feature/api-development #1
|
|
@ -9,6 +9,7 @@ import { uploadRouter } from './routes/upload';
|
||||||
import bootstrapRoutes from './routes/bootstrap';
|
import bootstrapRoutes from './routes/bootstrap';
|
||||||
import adminKeysRoutes from './routes/admin/keys';
|
import adminKeysRoutes from './routes/admin/keys';
|
||||||
import { v1Router } from './routes/v1';
|
import { v1Router } from './routes/v1';
|
||||||
|
import { cdnRouter } from './routes/cdn';
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
|
|
@ -112,6 +113,9 @@ export const createApp = (): Application => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public routes (no authentication)
|
// 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)
|
// Bootstrap route (no auth, but works only once)
|
||||||
app.use('/api/bootstrap', bootstrapRoutes);
|
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 './GenerationService';
|
||||||
export * from './FlowService';
|
export * from './FlowService';
|
||||||
export * from './PromptCacheService';
|
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 './paginationBuilder';
|
||||||
export * from './hashHelper';
|
export * from './hashHelper';
|
||||||
export * from './queryHelper';
|
export * from './queryHelper';
|
||||||
|
export * from './cacheKeyHelper';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue