import { randomUUID } from 'crypto'; import sizeOf from 'image-size'; import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; import { ImageService, AliasService } from '@/services/core'; 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'; import { uploadSingleImage, handleUploadErrors } from '@/middleware/upload'; import { validateAndNormalizePagination } from '@/utils/validators'; import { buildPaginatedResponse } from '@/utils/helpers'; import { toImageResponse } from '@/types/responses'; import { db } from '@/db'; import { flows } from '@banatie/database'; import { eq } from 'drizzle-orm'; import type { UploadImageResponse, ListImagesResponse, GetImageResponse, UpdateImageResponse, DeleteImageResponse, ResolveAliasResponse, } from '@/types/responses'; export const imagesRouter: RouterType = Router(); let imageService: ImageService; let aliasService: AliasService; const getImageService = (): ImageService => { if (!imageService) { imageService = new ImageService(); } return imageService; }; const getAliasService = (): AliasService => { if (!aliasService) { aliasService = new AliasService(); } return aliasService; }; /** * Upload a single image file to project storage * * Uploads an image file to MinIO storage and creates a database record with support for: * - Lazy flow creation using pendingFlowId when flowId is undefined * - Eager flow creation when flowAlias is provided * - Project-scoped alias assignment * - Custom metadata storage * - Multiple file formats (JPEG, PNG, WebP, etc.) * * FlowId behavior: * - undefined (not provided) → generates pendingFlowId, defers flow creation (lazy) * - null (explicitly null) → no flow association * - string (specific value) → uses provided flow ID, creates if needed * * @route POST /api/v1/images/upload * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {File} req.file - Image file (multipart/form-data, max 5MB) * @param {string} [req.body.alias] - Project-scoped alias (@custom-name) * @param {string|null} [req.body.flowId] - Flow association (undefined=auto, null=none, string=specific) * @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId, triggers eager creation) * @param {string} [req.body.meta] - Custom metadata (JSON string) * * @returns {UploadImageResponse} 201 - Uploaded image with storage details * @returns {object} 400 - Missing file or validation error * @returns {object} 401 - Missing or invalid API key * @returns {object} 413 - File too large * @returns {object} 415 - Unsupported file type * @returns {object} 429 - Rate limit exceeded * @returns {object} 500 - Upload or storage error * * @throws {Error} VALIDATION_ERROR - No file provided * @throws {Error} UPLOAD_ERROR - File upload failed * @throws {Error} ALIAS_CONFLICT - Alias already exists * * @example * // Upload with automatic flow creation * POST /api/v1/images/upload * Content-Type: multipart/form-data * { file: , alias: "@hero-bg" } * * @example * // Upload with eager flow creation and flow alias * POST /api/v1/images/upload * { file: , flowAlias: "@step-1" } */ imagesRouter.post( '/upload', validateApiKey, requireProjectKey, rateLimitByApiKey, uploadSingleImage, handleUploadErrors, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); const { alias, flowId, flowAlias, meta } = req.body; if (!req.file) { res.status(400).json({ success: false, error: { message: 'No file provided', code: 'VALIDATION_ERROR', }, }); return; } const projectId = req.apiKey.projectId; const apiKeyId = req.apiKey.id; const orgId = req.apiKey.organizationSlug || 'default'; const projectSlug = req.apiKey.projectSlug; const file = req.file; // FlowId logic (matching GenerationService lazy pattern): // - If undefined → generate UUID for pendingFlowId, flowId = null (lazy) // - If null → flowId = null, pendingFlowId = null (explicitly no flow) // - If string → flowId = string, pendingFlowId = null (use provided, create if needed) let finalFlowId: string | null; let pendingFlowId: string | null = null; if (flowId === undefined) { // Lazy pattern: defer flow creation until needed pendingFlowId = randomUUID(); finalFlowId = null; } else if (flowId === null) { // Explicitly no flow finalFlowId = null; pendingFlowId = null; } else { // Specific flowId provided - ensure flow exists (eager creation) finalFlowId = flowId; pendingFlowId = null; // Check if flow exists, create if not const existingFlow = await db.query.flows.findFirst({ where: eq(flows.id, finalFlowId), }); if (!existingFlow) { await db.insert(flows).values({ id: finalFlowId, projectId, aliases: {}, meta: {}, }); // Link any pending images to this new flow await service.linkPendingImagesToFlow(finalFlowId, projectId); } } try { const storageService = await StorageFactory.getInstance(); const uploadResult = await storageService.uploadFile( orgId, projectSlug, 'uploads', file.originalname, file.buffer, file.mimetype, ); if (!uploadResult.success) { res.status(500).json({ success: false, error: { message: 'File upload failed', code: 'UPLOAD_ERROR', details: uploadResult.error, }, }); return; } // Extract image dimensions from uploaded file buffer let width: number | null = null; let height: number | null = null; try { const dimensions = sizeOf(file.buffer); if (dimensions.width && dimensions.height) { width = dimensions.width; height = dimensions.height; } } catch (error) { console.warn('Failed to extract image dimensions:', error); } const imageRecord = await service.create({ projectId, flowId: finalFlowId, pendingFlowId: pendingFlowId, generationId: null, apiKeyId, storageKey: uploadResult.path!, storageUrl: uploadResult.url!, mimeType: file.mimetype, fileSize: file.size, fileHash: null, source: 'uploaded', alias: alias || null, meta: meta ? JSON.parse(meta) : {}, width, height, }); // Eager flow creation if flowAlias is provided if (flowAlias) { // Use pendingFlowId if available, otherwise finalFlowId const flowIdToUse = pendingFlowId || finalFlowId; if (!flowIdToUse) { throw new Error('Cannot create flow: no flowId available'); } // Check if flow exists, create if not const existingFlow = await db.query.flows.findFirst({ where: eq(flows.id, flowIdToUse), }); if (!existingFlow) { await db.insert(flows).values({ id: flowIdToUse, projectId, aliases: {}, meta: {}, }); // Link pending images if this was a lazy flow if (pendingFlowId) { await service.linkPendingImagesToFlow(flowIdToUse, projectId); } } // Assign flow alias to uploaded image const flow = await db.query.flows.findFirst({ where: eq(flows.id, flowIdToUse), }); if (flow) { const currentAliases = (flow.aliases as Record) || {}; const updatedAliases = { ...currentAliases }; updatedAliases[flowAlias] = imageRecord.id; await db .update(flows) .set({ aliases: updatedAliases, updatedAt: new Date() }) .where(eq(flows.id, flowIdToUse)); } } res.status(201).json({ success: true, data: toImageResponse(imageRecord), }); } catch (error) { res.status(500).json({ success: false, error: { message: error instanceof Error ? error.message : 'Upload failed', code: 'UPLOAD_ERROR', }, }); return; } }) ); /** * List all images for the project with filtering and pagination * * Retrieves images (both generated and uploaded) with support for: * - Flow-based filtering * - Source filtering (generated vs uploaded) * - Alias filtering (exact match) * - Pagination with configurable limit and offset * - Optional inclusion of soft-deleted images * * @route GET /api/v1/images * @authentication Project Key required * * @param {string} [req.query.flowId] - Filter by flow ID * @param {string} [req.query.source] - Filter by source (generated|uploaded) * @param {string} [req.query.alias] - Filter by exact alias match * @param {number} [req.query.limit=20] - Results per page (max 100) * @param {number} [req.query.offset=0] - Number of results to skip * @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted images * * @returns {ListImagesResponse} 200 - Paginated list of images * @returns {object} 400 - Invalid pagination parameters * @returns {object} 401 - Missing or invalid API key * * @example * // List uploaded images in a flow * GET /api/v1/images?flowId=abc-123&source=uploaded&limit=50 */ imagesRouter.get( '/', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); const { flowId, source, alias, limit, offset, includeDeleted } = req.query; const paginationResult = validateAndNormalizePagination(limit, offset); if (!paginationResult.valid) { res.status(400).json({ success: false, data: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false }, }); return; } const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!; const projectId = req.apiKey.projectId; const result = await service.list( { projectId, flowId: flowId as string | undefined, source: source as 'generated' | 'uploaded' | undefined, alias: alias as string | undefined, deleted: includeDeleted === 'true' ? true : undefined, }, validatedLimit, validatedOffset ); const responseData = result.images.map((img) => toImageResponse(img)); res.json( buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset) ); }) ); /** * Resolve an alias to an image using 3-tier precedence system * * Resolves aliases through a priority-based lookup system: * 1. Technical aliases (@last, @first, @upload) - computed on-the-fly * 2. Flow-scoped aliases - looked up in flow's JSONB aliases field (requires flowId) * 3. Project-scoped aliases - looked up in images.alias column * * Returns the image ID, resolution scope, and complete image details. * * @route GET /api/v1/images/resolve/:alias * @authentication Project Key required * * @param {string} req.params.alias - Alias to resolve (e.g., "@last", "@hero", "@step-1") * @param {string} [req.query.flowId] - Flow context for flow-scoped resolution * * @returns {ResolveAliasResponse} 200 - Resolved image with scope and details * @returns {object} 404 - Alias not found in any scope * @returns {object} 401 - Missing or invalid API key * * @throws {Error} ALIAS_NOT_FOUND - Alias does not exist * @throws {Error} RESOLUTION_ERROR - Resolution failed * * @example * // Resolve technical alias * GET /api/v1/images/resolve/@last * * @example * // Resolve flow-scoped alias * GET /api/v1/images/resolve/@step-1?flowId=abc-123 * * @example * // Resolve project-scoped alias * GET /api/v1/images/resolve/@hero-bg */ imagesRouter.get( '/resolve/:alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const aliasServiceInstance = getAliasService(); const { alias } = req.params; const { flowId } = req.query; const projectId = req.apiKey.projectId; try { const resolution = await aliasServiceInstance.resolve( alias, projectId, flowId as string | undefined ); if (!resolution) { res.status(404).json({ success: false, error: { message: `Alias '${alias}' not found`, code: 'ALIAS_NOT_FOUND', }, }); return; } // Verify project ownership if (resolution.image && resolution.image.projectId !== projectId) { res.status(404).json({ success: false, error: { message: 'Alias not found', code: 'ALIAS_NOT_FOUND', }, }); return; } res.json({ success: true, data: { alias, imageId: resolution.imageId, scope: resolution.scope, flowId: resolution.flowId, image: resolution.image ? toImageResponse(resolution.image) : ({} as any), }, }); } catch (error) { res.status(500).json({ success: false, error: { message: error instanceof Error ? error.message : 'Failed to resolve alias', code: 'RESOLUTION_ERROR', }, }); return; } }) ); /** * Get a single image by ID with complete details * * Retrieves full image information including: * - Storage URLs and keys * - Project and flow associations * - Alias assignments (project-scoped) * - Source (generated vs uploaded) * - File metadata (size, MIME type, hash) * - Focal point and custom metadata * * @route GET /api/v1/images/:id * @authentication Project Key required * * @param {string} req.params.id - Image ID (UUID) * * @returns {GetImageResponse} 200 - Complete image details * @returns {object} 404 - Image not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} IMAGE_NOT_FOUND - Image does not exist * * @example * GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000 */ imagesRouter.get( '/:id', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); const { id } = req.params; const image = await service.getById(id); if (!image) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } if (image.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } res.json({ success: true, data: toImageResponse(image), }); }) ); /** * Update image metadata (focal point and custom metadata) * * Updates non-generative image properties: * - Focal point for image cropping (x, y coordinates 0.0-1.0) * - Custom metadata (arbitrary JSON object) * * Note: Alias assignment moved to separate endpoint PUT /images/:id/alias (Section 6.1) * * @route PUT /api/v1/images/:id * @authentication Project Key required * * @param {string} req.params.id - Image ID (UUID) * @param {UpdateImageRequest} req.body - Update parameters * @param {object} [req.body.focalPoint] - Focal point for cropping * @param {number} req.body.focalPoint.x - X coordinate (0.0-1.0) * @param {number} req.body.focalPoint.y - Y coordinate (0.0-1.0) * @param {object} [req.body.meta] - Custom metadata * * @returns {UpdateImageResponse} 200 - Updated image details * @returns {object} 404 - Image not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} IMAGE_NOT_FOUND - Image does not exist * * @example * PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000 * { * "focalPoint": { "x": 0.5, "y": 0.3 }, * "meta": { "category": "hero", "priority": 1 } * } */ imagesRouter.put( '/:id', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); const { id } = req.params; const { focalPoint, meta } = req.body; // Removed alias (Section 6.1) const image = await service.getById(id); if (!image) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } if (image.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } const updates: { focalPoint?: { x: number; y: number }; meta?: Record; } = {}; if (focalPoint !== undefined) updates.focalPoint = focalPoint; if (meta !== undefined) updates.meta = meta; const updated = await service.update(id, updates); res.json({ success: true, data: toImageResponse(updated), }); }) ); /** * Assign a project-scoped alias to an image * * Sets or updates the project-scoped alias for an image: * - Alias must start with @ symbol * - Must be unique within the project * - Replaces existing alias if image already has one * - Used for alias resolution in generations and CDN access * * This is a dedicated endpoint introduced in Section 6.1 to separate * alias assignment from general metadata updates. * * @route PUT /api/v1/images/:id/alias * @authentication Project Key required * * @param {string} req.params.id - Image ID (UUID) * @param {object} req.body - Request body * @param {string} req.body.alias - Project-scoped alias (e.g., "@hero-bg") * * @returns {UpdateImageResponse} 200 - Updated image with new alias * @returns {object} 404 - Image not found or access denied * @returns {object} 400 - Missing or invalid alias * @returns {object} 401 - Missing or invalid API key * @returns {object} 409 - Alias already exists * * @throws {Error} IMAGE_NOT_FOUND - Image does not exist * @throws {Error} VALIDATION_ERROR - Alias is required * @throws {Error} ALIAS_CONFLICT - Alias already assigned to another image * * @example * PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias * { * "alias": "@hero-background" * } */ imagesRouter.put( '/:id/alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); const { id } = req.params; const { alias } = req.body; if (!alias || typeof alias !== 'string') { res.status(400).json({ success: false, error: { message: 'Alias is required and must be a string', code: 'VALIDATION_ERROR', }, }); return; } const image = await service.getById(id); if (!image) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } if (image.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } const updated = await service.assignProjectAlias(id, alias); res.json({ success: true, data: toImageResponse(updated), }); }) ); /** * Delete an image with storage cleanup and cascading deletions * * Performs hard delete of image record and MinIO file with cascading operations: * - Deletes image record from database (hard delete, no soft delete) * - Removes file from MinIO storage permanently * - Cascades to delete generation-image relationships * - Removes image from flow aliases (if present) * - Cannot be undone * * Use with caution: This is a destructive operation that permanently removes * the image file and all database references. * * @route DELETE /api/v1/images/:id * @authentication Project Key required * * @param {string} req.params.id - Image ID (UUID) * * @returns {DeleteImageResponse} 200 - Deletion confirmation with image ID * @returns {object} 404 - Image not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} IMAGE_NOT_FOUND - Image does not exist * * @example * DELETE /api/v1/images/550e8400-e29b-41d4-a716-446655440000 * * Response: * { * "success": true, * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" } * } */ imagesRouter.delete( '/:id', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); const { id } = req.params; const image = await service.getById(id); if (!image) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } if (image.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Image not found', code: 'IMAGE_NOT_FOUND', }, }); return; } await service.hardDelete(id); res.json({ success: true, data: { id }, }); }) );