import { Router, Request, Response } from 'express'; import type { Router as RouterType } from 'express'; import { StorageFactory } from '../services/StorageFactory'; import { asyncHandler } from '../middleware/errorHandler'; import { validateApiKey } from '../middleware/auth/validateApiKey'; import { requireProjectKey } from '../middleware/auth/requireProjectKey'; 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) */ imagesRouter.get( '/images/:orgId/:projectId/:category/:filename', asyncHandler(async (req: Request, res: Response): Promise => { const { orgId, projectId, category, filename } = req.params; // Validate required params (these are guaranteed by route pattern) if (!orgId || !projectId || !category || !filename) { res.status(400).json({ success: false, message: 'Missing required parameters', }); 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, ); if (!exists) { res.status(404).json({ success: false, message: 'File not found', }); 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 // Handle conditional requests (304 Not Modified) const ifNoneMatch = req.headers['if-none-match']; if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) { 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, ); // Handle stream errors fileStream.on('error', (streamError) => { console.error('Stream error:', streamError); if (!res.headersSent) { res.status(500).json({ success: false, message: 'Error streaming file', }); } }); // Stream the file without loading into memory fileStream.pipe(res); } catch (error) { console.error('Failed to stream file:', error); res.status(404).json({ success: false, message: 'File not found', }); } }), ); /** * GET /api/images/url/:orgId/:projectId/:category/:filename * Returns a presigned URL instead of redirecting */ imagesRouter.get( '/images/url/:orgId/:projectId/:category/:filename', asyncHandler(async (req: Request, res: Response): Promise => { const { orgId, projectId, category, filename } = req.params; const { expiry = '3600' } = req.query; // Default 1 hour // Validate required params (these are guaranteed by route pattern) if (!orgId || !projectId || !category || !filename) { res.status(400).json({ success: false, message: 'Missing required parameters', }); 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, parseInt(expiry as string, 10), ); res.json({ success: true, url: presignedUrl, expiresIn: parseInt(expiry as string, 10), }); } catch (error) { console.error('Failed to generate presigned URL:', error); res.status(404).json({ success: false, message: 'File not found or access denied', }); } }), ); /** * GET /api/images/generated * List generated images for the authenticated project */ imagesRouter.get( '/images/generated', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const timestamp = new Date().toISOString(); const requestId = req.requestId; // Parse and validate query parameters const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 30, 1), 100); const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); const prefix = (req.query.prefix as string) || undefined; // Validate query parameters if (isNaN(limit) || isNaN(offset)) { res.status(400).json({ success: false, message: 'Invalid query parameters', error: 'limit and offset must be valid numbers', }); return; } // Extract org/project from validated API key const orgId = req.apiKey?.organizationSlug || 'default'; const projectId = req.apiKey?.projectSlug!; console.log( `[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, 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); // Sort by lastModified descending (newest first) allFiles.sort((a, b) => { const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0; const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0; return dateB - dateA; }); // Apply pagination const total = allFiles.length; const paginatedFiles = allFiles.slice(offset, offset + limit); // Map to response format with public URLs const images = paginatedFiles.map((file) => ({ filename: file.filename, url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename), size: file.size, contentType: file.contentType, lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(), })); const hasMore = offset + limit < total; console.log( `[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`, ); return res.status(200).json({ success: true, data: { images, total, offset, limit, hasMore, }, }); } catch (error) { console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error); return res.status(500).json({ success: false, message: 'Failed to list generated images', error: error instanceof Error ? error.message : 'Unknown error occurred', }); } }), );