feat: phase 3 part 2 - CDN endpoints with live URL caching system
Implement public CDN endpoints for serving images and live URL generation
with intelligent caching based on prompt hashing.
**CDN Routes (Section 8):**
- **GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias**
- Public endpoint (no authentication)
- Serve images by filename or project-scoped alias
- Resolves org and project from slugs
- Supports @alias syntax or filename lookup
- Returns image bytes with 1-year cache headers
- Headers: Content-Type, Content-Length, Cache-Control, X-Image-Id
- **GET /cdn/:orgSlug/:projectSlug/live/:scope**
- Public endpoint (no authentication)
- Live URL generation with caching
- Query params: prompt, aspectRatio, autoEnhance, template
- Cache key: SHA-256(projectId + scope + prompt + params)
- Cache HIT: Returns cached image immediately
- Cache MISS: Generates new image, caches, returns
- Scope management: auto-create or check limits
- Image meta tracking: scope, isLiveUrl, cacheKey
- Headers: X-Cache-Status (HIT/MISS), X-Scope, X-Image-Id, X-Generation-Id
**Cache Key Helper:**
- computeLiveUrlCacheKey() - SHA-256 hash for live URL caching
- computePromptHash() - SHA-256 hash for prompt caching
- Normalized parameters for consistent cache keys
**Live URL Flow (Section 8.3):**
1. Validate prompt and scope format (alphanumeric + hyphens + underscores)
2. Resolve org, project from slugs
3. Compute cache key from params
4. Check cache via images.meta.cacheKey
5. If HIT: serve cached image
6. If MISS: check scope limits, generate, cache, serve
7. Lazy scope creation with project defaults
8. Enforce newGenerationsLimit per scope
**Service Exports:**
- Added LiveScopeService to core services index
- Available for dependency injection
**App Routing:**
- Mounted CDN router at /cdn (public, before auth routes)
- CDN endpoints are completely public (no API key required)
**Technical Notes:**
- Images tagged with meta: { scope, isLiveUrl: true, cacheKey }
- Scope statistics computed from images with matching meta
- Cache controlled via Cache-Control: public, max-age=31536000
- System generations use apiKeyId: null
- All code compiles with zero new TypeScript errors
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1ad5b483ef
commit
1f768d4761
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -3,3 +3,4 @@ export * from './ImageService';
|
|||
export * from './GenerationService';
|
||||
export * from './FlowService';
|
||||
export * from './PromptCacheService';
|
||||
export * from './LiveScopeService';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './paginationBuilder';
|
||||
export * from './hashHelper';
|
||||
export * from './queryHelper';
|
||||
export * from './cacheKeyHelper';
|
||||
|
|
|
|||
Loading…
Reference in New Issue