diff --git a/apps/api-service/src/app.ts b/apps/api-service/src/app.ts index a5fd4cc..44a0a51 100644 --- a/apps/api-service/src/app.ts +++ b/apps/api-service/src/app.ts @@ -9,6 +9,7 @@ import { uploadRouter } from './routes/upload'; import bootstrapRoutes from './routes/bootstrap'; import adminKeysRoutes from './routes/admin/keys'; import { v1Router } from './routes/v1'; +import { cdnRouter } from './routes/cdn'; import { errorHandler, notFoundHandler } from './middleware/errorHandler'; // Load environment variables @@ -112,6 +113,9 @@ export const createApp = (): Application => { }); // Public routes (no authentication) + // CDN routes for serving images and live URLs (public, no auth) + app.use('/cdn', cdnRouter); + // Bootstrap route (no auth, but works only once) app.use('/api/bootstrap', bootstrapRoutes); diff --git a/apps/api-service/src/routes/cdn.ts b/apps/api-service/src/routes/cdn.ts new file mode 100644 index 0000000..e59c61a --- /dev/null +++ b/apps/api-service/src/routes/cdn.ts @@ -0,0 +1,372 @@ +import { Response, Router } from 'express'; +import type { Router as RouterType } from 'express'; +import { db } from '@/db'; +import { organizations, projects, images } from '@banatie/database'; +import { eq, and, isNull, sql } from 'drizzle-orm'; +import { ImageService, GenerationService, LiveScopeService } from '@/services/core'; +import { StorageFactory } from '@/services/StorageFactory'; +import { asyncHandler } from '@/middleware/errorHandler'; +import { computeLiveUrlCacheKey } from '@/utils/helpers'; +import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants'; +import type { LiveGenerationQuery } from '@/types/requests'; + +export const cdnRouter: RouterType = Router(); + +let imageService: ImageService; +let generationService: GenerationService; +let liveScopeService: LiveScopeService; + +const getImageService = (): ImageService => { + if (!imageService) { + imageService = new ImageService(); + } + return imageService; +}; + +const getGenerationService = (): GenerationService => { + if (!generationService) { + generationService = new GenerationService(); + } + return generationService; +}; + +const getLiveScopeService = (): LiveScopeService => { + if (!liveScopeService) { + liveScopeService = new LiveScopeService(); + } + return liveScopeService; +}; + +/** + * GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias + * + * Serve images by filename or project-scoped alias (Section 8) + * Public endpoint - no authentication required + * Returns image bytes with caching headers + */ +cdnRouter.get( + '/:orgSlug/:projectSlug/img/:filenameOrAlias', + asyncHandler(async (req: any, res: Response) => { + const { orgSlug, projectSlug, filenameOrAlias } = req.params; + + try { + // Resolve organization and project + const org = await db.query.organizations.findFirst({ + where: eq(organizations.slug, orgSlug), + }); + + if (!org) { + res.status(404).json({ + success: false, + error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' }, + }); + return; + } + + const project = await db.query.projects.findFirst({ + where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)), + }); + + if (!project) { + res.status(404).json({ + success: false, + error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' }, + }); + return; + } + + let image; + + // Check if filenameOrAlias is an alias (starts with @) + if (filenameOrAlias.startsWith('@')) { + // Lookup by project-scoped alias + const allImages = await db.query.images.findMany({ + where: and( + eq(images.projectId, project.id), + eq(images.alias, filenameOrAlias), + isNull(images.deletedAt), + ), + }); + + image = allImages[0] || null; + } else { + // Lookup by filename in storageKey + const allImages = await db.query.images.findMany({ + where: and(eq(images.projectId, project.id), isNull(images.deletedAt)), + }); + + // Find image where storageKey ends with filename + image = allImages.find((img) => img.storageKey.includes(filenameOrAlias)) || null; + } + + if (!image) { + res.status(404).json({ + success: false, + error: { message: ERROR_MESSAGES.IMAGE_NOT_FOUND, code: 'IMAGE_NOT_FOUND' }, + }); + return; + } + + // Download image from storage + const storageService = await StorageFactory.getInstance(); + const keyParts = image.storageKey.split('/'); + + if (keyParts.length < 4) { + 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 buffer = await storageService.downloadFile(orgId, projectId, category, filename); + + // Set headers + res.setHeader('Content-Type', image.mimeType); + res.setHeader('Content-Length', buffer.length); + res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year + res.setHeader('X-Image-Id', image.id); + + // Stream image bytes + res.send(buffer); + } catch (error) { + console.error('CDN image serve error:', error); + res.status(500).json({ + success: false, + error: { + message: error instanceof Error ? error.message : 'Failed to serve image', + code: 'CDN_ERROR', + }, + }); + } + }), +); + +/** + * GET /cdn/:orgSlug/:projectSlug/live/:scope + * + * Live URL endpoint with caching (Section 8.3) + * Public endpoint - no authentication required + * Query params: prompt, aspectRatio, autoEnhance, template + * + * Flow: + * 1. Resolve org, project, and scope + * 2. Compute cache key from params + * 3. Check if image exists in cache (via meta field) + * 4. If HIT: return cached image + * 5. If MISS: check scope limits, generate new image, cache, return + */ +cdnRouter.get( + '/:orgSlug/:projectSlug/live/:scope', + asyncHandler(async (req: any, res: Response) => { + const { orgSlug, projectSlug, scope } = req.params; + const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery; + + const genService = getGenerationService(); + const imgService = getImageService(); + const scopeService = getLiveScopeService(); + + try { + // Validate prompt + if (!prompt || typeof prompt !== 'string') { + res.status(400).json({ + success: false, + error: { message: 'Prompt is required', code: 'VALIDATION_ERROR' }, + }); + return; + } + + // Validate scope format (alphanumeric + hyphens + underscores) + if (!/^[a-zA-Z0-9_-]+$/.test(scope)) { + res.status(400).json({ + success: false, + error: { message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT, code: 'SCOPE_INVALID_FORMAT' }, + }); + return; + } + + // Resolve organization + const org = await db.query.organizations.findFirst({ + where: eq(organizations.slug, orgSlug), + }); + + if (!org) { + res.status(404).json({ + success: false, + error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' }, + }); + return; + } + + // Resolve project + const project = await db.query.projects.findFirst({ + where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)), + }); + + if (!project) { + res.status(404).json({ + success: false, + error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' }, + }); + return; + } + + // Compute cache key + const normalizedAutoEnhance = + typeof autoEnhance === 'string' ? autoEnhance === 'true' : Boolean(autoEnhance); + + const cacheParams: { + aspectRatio?: string; + autoEnhance?: boolean; + template?: string; + } = {}; + if (aspectRatio) cacheParams.aspectRatio = aspectRatio as string; + if (autoEnhance !== undefined) cacheParams.autoEnhance = normalizedAutoEnhance; + if (template) cacheParams.template = template as string; + + const cacheKey = computeLiveUrlCacheKey(project.id, scope, prompt, cacheParams); + + // Check cache: find image with meta.liveUrlCacheKey = cacheKey + const cachedImages = await db.query.images.findMany({ + where: and( + eq(images.projectId, project.id), + isNull(images.deletedAt), + sql`${images.meta}->>'scope' = ${scope}`, + sql`${images.meta}->>'isLiveUrl' = 'true'`, + sql`${images.meta}->>'cacheKey' = ${cacheKey}`, + ), + limit: 1, + }); + + const cachedImage = cachedImages[0]; + + if (cachedImage) { + // Cache HIT - serve existing image + const storageService = await StorageFactory.getInstance(); + const keyParts = cachedImage.storageKey.split('/'); + + if (keyParts.length < 4) { + 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 buffer = await storageService.downloadFile(orgId, projectId, category, filename); + + // Set headers + res.setHeader('Content-Type', cachedImage.mimeType); + res.setHeader('Content-Length', buffer.length); + res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year + res.setHeader('X-Cache-Status', 'HIT'); + res.setHeader('X-Scope', scope); + res.setHeader('X-Image-Id', cachedImage.id); + + res.send(buffer); + return; + } + + // Cache MISS - check scope and generate + // Get or create scope + let liveScope; + try { + liveScope = await scopeService.createOrGet(project.id, scope, { + allowNewLiveScopes: project.allowNewLiveScopes, + newLiveScopesGenerationLimit: project.newLiveScopesGenerationLimit, + }); + } catch (error) { + if (error instanceof Error && error.message === ERROR_MESSAGES.SCOPE_CREATION_DISABLED) { + res.status(403).json({ + success: false, + error: { + message: ERROR_MESSAGES.SCOPE_CREATION_DISABLED, + code: 'SCOPE_CREATION_DISABLED', + }, + }); + return; + } + throw error; + } + + // Check if scope allows new generations + const scopeStats = await scopeService.getByIdWithStats(liveScope.id); + const canGenerate = await scopeService.canGenerateNew( + liveScope, + scopeStats.currentGenerations, + ); + + if (!canGenerate) { + res.status(429).json({ + success: false, + error: { + message: ERROR_MESSAGES.SCOPE_GENERATION_LIMIT_EXCEEDED, + code: 'SCOPE_GENERATION_LIMIT_EXCEEDED', + }, + }); + return; + } + + // Generate new image (no API key, use system generation) + const generation = await genService.create({ + projectId: project.id, + apiKeyId: null as unknown as string, // System generation for live URLs + prompt, + aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, + autoEnhance: normalizedAutoEnhance, + requestId: `live-${scope}-${Date.now()}`, + }); + + if (!generation.outputImage) { + throw new Error('Generation succeeded but no output image was created'); + } + + // Update image meta to mark as live URL with cache key and scope + await imgService.update(generation.outputImage.id, { + meta: { + ...generation.outputImage.meta, + scope, + isLiveUrl: true, + cacheKey, + }, + }); + + // Download newly generated image + const storageService = await StorageFactory.getInstance(); + const keyParts = generation.outputImage.storageKey.split('/'); + + if (keyParts.length < 4) { + 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 buffer = await storageService.downloadFile(orgId, projectId, category, filename); + + // Set headers + res.setHeader('Content-Type', generation.outputImage.mimeType); + res.setHeader('Content-Length', buffer.length); + res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year + res.setHeader('X-Cache-Status', 'MISS'); + res.setHeader('X-Scope', scope); + res.setHeader('X-Generation-Id', generation.id); + res.setHeader('X-Image-Id', generation.outputImage.id); + + res.send(buffer); + } catch (error) { + console.error('Live URL generation error:', error); + res.status(500).json({ + success: false, + error: { + message: error instanceof Error ? error.message : 'Generation failed', + code: 'LIVE_URL_ERROR', + }, + }); + } + }), +); diff --git a/apps/api-service/src/services/core/index.ts b/apps/api-service/src/services/core/index.ts index 779cfa6..2aa1b31 100644 --- a/apps/api-service/src/services/core/index.ts +++ b/apps/api-service/src/services/core/index.ts @@ -3,3 +3,4 @@ export * from './ImageService'; export * from './GenerationService'; export * from './FlowService'; export * from './PromptCacheService'; +export * from './LiveScopeService'; diff --git a/apps/api-service/src/utils/helpers/cacheKeyHelper.ts b/apps/api-service/src/utils/helpers/cacheKeyHelper.ts new file mode 100644 index 0000000..9988303 --- /dev/null +++ b/apps/api-service/src/utils/helpers/cacheKeyHelper.ts @@ -0,0 +1,53 @@ +import crypto from 'crypto'; + +/** + * Compute cache key for live URL generation (Section 8.7) + * + * Cache key format: SHA-256 hash of (projectId + scope + prompt + params) + * + * @param projectId - Project UUID + * @param scope - Live scope slug + * @param prompt - User prompt + * @param params - Additional generation parameters (aspectRatio, etc.) + * @returns SHA-256 hash string + */ +export const computeLiveUrlCacheKey = ( + projectId: string, + scope: string, + prompt: string, + params: { + aspectRatio?: string; + autoEnhance?: boolean; + template?: string; + } = {}, +): string => { + // Normalize parameters to ensure consistent cache keys + const normalizedParams = { + aspectRatio: params.aspectRatio || '1:1', + autoEnhance: params.autoEnhance ?? false, + template: params.template || 'general', + }; + + // Create cache key string + const cacheKeyString = [ + projectId, + scope, + prompt.trim().toLowerCase(), // Normalize prompt + normalizedParams.aspectRatio, + normalizedParams.autoEnhance.toString(), + normalizedParams.template, + ].join('::'); + + // Compute SHA-256 hash + return crypto.createHash('sha256').update(cacheKeyString).digest('hex'); +}; + +/** + * Compute prompt hash for prompt URL cache (Section 5 - already implemented) + * + * @param prompt - User prompt + * @returns SHA-256 hash string + */ +export const computePromptHash = (prompt: string): string => { + return crypto.createHash('sha256').update(prompt.trim().toLowerCase()).digest('hex'); +}; diff --git a/apps/api-service/src/utils/helpers/index.ts b/apps/api-service/src/utils/helpers/index.ts index 32cd539..abda018 100644 --- a/apps/api-service/src/utils/helpers/index.ts +++ b/apps/api-service/src/utils/helpers/index.ts @@ -1,3 +1,4 @@ export * from './paginationBuilder'; export * from './hashHelper'; export * from './queryHelper'; +export * from './cacheKeyHelper';