diff --git a/apps/api-service/src/routes/cdn.ts b/apps/api-service/src/routes/cdn.ts index 65f737c..ec897f5 100644 --- a/apps/api-service/src/routes/cdn.ts +++ b/apps/api-service/src/routes/cdn.ts @@ -144,19 +144,19 @@ cdnRouter.get( } // Download image from storage + // Storage key format: {orgSlug}/{projectSlug}/img/{imageId} const storageService = await StorageFactory.getInstance(); const keyParts = image.storageKey.split('/'); - if (keyParts.length < 4) { + if (keyParts.length < 4 || keyParts[2] !== 'img') { throw new Error('Invalid storage key format'); } - const orgId = keyParts[0]!; - const projectId = keyParts[1]!; - const category = keyParts[2]! as 'uploads' | 'generated' | 'references'; - const filename = keyParts.slice(3).join('/'); + const storedOrgSlug = keyParts[0]!; + const storedProjectSlug = keyParts[1]!; + const imageId = keyParts[3]!; - const buffer = await storageService.downloadFile(orgId, projectId, category, filename); + const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId); // Set headers res.setHeader('Content-Type', image.mimeType); @@ -345,19 +345,19 @@ cdnRouter.get( if (cachedImage) { // Cache HIT - serve existing image + // Storage key format: {orgSlug}/{projectSlug}/img/{imageId} const storageService = await StorageFactory.getInstance(); const keyParts = cachedImage.storageKey.split('/'); - if (keyParts.length < 4) { + if (keyParts.length < 4 || keyParts[2] !== 'img') { throw new Error('Invalid storage key format'); } - const orgId = keyParts[0]!; - const projectId = keyParts[1]!; - const category = keyParts[2]! as 'uploads' | 'generated' | 'references'; - const filename = keyParts.slice(3).join('/'); + const storedOrgSlug = keyParts[0]!; + const storedProjectSlug = keyParts[1]!; + const imageId = keyParts[3]!; - const buffer = await storageService.downloadFile(orgId, projectId, category, filename); + const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId); // Set headers res.setHeader('Content-Type', cachedImage.mimeType); @@ -445,19 +445,19 @@ cdnRouter.get( }); // Download newly generated image + // Storage key format: {orgSlug}/{projectSlug}/img/{imageId} const storageService = await StorageFactory.getInstance(); const keyParts = generation.outputImage.storageKey.split('/'); - if (keyParts.length < 4) { + if (keyParts.length < 4 || keyParts[2] !== 'img') { throw new Error('Invalid storage key format'); } - const orgId = keyParts[0]!; - const projectId = keyParts[1]!; - const category = keyParts[2]! as 'uploads' | 'generated' | 'references'; - const filename = keyParts.slice(3).join('/'); + const storedOrgSlug = keyParts[0]!; + const storedProjectSlug = keyParts[1]!; + const imageId = keyParts[3]!; - const buffer = await storageService.downloadFile(orgId, projectId, category, filename); + const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId); // Set headers res.setHeader('Content-Type', generation.outputImage.mimeType); diff --git a/apps/api-service/src/routes/images.ts b/apps/api-service/src/routes/images.ts index 225c069..a297790 100644 --- a/apps/api-service/src/routes/images.ts +++ b/apps/api-service/src/routes/images.ts @@ -9,16 +9,17 @@ import { rateLimitByApiKey } from '../middleware/auth/rateLimiter'; export const imagesRouter: RouterType = Router(); /** - * GET /api/images/:orgId/:projectId/:category/:filename - * Serves images via presigned URLs (redirect approach) + * GET /api/images/:orgSlug/:projectSlug/img/:imageId + * Serves images directly (streaming approach) + * New format: {orgSlug}/{projectSlug}/img/{imageId} */ imagesRouter.get( - '/images/:orgId/:projectId/:category/:filename', + '/images/:orgSlug/:projectSlug/img/:imageId', asyncHandler(async (req: Request, res: Response): Promise => { - const { orgId, projectId, category, filename } = req.params; + const { orgSlug, projectSlug, imageId } = req.params; // Validate required params (these are guaranteed by route pattern) - if (!orgId || !projectId || !category || !filename) { + if (!orgSlug || !projectSlug || !imageId) { res.status(400).json({ success: false, message: 'Missing required parameters', @@ -26,25 +27,11 @@ imagesRouter.get( return; } - // Validate category - if (!['uploads', 'generated', 'references'].includes(category)) { - res.status(400).json({ - success: false, - message: 'Invalid category', - }); - return; - } - const storageService = await StorageFactory.getInstance(); try { // Check if file exists first (fast check) - const exists = await storageService.fileExists( - orgId, - projectId, - category as 'uploads' | 'generated' | 'references', - filename, - ); + const exists = await storageService.fileExists(orgSlug, projectSlug, imageId); if (!exists) { res.status(404).json({ @@ -54,37 +41,20 @@ imagesRouter.get( return; } - // Determine content type from filename - const ext = filename.toLowerCase().split('.').pop(); - const contentType = - { - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - }[ext || ''] || 'application/octet-stream'; - // Set headers for optimal caching and performance - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable - res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag + // Note: Content-Type will be set from MinIO metadata + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year + immutable + res.setHeader('ETag', `"${imageId}"`); // UUID as ETag // Handle conditional requests (304 Not Modified) const ifNoneMatch = req.headers['if-none-match']; - if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) { + if (ifNoneMatch === `"${imageId}"`) { res.status(304).end(); // Not Modified return; } // Stream the file directly through our API (memory efficient) - const fileStream = await storageService.streamFile( - orgId, - projectId, - category as 'uploads' | 'generated' | 'references', - filename, - ); + const fileStream = await storageService.streamFile(orgSlug, projectSlug, imageId); // Handle stream errors fileStream.on('error', (streamError) => { @@ -110,17 +80,17 @@ imagesRouter.get( ); /** - * GET /api/images/url/:orgId/:projectId/:category/:filename + * GET /api/images/url/:orgSlug/:projectSlug/img/:imageId * Returns a presigned URL instead of redirecting */ imagesRouter.get( - '/images/url/:orgId/:projectId/:category/:filename', + '/images/url/:orgSlug/:projectSlug/img/:imageId', asyncHandler(async (req: Request, res: Response): Promise => { - const { orgId, projectId, category, filename } = req.params; + const { orgSlug, projectSlug, imageId } = req.params; const { expiry = '3600' } = req.query; // Default 1 hour // Validate required params (these are guaranteed by route pattern) - if (!orgId || !projectId || !category || !filename) { + if (!orgSlug || !projectSlug || !imageId) { res.status(400).json({ success: false, message: 'Missing required parameters', @@ -128,22 +98,13 @@ imagesRouter.get( return; } - if (!['uploads', 'generated', 'references'].includes(category)) { - res.status(400).json({ - success: false, - message: 'Invalid category', - }); - return; - } - const storageService = await StorageFactory.getInstance(); try { const presignedUrl = await storageService.getPresignedDownloadUrl( - orgId, - projectId, - category as 'uploads' | 'generated' | 'references', - filename, + orgSlug, + projectSlug, + imageId, parseInt(expiry as string, 10), ); @@ -191,19 +152,19 @@ imagesRouter.get( } // Extract org/project from validated API key - const orgId = req.apiKey?.organizationSlug || 'default'; - const projectId = req.apiKey?.projectSlug!; + const orgSlug = req.apiKey?.organizationSlug || 'default'; + const projectSlug = req.apiKey?.projectSlug!; console.log( - `[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`, + `[${timestamp}] [${requestId}] Listing images for org:${orgSlug}, project:${projectSlug}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`, ); try { // Get storage service instance const storageService = await StorageFactory.getInstance(); - // List files in generated category - const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix); + // List files in img folder + const allFiles = await storageService.listFiles(orgSlug, projectSlug, prefix); // Sort by lastModified descending (newest first) allFiles.sort((a, b) => { @@ -218,8 +179,8 @@ imagesRouter.get( // Map to response format with public URLs const images = paginatedFiles.map((file) => ({ - filename: file.filename, - url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename), + imageId: file.filename, + url: storageService.getPublicUrl(orgSlug, projectSlug, file.filename), size: file.size, contentType: file.contentType, lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(), @@ -228,7 +189,7 @@ imagesRouter.get( const hasMore = offset + limit < total; console.log( - `[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`, + `[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} images`, ); return res.status(200).json({ @@ -242,11 +203,11 @@ imagesRouter.get( }, }); } catch (error) { - console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error); + console.error(`[${timestamp}] [${requestId}] Failed to list images:`, error); return res.status(500).json({ success: false, - message: 'Failed to list generated images', + message: 'Failed to list images', error: error instanceof Error ? error.message : 'Unknown error occurred', }); } diff --git a/apps/api-service/src/routes/textToImage.ts b/apps/api-service/src/routes/textToImage.ts index a5f9ecb..dd82fa3 100644 --- a/apps/api-service/src/routes/textToImage.ts +++ b/apps/api-service/src/routes/textToImage.ts @@ -1,5 +1,6 @@ import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; +import { randomUUID } from 'crypto'; import { ImageGenService } from '../services/ImageGenService'; import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation'; import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement'; @@ -48,14 +49,17 @@ textToImageRouter.post( const timestamp = new Date().toISOString(); const requestId = req.requestId; - const { prompt, filename, aspectRatio, meta } = req.body; + const { prompt, aspectRatio, meta } = req.body; // Extract org/project slugs from validated API key - const orgId = req.apiKey?.organizationSlug || undefined; - const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware + const orgSlug = req.apiKey?.organizationSlug || undefined; + const projectSlug = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware + + // Generate imageId (UUID) - this will be the filename in storage + const imageId = randomUUID(); console.log( - `[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgId}, project:${projectId}`, + `[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgSlug}, project:${projectSlug}`, ); try { @@ -66,10 +70,10 @@ textToImageRouter.post( const result = await imageGenService.generateImage({ prompt, - filename, + imageId, ...(aspectRatio && { aspectRatio }), - orgId, - projectId, + orgSlug, + projectSlug, ...(meta && { meta }), }); @@ -77,7 +81,7 @@ textToImageRouter.post( console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, { success: result.success, model: result.model, - filename: result.filename, + imageId: result.imageId, hasError: !!result.error, }); @@ -87,7 +91,7 @@ textToImageRouter.post( success: true, message: 'Image generated successfully', data: { - filename: result.filename!, + filename: result.imageId!, filepath: result.filepath!, ...(result.url && { url: result.url }), ...(result.description && { description: result.description }), diff --git a/apps/api-service/src/routes/upload.ts b/apps/api-service/src/routes/upload.ts index 8210fc6..e8bc848 100644 --- a/apps/api-service/src/routes/upload.ts +++ b/apps/api-service/src/routes/upload.ts @@ -1,5 +1,6 @@ import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; +import { randomUUID } from 'crypto'; import { StorageFactory } from '../services/StorageFactory'; import { asyncHandler } from '../middleware/errorHandler'; import { validateApiKey } from '../middleware/auth/validateApiKey'; @@ -53,18 +54,22 @@ uploadRouter.post( // Initialize storage service const storageService = await StorageFactory.getInstance(); - // Upload file to MinIO in 'uploads' category + // Generate imageId (UUID) - this will be the filename in storage + const imageId = randomUUID(); + + // Upload file to MinIO + // Path format: {orgSlug}/{projectSlug}/img/{imageId} console.log( - `[${timestamp}] [${requestId}] Uploading file: ${file.originalname} (${file.size} bytes)`, + `[${timestamp}] [${requestId}] Uploading file: ${file.originalname} as ${imageId} (${file.size} bytes)`, ); const uploadResult = await storageService.uploadFile( orgSlug, projectSlug, - 'uploads', - file.originalname, + imageId, file.buffer, file.mimetype, + file.originalname, ); if (!uploadResult.success) { diff --git a/apps/api-service/src/routes/v1/live.ts b/apps/api-service/src/routes/v1/live.ts index 9c1474c..4dc4bc0 100644 --- a/apps/api-service/src/routes/v1/live.ts +++ b/apps/api-service/src/routes/v1/live.ts @@ -88,23 +88,21 @@ liveRouter.get( const storageService = await StorageFactory.getInstance(); // Parse storage key to get components - // Format: orgId/projectId/category/filename.ext + // Format: {orgSlug}/{projectSlug}/img/{imageId} const keyParts = image.storageKey.split('/'); - if (keyParts.length < 4) { + if (keyParts.length < 4 || keyParts[2] !== 'img') { throw new Error('Invalid storage key format'); } - const orgId = keyParts[0]; - const projectIdSlug = keyParts[1]; - const category = keyParts[2] as 'uploads' | 'generated' | 'references'; - const filename = keyParts.slice(3).join('/'); + const storedOrgSlug = keyParts[0]!; + const storedProjectSlug = keyParts[1]!; + const imageId = keyParts[3]!; // Download image from storage const buffer = await storageService.downloadFile( - orgId!, - projectIdSlug!, - category, - filename! + storedOrgSlug, + storedProjectSlug, + imageId ); // Set cache headers @@ -157,22 +155,20 @@ liveRouter.get( // Download newly generated image const storageService = await StorageFactory.getInstance(); - // Format: orgId/projectId/category/filename.ext + // Format: {orgSlug}/{projectSlug}/img/{imageId} const keyParts = generation.outputImage.storageKey.split('/'); - if (keyParts.length < 4) { + if (keyParts.length < 4 || keyParts[2] !== 'img') { throw new Error('Invalid storage key format'); } - const orgId = keyParts[0]; - const projectIdSlug = keyParts[1]; - const category = keyParts[2] as 'uploads' | 'generated' | 'references'; - const filename = keyParts.slice(3).join('/'); + const storedOrgSlug = keyParts[0]!; + const storedProjectSlug = keyParts[1]!; + const imageId = keyParts[3]!; const buffer = await storageService.downloadFile( - orgId!, - projectIdSlug!, - category, - filename! + storedOrgSlug, + storedProjectSlug, + imageId ); // Set cache headers diff --git a/apps/api-service/src/services/ImageGenService.ts b/apps/api-service/src/services/ImageGenService.ts index 827d8e6..fc73ed8 100644 --- a/apps/api-service/src/services/ImageGenService.ts +++ b/apps/api-service/src/services/ImageGenService.ts @@ -29,11 +29,11 @@ export class ImageGenService { * This method separates image generation from storage for clear error handling */ async generateImage(options: ImageGenerationOptions): Promise { - const { prompt, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options; + const { prompt, imageId, referenceImages, aspectRatio, orgSlug, projectSlug, meta } = options; // Use default values if not provided - const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default'; - const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main'; + const finalOrgSlug = orgSlug || process.env['DEFAULT_ORG_SLUG'] || 'default'; + const finalProjectSlug = projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; const finalAspectRatio = aspectRatio || '1:1'; // Default to square // Step 1: Generate image from Gemini AI @@ -44,8 +44,8 @@ export class ImageGenService { prompt, referenceImages, finalAspectRatio, - finalOrgId, - finalProjectId, + finalOrgSlug, + finalProjectSlug, meta, ); generatedData = aiResult.generatedData; @@ -61,22 +61,25 @@ export class ImageGenService { } // Step 2: Save generated image to storage + // Path format: {orgSlug}/{projectSlug}/img/{imageId} try { - const finalFilename = `${filename}.${generatedData.fileExtension}`; const storageService = await StorageFactory.getInstance(); + // Original filename for metadata (e.g., "my-image.png") + const originalFilename = `generated-image.${generatedData.fileExtension}`; + const uploadResult = await storageService.uploadFile( - finalOrgId, - finalProjectId, - 'generated', - finalFilename, + finalOrgSlug, + finalProjectSlug, + imageId, generatedData.buffer, generatedData.mimeType, + originalFilename, ); if (uploadResult.success) { return { success: true, - filename: uploadResult.filename, + imageId: uploadResult.filename, filepath: uploadResult.path, url: uploadResult.url, size: uploadResult.size, @@ -125,8 +128,8 @@ export class ImageGenService { prompt: string, referenceImages: ReferenceImage[] | undefined, aspectRatio: string, - orgId: string, - projectId: string, + orgSlug: string, + projectSlug: string, meta?: { tags?: string[] }, ): Promise<{ generatedData: GeneratedImageData; @@ -182,8 +185,8 @@ export class ImageGenService { const ttiLogger = TTILogger.getInstance(); const logEntry: TTILogEntry = { timestamp: new Date().toISOString(), - orgId, - projectId, + orgId: orgSlug, + projectId: projectSlug, prompt, model: this.primaryModel, config, diff --git a/apps/api-service/src/services/MinioStorageService.ts b/apps/api-service/src/services/MinioStorageService.ts index 6e15b5a..7a72744 100644 --- a/apps/api-service/src/services/MinioStorageService.ts +++ b/apps/api-service/src/services/MinioStorageService.ts @@ -4,7 +4,7 @@ import { StorageService, FileMetadata, UploadResult } from './StorageService'; export class MinioStorageService implements StorageService { private client: MinioClient; private bucketName: string; - private publicUrl: string; + private cdnBaseUrl: string; constructor( endpoint: string, @@ -12,7 +12,7 @@ export class MinioStorageService implements StorageService { secretKey: string, useSSL: boolean = false, bucketName: string = 'banatie', - publicUrl?: string, + cdnBaseUrl?: string, ) { // Parse endpoint to separate hostname and port const cleanEndpoint = endpoint.replace(/^https?:\/\//, ''); @@ -31,119 +31,59 @@ export class MinioStorageService implements StorageService { secretKey, }); this.bucketName = bucketName; - this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`; + // CDN base URL without bucket name (e.g., https://cdn.banatie.app) + this.cdnBaseUrl = cdnBaseUrl || process.env['CDN_BASE_URL'] || `${useSSL ? 'https' : 'http'}://${endpoint}/${bucketName}`; } - private getFilePath( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, - ): string { - // Simplified path without date folder for now - return `${orgId}/${projectId}/${category}/${filename}`; + /** + * Get file path in storage + * Format: {orgSlug}/{projectSlug}/img/{imageId} + */ + private getFilePath(orgSlug: string, projectSlug: string, imageId: string): string { + return `${orgSlug}/${projectSlug}/img/${imageId}`; } - private generateUniqueFilename(originalFilename: string): string { - // Sanitize filename first - const sanitized = this.sanitizeFilename(originalFilename); - - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 8); - const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : ''; - const name = sanitized.includes('.') - ? sanitized.substring(0, sanitized.lastIndexOf('.')) - : sanitized; - - return `${name}-${timestamp}-${random}${ext}`; + /** + * Extract file extension from original filename + */ + private extractExtension(filename: string): string | undefined { + if (!filename) return undefined; + const lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex <= 0) return undefined; + return filename.substring(lastDotIndex + 1).toLowerCase(); } - private sanitizeFilename(filename: string): string { - // Remove path traversal attempts FIRST from entire filename - let cleaned = filename.replace(/\.\./g, '').trim(); - - // Split filename and extension - const lastDotIndex = cleaned.lastIndexOf('.'); - let baseName = lastDotIndex > 0 ? cleaned.substring(0, lastDotIndex) : cleaned; - const extension = lastDotIndex > 0 ? cleaned.substring(lastDotIndex) : ''; - - // Remove dangerous characters from base name - baseName = baseName - .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars - .trim(); - - // Replace non-ASCII characters with ASCII equivalents or remove them - // This prevents S3 signature mismatches with MinIO - baseName = baseName - .normalize('NFD') // Decompose combined characters (é -> e + ´) - .replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks - .replace(/[^\x20-\x7E]/g, '_') // Replace any remaining non-ASCII with underscore - .replace(/[^\w\s\-_.]/g, '_') // Replace special chars (except word chars, space, dash, underscore, dot) with underscore - .replace(/\s+/g, '_') // Replace spaces with underscores - .replace(/_{2,}/g, '_') // Collapse multiple underscores - .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores - - // Ensure we still have a valid base name - if (baseName.length === 0) { - baseName = 'file'; - } - - // Sanitize extension (remove only dangerous chars, keep the dot) - let sanitizedExt = extension - .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') - .replace(/[^\x20-\x7E]/g, '') - .toLowerCase(); - - // Ensure extension starts with a dot and is reasonable - if (sanitizedExt && !sanitizedExt.startsWith('.')) { - sanitizedExt = '.' + sanitizedExt; - } - if (sanitizedExt.length > 10) { - sanitizedExt = sanitizedExt.substring(0, 10); - } - - const result = baseName + sanitizedExt; - return result.substring(0, 255); // Limit total length - } - - private validateFilePath( - orgId: string, - projectId: string, - category: string, - filename: string, - ): void { - // Validate orgId - if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) { + /** + * Validate storage path components + */ + private validatePath(orgSlug: string, projectSlug: string, imageId: string): void { + // Validate orgSlug + if (!orgSlug || !/^[a-zA-Z0-9_-]+$/.test(orgSlug) || orgSlug.length > 50) { throw new Error( - 'Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars', + 'Invalid organization slug: must be alphanumeric with dashes/underscores, max 50 chars', ); } - // Validate projectId - if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) { + // Validate projectSlug + if (!projectSlug || !/^[a-zA-Z0-9_-]+$/.test(projectSlug) || projectSlug.length > 50) { throw new Error( - 'Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars', + 'Invalid project slug: must be alphanumeric with dashes/underscores, max 50 chars', ); } - // Validate category - if (!['uploads', 'generated', 'references'].includes(category)) { - throw new Error('Invalid category: must be uploads, generated, or references'); - } - - // Validate filename - if (!filename || filename.length === 0 || filename.length > 255) { - throw new Error('Invalid filename: must be 1-255 characters'); + // Validate imageId (UUID format) + if (!imageId || imageId.length === 0 || imageId.length > 50) { + throw new Error('Invalid imageId: must be 1-50 characters'); } // Check for path traversal and dangerous patterns - if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - throw new Error('Invalid characters in filename: path traversal not allowed'); + if (imageId.includes('..') || imageId.includes('/') || imageId.includes('\\')) { + throw new Error('Invalid characters in imageId: path traversal not allowed'); } // Prevent null bytes and control characters - if (/[\x00-\x1f]/.test(filename)) { - throw new Error('Invalid filename: control characters not allowed'); + if (/[\x00-\x1f]/.test(imageId)) { + throw new Error('Invalid imageId: control characters not allowed'); } } @@ -154,8 +94,8 @@ export class MinioStorageService implements StorageService { console.log(`Created bucket: ${this.bucketName}`); } - // Note: With SNMD and presigned URLs, we don't need bucket policies - console.log(`Bucket ${this.bucketName} ready for presigned URL access`); + // Bucket should be public for CDN access (configured via mc anonymous set download) + console.log(`Bucket ${this.bucketName} ready for CDN access`); } async bucketExists(): Promise { @@ -163,15 +103,15 @@ export class MinioStorageService implements StorageService { } async uploadFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, buffer: Buffer, contentType: string, + originalFilename?: string, ): Promise { // Validate inputs first - this.validateFilePath(orgId, projectId, category, filename); + this.validatePath(orgSlug, projectSlug, imageId); if (!buffer || buffer.length === 0) { throw new Error('Buffer cannot be empty'); @@ -184,24 +124,34 @@ export class MinioStorageService implements StorageService { // Ensure bucket exists await this.createBucket(); - // Generate unique filename to avoid conflicts - const uniqueFilename = this.generateUniqueFilename(filename); - const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename); + // Get file path: {orgSlug}/{projectSlug}/img/{imageId} + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); + + // Extract file extension from original filename + const fileExtension = originalFilename ? this.extractExtension(originalFilename) : undefined; // Encode original filename to Base64 to safely store non-ASCII characters in metadata - const originalNameEncoded = Buffer.from(filename, 'utf-8').toString('base64'); + const originalNameEncoded = originalFilename + ? Buffer.from(originalFilename, 'utf-8').toString('base64') + : undefined; - const metadata = { + const metadata: Record = { 'Content-Type': contentType, - 'X-Amz-Meta-Original-Name': originalNameEncoded, - 'X-Amz-Meta-Original-Name-Encoding': 'base64', - 'X-Amz-Meta-Category': category, - 'X-Amz-Meta-Project': projectId, - 'X-Amz-Meta-Organization': orgId, + 'X-Amz-Meta-Project': projectSlug, + 'X-Amz-Meta-Organization': orgSlug, 'X-Amz-Meta-Upload-Time': new Date().toISOString(), }; - console.log(`Uploading file to: ${this.bucketName}/${filePath}`); + if (originalNameEncoded) { + metadata['X-Amz-Meta-Original-Name'] = originalNameEncoded; + metadata['X-Amz-Meta-Original-Name-Encoding'] = 'base64'; + } + + if (fileExtension) { + metadata['X-Amz-Meta-File-Extension'] = fileExtension; + } + + console.log(`[MinIO] Uploading file to: ${this.bucketName}/${filePath}`); await this.client.putObject( this.bucketName, @@ -211,28 +161,29 @@ export class MinioStorageService implements StorageService { metadata, ); - const url = this.getPublicUrl(orgId, projectId, category, uniqueFilename); + const url = this.getPublicUrl(orgSlug, projectSlug, imageId); - console.log(`Generated API URL: ${url}`); + console.log(`[MinIO] CDN URL: ${url}`); return { success: true, - filename: uniqueFilename, + filename: imageId, path: filePath, url, size: buffer.length, contentType, + ...(originalFilename && { originalFilename }), + ...(fileExtension && { fileExtension }), }; } async downloadFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): Promise { - this.validateFilePath(orgId, projectId, category, filename); - const filePath = this.getFilePath(orgId, projectId, category, filename); + this.validatePath(orgSlug, projectSlug, imageId); + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const stream = await this.client.getObject(this.bucketName, filePath); @@ -245,203 +196,91 @@ export class MinioStorageService implements StorageService { } async streamFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): Promise { - this.validateFilePath(orgId, projectId, category, filename); - const filePath = this.getFilePath(orgId, projectId, category, filename); + this.validatePath(orgSlug, projectSlug, imageId); + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); // Return the stream directly without buffering - memory efficient! return await this.client.getObject(this.bucketName, filePath); } async deleteFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): Promise { - this.validateFilePath(orgId, projectId, category, filename); - const filePath = this.getFilePath(orgId, projectId, category, filename); + this.validatePath(orgSlug, projectSlug, imageId); + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); await this.client.removeObject(this.bucketName, filePath); } /** - * Get public URL for file access - * Returns CDN URL if MINIO_PUBLIC_URL is configured (production), - * otherwise falls back to API endpoint URL (development) - * - * @returns {string} URL for accessing the file + * Get public CDN URL for file access + * Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId} */ - getPublicUrl( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, - ): string { - this.validateFilePath(orgId, projectId, category, filename); - - // If MINIO_PUBLIC_URL is configured, use direct CDN access - // This provides better performance and reduces API server load - if (this.publicUrl && process.env['USE_DIRECT_CDN'] !== 'false') { - const filePath = this.getFilePath(orgId, projectId, category, filename); - const cdnUrl = `${this.publicUrl}/${this.bucketName}/${filePath}`; - console.log(`[MinIO] Using CDN URL: ${cdnUrl}`); - return cdnUrl; - } - - // Fallback to API URL for local development or when CDN is disabled - const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000'; - const apiUrl = `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`; - console.log(`[MinIO] Using API URL: ${apiUrl}`); - return apiUrl; + getPublicUrl(orgSlug: string, projectSlug: string, imageId: string): string { + this.validatePath(orgSlug, projectSlug, imageId); + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); + return `${this.cdnBaseUrl}/${filePath}`; } async getPresignedUploadUrl( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, expirySeconds: number, contentType: string, ): Promise { - this.validateFilePath(orgId, projectId, category, filename); + this.validatePath(orgSlug, projectSlug, imageId); if (!contentType || contentType.trim().length === 0) { throw new Error('Content type is required for presigned upload URL'); } - const filePath = this.getFilePath(orgId, projectId, category, filename); + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds); } async getPresignedDownloadUrl( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, expirySeconds: number = 86400, // 24 hours default ): Promise { - this.validateFilePath(orgId, projectId, category, filename); - const filePath = this.getFilePath(orgId, projectId, category, filename); + this.validatePath(orgSlug, projectSlug, imageId); + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const presignedUrl = await this.client.presignedGetObject( this.bucketName, filePath, expirySeconds, ); - // Replace internal Docker hostname with public URL if configured - if (this.publicUrl) { + // Replace internal Docker hostname with CDN URL if configured + if (this.cdnBaseUrl) { // Access protected properties via type assertion for URL replacement const client = this.client as unknown as { host: string; port: number; protocol: string }; const clientEndpoint = client.host + (client.port ? `:${client.port}` : ''); - return presignedUrl.replace(`${client.protocol}//${clientEndpoint}`, this.publicUrl); + return presignedUrl.replace(`${client.protocol}//${clientEndpoint}/${this.bucketName}`, this.cdnBaseUrl); } return presignedUrl; } - async listProjectFiles( - orgId: string, - projectId: string, - category?: 'uploads' | 'generated' | 'references', - ): Promise { - const prefix = category ? `${orgId}/${projectId}/${category}/` : `${orgId}/${projectId}/`; - - const files: FileMetadata[] = []; - - return new Promise((resolve, reject) => { - const stream = this.client.listObjects(this.bucketName, prefix, true); - - stream.on('data', async (obj) => { - try { - if (!obj.name) return; - - const metadata = await this.client.statObject(this.bucketName, obj.name); - - const pathParts = obj.name.split('/'); - const filename = pathParts[pathParts.length - 1]; - const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references'; - - if (!filename || !categoryFromPath) { - return; - } - - files.push({ - filename, - contentType: metadata.metaData?.['content-type'] || 'application/octet-stream', - size: obj.size || 0, - lastModified: obj.lastModified || new Date(), - path: obj.name, - }); - } catch (error) {} - }); - - stream.on('end', () => resolve(files)); - stream.on('error', reject); - }); - } - - parseKey(key: string): { - orgId: string; - projectId: string; - category: 'uploads' | 'generated' | 'references'; - filename: string; - } | null { - try { - const match = key.match( - /^banatie\/([^/]+)\/([^/]+)\/(uploads|generated|references)\/[^/]+\/(.+)$/, - ); - - if (!match) { - return null; - } - - const [, orgId, projectId, category, filename] = match; - - if (!orgId || !projectId || !category || !filename) { - return null; - } - - return { - orgId, - projectId, - category: category as 'uploads' | 'generated' | 'references', - filename, - }; - } catch { - return null; - } - } - - async fileExists( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, - ): Promise { - try { - this.validateFilePath(orgId, projectId, category, filename); - const filePath = this.getFilePath(orgId, projectId, category, filename); - await this.client.statObject(this.bucketName, filePath); - return true; - } catch (error) { - return false; - } - } - + /** + * List files in a project's img folder + */ async listFiles( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', + orgSlug: string, + projectSlug: string, prefix?: string, ): Promise { - this.validateFilePath(orgId, projectId, category, 'dummy.txt'); + this.validatePath(orgSlug, projectSlug, 'dummy'); - const basePath = `${orgId}/${projectId}/${category}/`; + const basePath = `${orgSlug}/${projectSlug}/img/`; const searchPrefix = prefix ? `${basePath}${prefix}` : basePath; const files: FileMetadata[] = []; @@ -449,31 +288,22 @@ export class MinioStorageService implements StorageService { return new Promise((resolve, reject) => { const stream = this.client.listObjects(this.bucketName, searchPrefix, true); - stream.on('data', (obj) => { + stream.on('data', async (obj) => { if (!obj.name || !obj.size) return; try { const pathParts = obj.name.split('/'); - const filename = pathParts[pathParts.length - 1]; + const imageId = pathParts[pathParts.length - 1]; - if (!filename) return; + if (!imageId) return; - // Infer content type from file extension (more efficient than statObject) - const ext = filename.toLowerCase().split('.').pop(); - const contentType = - { - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - }[ext || ''] || 'application/octet-stream'; + // Get metadata to find content type (no extension in filename) + const metadata = await this.client.statObject(this.bucketName, obj.name); files.push({ - filename, + filename: imageId!, size: obj.size, - contentType, + contentType: metadata.metaData?.['content-type'] || 'application/octet-stream', lastModified: obj.lastModified || new Date(), etag: obj.etag || '', path: obj.name, @@ -493,4 +323,52 @@ export class MinioStorageService implements StorageService { }); }); } + + /** + * Parse storage key to extract components + * Format: {orgSlug}/{projectSlug}/img/{imageId} + */ + parseKey(key: string): { + orgSlug: string; + projectSlug: string; + imageId: string; + } | null { + try { + // Match: orgSlug/projectSlug/img/imageId + const match = key.match(/^([^/]+)\/([^/]+)\/img\/([^/]+)$/); + + if (!match) { + return null; + } + + const [, orgSlug, projectSlug, imageId] = match; + + if (!orgSlug || !projectSlug || !imageId) { + return null; + } + + return { + orgSlug, + projectSlug, + imageId, + }; + } catch { + return null; + } + } + + async fileExists( + orgSlug: string, + projectSlug: string, + imageId: string, + ): Promise { + try { + this.validatePath(orgSlug, projectSlug, imageId); + const filePath = this.getFilePath(orgSlug, projectSlug, imageId); + await this.client.statObject(this.bucketName, filePath); + return true; + } catch (error) { + return false; + } + } } diff --git a/apps/api-service/src/services/StorageService.ts b/apps/api-service/src/services/StorageService.ts index f721b97..12e0ca5 100644 --- a/apps/api-service/src/services/StorageService.ts +++ b/apps/api-service/src/services/StorageService.ts @@ -11,11 +11,13 @@ export interface FileMetadata { export interface UploadResult { success: boolean; - filename: string; + filename: string; // UUID (same as image.id) path: string; - url: string; // API URL for accessing the file + url: string; // CDN URL for accessing the file size: number; contentType: string; + originalFilename?: string; // User's original filename + fileExtension?: string; // Original extension (png, jpg, etc.) error?: string; } @@ -32,137 +34,125 @@ export interface StorageService { /** * Upload a file to storage - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category (uploads, generated, references) - * @param filename Original filename + * Path format: {orgSlug}/{projectSlug}/img/{imageId} + * + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID for the file (same as image.id in DB) * @param buffer File buffer * @param contentType MIME type + * @param originalFilename Original filename from user (for metadata) */ uploadFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, buffer: Buffer, contentType: string, + originalFilename?: string, ): Promise; /** * Download a file from storage - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category - * @param filename Filename to download + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID filename */ downloadFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): Promise; /** * Stream a file from storage (memory efficient) - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category - * @param filename Filename to stream + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID filename */ streamFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): Promise; /** * Generate a presigned URL for downloading a file - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category - * @param filename Filename + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID filename * @param expirySeconds URL expiry time in seconds */ getPresignedDownloadUrl( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, expirySeconds: number, ): Promise; /** * Generate a presigned URL for uploading a file - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category - * @param filename Filename + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID filename * @param expirySeconds URL expiry time in seconds * @param contentType MIME type */ getPresignedUploadUrl( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, expirySeconds: number, contentType: string, ): Promise; /** - * List files in a specific path - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category + * List files in a project's img folder + * @param orgSlug Organization slug + * @param projectSlug Project slug * @param prefix Optional prefix to filter files */ listFiles( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', + orgSlug: string, + projectSlug: string, prefix?: string, ): Promise; /** * Delete a file from storage - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category - * @param filename Filename to delete + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID filename to delete */ deleteFile( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): Promise; /** * Check if a file exists - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category - * @param filename Filename to check + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID filename to check */ fileExists( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): Promise; /** - * Get the public URL for a file - * @param orgId Organization ID - * @param projectId Project ID - * @param category File category - * @param filename Filename + * Get the public CDN URL for a file + * Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId} + * + * @param orgSlug Organization slug + * @param projectSlug Project slug + * @param imageId UUID filename */ getPublicUrl( - orgId: string, - projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string, + orgSlug: string, + projectSlug: string, + imageId: string, ): string; } diff --git a/apps/api-service/src/services/core/GenerationService.ts b/apps/api-service/src/services/core/GenerationService.ts index 5bee6b5..0d4073d 100644 --- a/apps/api-service/src/services/core/GenerationService.ts +++ b/apps/api-service/src/services/core/GenerationService.ts @@ -148,13 +148,16 @@ export class GenerationService { .where(eq(generations.id, generation.id)); } + // Generate imageId (UUID) upfront - this will be the filename in storage + const imageId = randomUUID(); + const genResult = await this.imageGenService.generateImage({ prompt: usedPrompt, // Use the prompt that was stored (enhanced or original) - filename: `gen_${generation.id}`, + imageId, // UUID used as filename: {orgSlug}/{projectSlug}/img/{imageId} referenceImages: referenceImageBuffers, aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, - orgId: params.organizationSlug, // Use slug for storage path - projectId: params.projectSlug, // Use slug for storage path + orgSlug: params.organizationSlug, + projectSlug: params.projectSlug, meta: params.meta || {}, }); @@ -172,13 +175,14 @@ export class GenerationService { const fileHash = null; const imageRecord = await this.imageService.create({ + id: imageId, // Use the same UUID for image record projectId: params.projectId, flowId: finalFlowId, generationId: generation.id, apiKeyId: params.apiKeyId, storageKey, storageUrl: genResult.url!, - mimeType: 'image/jpeg', + mimeType: genResult.generatedImageData?.mimeType || 'image/png', fileSize: genResult.size || 0, fileHash, source: 'generated', @@ -186,6 +190,8 @@ export class GenerationService { meta: params.meta || {}, width: genResult.generatedImageData?.width ?? null, height: genResult.generatedImageData?.height ?? null, + originalFilename: `generated-image.${genResult.generatedImageData?.fileExtension || 'png'}`, + fileExtension: genResult.generatedImageData?.fileExtension || 'png', }); // Reassign project alias if provided (override behavior per Section 5.2) @@ -272,27 +278,22 @@ export class GenerationService { throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`); } + // Parse storage key: {orgSlug}/{projectSlug}/img/{imageId} const parts = resolution.image.storageKey.split('/'); - if (parts.length < 4) { + if (parts.length < 4 || parts[2] !== 'img') { throw new Error(`Invalid storage key format: ${resolution.image.storageKey}`); } - const orgId = parts[0]!; - const projId = parts[1]!; - const category = parts[2]! as 'uploads' | 'generated' | 'references'; - const filename = parts.slice(3).join('/'); + const orgSlug = parts[0]!; + const projectSlug = parts[1]!; + const imageId = parts[3]!; - const buffer = await storageService.downloadFile( - orgId, - projId, - category, - filename - ); + const buffer = await storageService.downloadFile(orgSlug, projectSlug, imageId); buffers.push({ buffer, mimetype: resolution.image.mimeType, - originalname: filename, + originalname: resolution.image.originalFilename || imageId, }); metadata.push({ @@ -517,14 +518,18 @@ export class GenerationService { // Get slugs for storage paths const { orgSlug, projectSlug } = await this.getSlugs(generation.projectId); + // Use the existing output image ID as the imageId for storage + // This ensures the file is overwritten at the same path + const imageId = generation.outputImageId; + // Use EXACT same parameters as original (no overrides) const genResult = await this.imageGenService.generateImage({ prompt: generation.prompt, - filename: `gen_${id}`, + imageId, // Same UUID to overwrite existing file referenceImages: [], // TODO: Re-resolve referenced images if needed aspectRatio: generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, - orgId: orgSlug, - projectId: projectSlug, + orgSlug, + projectSlug, meta: generation.meta as Record || {}, }); @@ -634,14 +639,17 @@ export class GenerationService { // Get slugs for storage paths const { orgSlug, projectSlug } = await this.getSlugs(generation.projectId); + // Use the existing output image ID as the imageId for storage + const imageId = generation.outputImageId!; + // Regenerate image const genResult = await this.imageGenService.generateImage({ prompt: promptToUse, - filename: `gen_${id}`, + imageId, // Same UUID to overwrite existing file referenceImages: [], aspectRatio: aspectRatioToUse, - orgId: orgSlug, - projectId: projectSlug, + orgSlug, + projectSlug, meta: updates.meta || generation.meta || {}, }); diff --git a/apps/api-service/src/services/core/ImageService.ts b/apps/api-service/src/services/core/ImageService.ts index 668fbfb..a161e67 100644 --- a/apps/api-service/src/services/core/ImageService.ts +++ b/apps/api-service/src/services/core/ImageService.ts @@ -154,16 +154,16 @@ export class ImageService { try { // 1. Delete physical file from MinIO storage + // Storage key format: {orgSlug}/{projectSlug}/img/{imageId} const storageService = await StorageFactory.getInstance(); const storageParts = image.storageKey.split('/'); - if (storageParts.length >= 4) { - const orgId = storageParts[0]!; - const projectId = storageParts[1]!; - const category = storageParts[2]! as 'uploads' | 'generated' | 'references'; - const filename = storageParts.slice(3).join('/'); + if (storageParts.length >= 4 && storageParts[2] === 'img') { + const orgSlug = storageParts[0]!; + const projectSlug = storageParts[1]!; + const imageId = storageParts[3]!; - await storageService.deleteFile(orgId, projectId, category, filename); + await storageService.deleteFile(orgSlug, projectSlug, imageId); } // 2. Cascade: Set outputImageId = NULL in related generations diff --git a/apps/api-service/src/types/api.ts b/apps/api-service/src/types/api.ts index ca08a54..76478e6 100644 --- a/apps/api-service/src/types/api.ts +++ b/apps/api-service/src/types/api.ts @@ -57,11 +57,11 @@ export interface GenerateImageRequestWithFiles extends Request { // Image generation service types export interface ImageGenerationOptions { prompt: string; - filename: string; + imageId: string; // UUID used as filename in storage (same as image.id in DB) referenceImages?: ReferenceImage[]; aspectRatio?: string; - orgId?: string; - projectId?: string; + orgSlug?: string; + projectSlug?: string; userId?: string; meta?: { tags?: string[]; @@ -91,9 +91,9 @@ export interface GeminiParams { export interface ImageGenerationResult { success: boolean; - filename?: string; + imageId?: string; // UUID filename (same as image.id in DB) filepath?: string; - url?: string; // API URL for accessing the image + url?: string; // CDN URL for accessing the image size?: number; // File size in bytes description?: string; model: string; diff --git a/docs/url-fix-cloudflare-site.md b/docs/url-fix-cloudflare-site.md new file mode 100644 index 0000000..b017112 --- /dev/null +++ b/docs/url-fix-cloudflare-site.md @@ -0,0 +1,158 @@ +# CDN URL Architecture Fix - Cloudflare Configuration + +This document describes the Cloudflare configuration for the new CDN URL architecture. + +## Domain Structure + +| Domain | Purpose | Cloudflare Proxy | +|--------|---------|------------------| +| cdn.banatie.app | CDN for images | Yes (orange cloud) | +| api.banatie.app | API server | Yes (orange cloud) | +| banatie.app | Landing page | Yes (orange cloud) | + +## Cache Rules + +### Rule 1: Cache UUID Images (High Priority) + +Cache static images with UUID filenames for maximum performance. + +**When:** Custom filter expression +``` +(http.host eq "cdn.banatie.app" and http.request.uri.path matches "^/[^/]+/[^/]+/img/[0-9a-f-]{36}$") +``` + +**Then:** +- Cache eligibility: Eligible for cache +- Edge TTL: Override origin, 7 days +- Browser TTL: Override origin, 1 year (31536000 seconds) +- Cache Key: Include query string = No + +### Rule 2: Bypass Cache for Aliases + +Aliases need dynamic resolution, bypass cache. + +**When:** Custom filter expression +``` +(http.host eq "cdn.banatie.app" and http.request.uri.path matches "^/[^/]+/[^/]+/img/@") +``` + +**Then:** +- Cache eligibility: Bypass cache + +### Rule 3: Bypass Cache for Live URLs + +Live URLs need dynamic generation, bypass cache. + +**When:** Custom filter expression +``` +(http.host eq "cdn.banatie.app" and http.request.uri.path matches "^/[^/]+/[^/]+/live/") +``` + +**Then:** +- Cache eligibility: Bypass cache + +## Page Rules (Alternative) + +If not using Cache Rules, use Page Rules: + +### Page Rule 1: Cache UUID Images +- URL: `cdn.banatie.app/*/img/*` +- Cache Level: Cache Everything +- Edge Cache TTL: 7 days +- Browser Cache TTL: 1 year + +### Page Rule 2: Bypass Aliases and Live +- URL: `cdn.banatie.app/*/img/@*` +- Cache Level: Bypass + +- URL: `cdn.banatie.app/*/live/*` +- Cache Level: Bypass + +## DNS Configuration + +Ensure DNS records point to your VPS: + +``` +cdn.banatie.app A YOUR_VPS_IP Proxied (orange cloud) +``` + +## SSL/TLS Configuration + +- SSL Mode: Full (strict) +- Always Use HTTPS: On +- Automatic HTTPS Rewrites: On +- Minimum TLS Version: TLS 1.2 + +## Performance Settings + +- Auto Minify: CSS, JavaScript (not HTML for API responses) +- Brotli: On +- Early Hints: On +- Rocket Loader: Off (may break API responses) + +## Security Settings + +- Security Level: Medium +- Challenge Passage: 30 minutes +- Browser Integrity Check: On + +## Rate Limiting (Optional) + +Create rate limiting rule for live URL abuse prevention: + +**Rule Name:** Live URL Rate Limit + +**When:** +``` +(http.host eq "cdn.banatie.app" and http.request.uri.path matches "^/[^/]+/[^/]+/live/") +``` + +**Then:** +- Rate limit: 10 requests per minute per IP +- Action: Block for 1 minute + +## Verification + +After configuration: + +1. **Test UUID caching:** + ```bash + curl -I "https://cdn.banatie.app/org/proj/img/uuid-here" + # Check for: cf-cache-status: HIT (on second request) + ``` + +2. **Test alias bypass:** + ```bash + curl -I "https://cdn.banatie.app/org/proj/img/@alias" + # Check for: cf-cache-status: DYNAMIC or BYPASS + ``` + +3. **Test live URL bypass:** + ```bash + curl -I "https://cdn.banatie.app/org/proj/live/scope?prompt=test" + # Check for: cf-cache-status: DYNAMIC or BYPASS + ``` + +## Troubleshooting + +### Images not caching +- Verify the URL matches UUID pattern (36 character UUID) +- Check Cache Rules order (UUID rule should be first) +- Purge cache and retry + +### Alias/Live URLs being cached +- Verify bypass rules are active +- Check rule order (bypass rules should run before catch-all) +- Development mode may disable caching + +### Slow first requests +- Expected behavior for cache MISS +- Subsequent requests from same edge should be HIT +- Consider using Cache Reserve for longer edge retention + +## Notes + +- UUID pattern ensures only static, immutable images are cached at edge +- Aliases and live URLs are always fresh from origin +- 1-year browser cache is safe because UUID = immutable content +- Cloudflare caches at edge, browser caches locally diff --git a/docs/url-fix-vps-site.md b/docs/url-fix-vps-site.md new file mode 100644 index 0000000..1221040 --- /dev/null +++ b/docs/url-fix-vps-site.md @@ -0,0 +1,152 @@ +# CDN URL Architecture Fix - VPS Deployment + +This document describes the changes needed on VPS to support the new CDN URL architecture. + +## Problem + +Previous URL structure used presigned URLs with 24-hour expiry, which doesn't work for permanent image embedding on websites. + +## Solution + +New URL structure with direct CDN access: +- `cdn.banatie.app/{org}/{proj}/img/{imageId}` - Direct MinIO access for static images +- `cdn.banatie.app/{org}/{proj}/img/@{alias}` - API-mediated alias resolution +- `cdn.banatie.app/{org}/{proj}/live/{scope}?prompt=...` - API-mediated live generation + +## Storage Path Format + +``` +Old: {orgSlug}/{projectSlug}/{category}/{timestamp-filename.ext} +New: {orgSlug}/{projectSlug}/img/{imageId} +``` + +Where `imageId` = UUID (same as `images.id` in database). + +## VPS Deployment Steps + +### 1. Update Caddy Configuration + +Add the following routing rules to your Caddy config: + +```caddyfile +# CDN Domain +cdn.banatie.app { + # UUID pattern - direct to MinIO (no extension in URL) + @uuid path_regexp uuid ^/([^/]+)/([^/]+)/img/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$ + handle @uuid { + reverse_proxy banatie-minio:9000 { + # Rewrite to bucket path + header_up Host cdn.banatie.app + rewrite * /banatie{uri} + } + } + + # Alias pattern (@name) - proxy to API + @alias path_regexp alias ^/([^/]+)/([^/]+)/img/@(.+)$ + handle @alias { + reverse_proxy banatie-api:3000 { + rewrite * /cdn{uri} + } + } + + # Live URL pattern - proxy to API + @live path_regexp live ^/([^/]+)/([^/]+)/live/(.+)$ + handle @live { + reverse_proxy banatie-api:3000 { + rewrite * /cdn{uri} + } + } + + # Fallback for other patterns + handle { + reverse_proxy banatie-minio:9000 { + header_up Host cdn.banatie.app + rewrite * /banatie{uri} + } + } +} +``` + +### 2. Update Environment Variables + +Add to `/opt/banatie/.env`: + +```env +CDN_BASE_URL=https://cdn.banatie.app +``` + +### 3. Reset Database and MinIO Storage + +Since this is a breaking change to the storage path format: + +```bash +# Stop services +cd /opt/banatie +docker compose down + +# Clean database (WARNING: deletes all data) +rm -rf /opt/banatie/data/postgres/* + +# Clean MinIO storage (WARNING: deletes all files) +rm -rf /opt/banatie/data/minio/drive{1,2,3,4}/* + +# Rebuild and start services +docker compose up -d --build +``` + +### 4. Run Storage Initialization + +After rebuild, the `banatie-storage-init` container will: +1. Create the `banatie` bucket +2. Configure service user with readwrite access +3. Enable public anonymous download access for CDN + +Verify public access is enabled: + +```bash +docker exec banatie-minio mc anonymous get local/banatie +# Should show: Access permission for `local/banatie` is `download` +``` + +## Verification + +### Test Direct UUID Access + +```bash +# After generating an image, get its UUID from database or API response +# Then test direct CDN access: +curl -I "https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{uuid}" + +# Expected: HTTP 200 with Content-Type: image/png (or similar) +``` + +### Test Alias Resolution + +```bash +# Assign an alias to an image via API, then test: +curl -I "https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/@hero" + +# Expected: HTTP 200 (API resolves alias and streams image) +``` + +### Test Live URL Generation + +```bash +curl -I "https://cdn.banatie.app/{orgSlug}/{projectSlug}/live/test?prompt=mountain" + +# Expected: HTTP 200 (generates or returns cached image) +``` + +## Rollback + +If issues occur: + +1. Revert code changes +2. Rebuild API container +3. Regenerate any images (old storage paths won't work) + +## Notes + +- `filename = image.id` (UUID) ensures consistent identification across DB, storage, and URLs +- Files are stored without extension; Content-Type is served from MinIO metadata +- Cloudflare caching can be enabled for UUID patterns (see url-fix-cloudflare-site.md) diff --git a/infrastructure/.env.example b/infrastructure/.env.example index ff5e5f5..408597d 100644 --- a/infrastructure/.env.example +++ b/infrastructure/.env.example @@ -31,13 +31,9 @@ MINIO_BUCKET_NAME=banatie MINIO_USE_SSL=false STORAGE_TYPE=minio -# Public URL for CDN access (used in API responses) -MINIO_PUBLIC_URL=https://cdn.banatie.app - -# Use direct CDN URLs instead of API proxy (recommended for production) -# Set to 'false' to force API URLs even when MINIO_PUBLIC_URL is configured -# Default: true (CDN enabled when MINIO_PUBLIC_URL is present) -USE_DIRECT_CDN=true +# CDN Base URL for image access +# Format: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId} +CDN_BASE_URL=https://cdn.banatie.app # ---------------------------------------- # API Configuration @@ -57,6 +53,6 @@ CORS_ORIGIN=https://banatie.app,https://api.banatie.app # ---------------------------------------- # Multi-tenancy Defaults # ---------------------------------------- -DEFAULT_ORG_ID=default -DEFAULT_PROJECT_ID=main +DEFAULT_ORG_SLUG=default +DEFAULT_PROJECT_SLUG=main DEFAULT_USER_ID=system diff --git a/infrastructure/docker-compose.vps.yml b/infrastructure/docker-compose.vps.yml index bf41add..9c687cc 100644 --- a/infrastructure/docker-compose.vps.yml +++ b/infrastructure/docker-compose.vps.yml @@ -129,6 +129,9 @@ services: {"Rules":[{"ID":"temp-cleanup","Status":"Enabled","Filter":{"Prefix":"temp/"},"Expiration":{"Days":7}}]} LCEOF mc ilm import storage/banatie < /tmp/lifecycle.json || echo 'Lifecycle policy may already exist' + # Enable public read access for CDN + mc anonymous set download storage/banatie || echo 'Anonymous access may already be set' + echo 'Public read access enabled for CDN' echo '=== Storage Initialization Completed ===' exit 0 restart: "no" diff --git a/packages/database/src/schema/images.ts b/packages/database/src/schema/images.ts index c80077f..57a37b6 100644 --- a/packages/database/src/schema/images.ts +++ b/packages/database/src/schema/images.ts @@ -46,7 +46,8 @@ export const images = pgTable( pendingFlowId: text('pending_flow_id'), // Temporary UUID for lazy flow pattern apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }), - // Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext) + // Storage (MinIO path format: orgSlug/projectSlug/img/{imageId}) + // Note: imageId = this record's UUID, filename has no extension storageKey: varchar('storage_key', { length: 500 }).notNull().unique(), storageUrl: text('storage_url').notNull(), @@ -54,6 +55,8 @@ export const images = pgTable( mimeType: varchar('mime_type', { length: 100 }).notNull(), fileSize: integer('file_size').notNull(), fileHash: varchar('file_hash', { length: 64 }), // SHA-256 for deduplication + originalFilename: varchar('original_filename', { length: 255 }), // User's original filename + fileExtension: varchar('file_extension', { length: 20 }), // Original extension (png, jpg, etc.) // Dimensions width: integer('width'), diff --git a/tests/api/02-basic.ts b/tests/api/02-basic.ts index 1d33339..991f884 100644 --- a/tests/api/02-basic.ts +++ b/tests/api/02-basic.ts @@ -42,6 +42,37 @@ async function main() { log.detail('Source', response.source); }); + // Test 1.5: Validate storage path format + await runTest('Storage path format validation', async () => { + // Get uploaded image + const result = await api(`${endpoints.images}/${testContext.uploadedImageId}`); + const image = result.data.data; + + // Validate storageKey format: {orgSlug}/{projectSlug}/img/{uuid} + const keyRegex = /^[\w-]+\/[\w-]+\/img\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + if (!keyRegex.test(image.storageKey)) { + throw new Error(`Invalid storageKey format: ${image.storageKey}. Expected: {org}/{proj}/img/{uuid}`); + } + + // Validate storageUrl format: https://.../img/{uuid} + const urlRegex = /^https?:\/\/[^\/]+\/[\w-]+\/[\w-]+\/img\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + if (!urlRegex.test(image.storageUrl)) { + throw new Error(`Invalid storageUrl format: ${image.storageUrl}. Expected: https://.../img/{uuid}`); + } + + // Verify imageId matches the UUID in path + const pathParts = image.storageKey.split('/'); + const uuidFromPath = pathParts[3]; + if (uuidFromPath !== image.id) { + throw new Error(`storageKey UUID (${uuidFromPath}) doesn't match image.id (${image.id})`); + } + + log.detail('storageKey', image.storageKey); + log.detail('storageUrl', image.storageUrl); + log.detail('UUID in path = image.id', '✓'); + log.detail('Format', '{org}/{proj}/img/{uuid}'); + }); + // Test 2: Upload image without alias await runTest('Upload image without alias', async () => { const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png'); diff --git a/tests/api/06-edge-cases.rest b/tests/api/06-edge-cases.rest index 28856f9..a4770bb 100644 --- a/tests/api/06-edge-cases.rest +++ b/tests/api/06-edge-cases.rest @@ -236,11 +236,12 @@ X-API-Key: {{apiKey}} ### Step 6.1: CDN image by path (if implemented) # @name cdnImage -GET {{base}}/api/v1/cdn/default/test-project/generated/2024-01/test.jpg +# New format: {org}/{project}/img/{uuid} +GET {{base}}/api/v1/cdn/default/test-project/img/00000000-0000-0000-0000-000000000000 X-API-Key: {{apiKey}} ### -# Note: Endpoint structure check only - actual paths depend on storage +# Note: Endpoint structure check only - uses placeholder UUID ### Step 6.2: Health check # @name healthCheck