|
|
|
|
@ -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',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|