feature/api-development #1

Merged
usulpro merged 47 commits from feature/api-development into main 2025-11-29 23:03:01 +07:00
5 changed files with 431 additions and 0 deletions
Showing only changes of commit 1f768d4761 - Show all commits

View File

@ -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);

View File

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

View File

@ -3,3 +3,4 @@ export * from './ImageService';
export * from './GenerationService';
export * from './FlowService';
export * from './PromptCacheService';
export * from './LiveScopeService';

View File

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

View File

@ -1,3 +1,4 @@
export * from './paginationBuilder';
export * from './hashHelper';
export * from './queryHelper';
export * from './cacheKeyHelper';