feature/api-development #1

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

View File

@ -0,0 +1,176 @@
import { Request, Response, NextFunction } from 'express';
/**
* IP-based rate limiter for live URL generation (Section 8.6)
*
* Limits: 10 new generations per hour per IP address
* - Separate from API key rate limits
* - Cache hits do NOT count toward limit
* - Only new generations (cache MISS) count
*
* Implementation uses in-memory store with automatic cleanup
*/
interface RateLimitEntry {
count: number;
resetAt: number; // Timestamp when count resets
}
// In-memory store for IP rate limits
// Key: IP address, Value: { count, resetAt }
const ipRateLimits = new Map<string, RateLimitEntry>();
// Configuration
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const MAX_REQUESTS_PER_WINDOW = 10; // 10 new generations per hour
/**
* Get client IP address from request
* Supports X-Forwarded-For header for proxy/load balancer setups
*/
const getClientIp = (req: Request): string => {
// Check X-Forwarded-For header (used by proxies/load balancers)
const forwardedFor = req.headers['x-forwarded-for'];
if (forwardedFor) {
// X-Forwarded-For can contain multiple IPs, take the first one
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
return ips?.split(',')[0]?.trim() || req.ip || 'unknown';
}
// Fall back to req.ip
return req.ip || 'unknown';
};
/**
* Clean up expired entries from the rate limit store
* Called periodically to prevent memory leaks
*/
const cleanupExpiredEntries = (): void => {
const now = Date.now();
for (const [ip, entry] of ipRateLimits.entries()) {
if (now > entry.resetAt) {
ipRateLimits.delete(ip);
}
}
};
// Run cleanup every 5 minutes
setInterval(cleanupExpiredEntries, 5 * 60 * 1000);
/**
* Check if IP has exceeded rate limit
* Returns true if limit exceeded, false otherwise
*/
export const checkIpRateLimit = (ip: string): boolean => {
const now = Date.now();
const entry = ipRateLimits.get(ip);
if (!entry) {
// First request from this IP
ipRateLimits.set(ip, {
count: 1,
resetAt: now + RATE_LIMIT_WINDOW_MS,
});
return false; // Not limited
}
// Check if window has expired
if (now > entry.resetAt) {
// Reset the counter
entry.count = 1;
entry.resetAt = now + RATE_LIMIT_WINDOW_MS;
return false; // Not limited
}
// Increment counter
entry.count += 1;
// Check if limit exceeded
return entry.count > MAX_REQUESTS_PER_WINDOW;
};
/**
* Get remaining requests for IP
*/
export const getRemainingRequests = (ip: string): number => {
const now = Date.now();
const entry = ipRateLimits.get(ip);
if (!entry) {
return MAX_REQUESTS_PER_WINDOW;
}
// Check if window has expired
if (now > entry.resetAt) {
return MAX_REQUESTS_PER_WINDOW;
}
return Math.max(0, MAX_REQUESTS_PER_WINDOW - entry.count);
};
/**
* Get time until rate limit resets (in seconds)
*/
export const getResetTime = (ip: string): number => {
const now = Date.now();
const entry = ipRateLimits.get(ip);
if (!entry || now > entry.resetAt) {
return 0;
}
return Math.ceil((entry.resetAt - now) / 1000);
};
/**
* Middleware: IP-based rate limiter for live URLs
* Only increments counter on cache MISS (new generation)
* Use this middleware BEFORE cache check, but only increment after cache MISS
*/
export const ipRateLimiterMiddleware = (req: Request, res: Response, next: NextFunction): void => {
const ip = getClientIp(req);
// Attach IP to request for later use
(req as any).clientIp = ip;
// Attach rate limit check function to request
(req as any).checkIpRateLimit = () => {
const limited = checkIpRateLimit(ip);
if (limited) {
const resetTime = getResetTime(ip);
res.status(429).json({
success: false,
error: {
message: `Rate limit exceeded. Try again in ${resetTime} seconds`,
code: 'IP_RATE_LIMIT_EXCEEDED',
},
});
res.setHeader('Retry-After', resetTime.toString());
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
res.setHeader('X-RateLimit-Remaining', '0');
res.setHeader('X-RateLimit-Reset', getResetTime(ip).toString());
return true; // Limited
}
return false; // Not limited
};
// Set rate limit headers
const remaining = getRemainingRequests(ip);
const resetTime = getResetTime(ip);
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
res.setHeader('X-RateLimit-Remaining', remaining.toString());
if (resetTime > 0) {
res.setHeader('X-RateLimit-Reset', resetTime.toString());
}
next();
};
/**
* Helper function to manually increment IP rate limit counter
* Use this after confirming cache MISS (new generation)
*/
export const incrementIpRateLimit = (_ip: string): void => {
// Counter already incremented in checkIpRateLimit
// This is a no-op, kept for API consistency
};

View File

@ -6,6 +6,7 @@ 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 { ipRateLimiterMiddleware } from '@/middleware/ipRateLimiter';
import { computeLiveUrlCacheKey } from '@/utils/helpers';
import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
import type { LiveGenerationQuery } from '@/types/requests';
@ -159,6 +160,7 @@ cdnRouter.get(
*/
cdnRouter.get(
'/:orgSlug/:projectSlug/live/:scope',
ipRateLimiterMiddleware,
asyncHandler(async (req: any, res: Response) => {
const { orgSlug, projectSlug, scope } = req.params;
const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery;
@ -269,6 +271,13 @@ cdnRouter.get(
return;
}
// Cache MISS - check IP rate limit before generating
// Only count new generations (cache MISS) toward IP rate limit
const isLimited = (req as any).checkIpRateLimit();
if (isLimited) {
return; // Rate limit response already sent
}
// Cache MISS - check scope and generate
// Get or create scope
let liveScope;

View File

@ -4,6 +4,7 @@ import { generationsRouter } from './generations';
import { flowsRouter } from './flows';
import { imagesRouter } from './images';
import { liveRouter } from './live';
import { scopesRouter } from './scopes';
export const v1Router: RouterType = Router();
@ -12,3 +13,4 @@ v1Router.use('/generations', generationsRouter);
v1Router.use('/flows', flowsRouter);
v1Router.use('/images', imagesRouter);
v1Router.use('/live', liveRouter);
v1Router.use('/live/scopes', scopesRouter);

View File

@ -0,0 +1,344 @@
import { Response, Router } from 'express';
import type { Router as RouterType } from 'express';
import { LiveScopeService, ImageService, GenerationService } from '@/services/core';
import { asyncHandler } from '@/middleware/errorHandler';
import { validateApiKey } from '@/middleware/auth/validateApiKey';
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
import { PAGINATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
import { buildPaginationMeta } from '@/utils/helpers';
import { toLiveScopeResponse, toImageResponse } from '@/types/responses';
import type {
CreateLiveScopeRequest,
ListLiveScopesQuery,
UpdateLiveScopeRequest,
RegenerateScopeRequest,
} from '@/types/requests';
import type {
CreateLiveScopeResponse,
GetLiveScopeResponse,
ListLiveScopesResponse,
UpdateLiveScopeResponse,
DeleteLiveScopeResponse,
RegenerateScopeResponse,
} from '@/types/responses';
export const scopesRouter: RouterType = Router();
let scopeService: LiveScopeService;
let imageService: ImageService;
let generationService: GenerationService;
const getScopeService = (): LiveScopeService => {
if (!scopeService) {
scopeService = new LiveScopeService();
}
return scopeService;
};
const getImageService = (): ImageService => {
if (!imageService) {
imageService = new ImageService();
}
return imageService;
};
const getGenerationService = (): GenerationService => {
if (!generationService) {
generationService = new GenerationService();
}
return generationService;
};
/**
* POST /api/v1/live/scopes
* Create new live scope manually (Section 8.5)
* @authentication Project Key required
*/
scopesRouter.post(
'/',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response<CreateLiveScopeResponse>) => {
const service = getScopeService();
const { slug, allowNewGenerations, newGenerationsLimit, meta } = req.body as CreateLiveScopeRequest;
const projectId = req.apiKey.projectId;
// Validate slug format
if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) {
res.status(400).json({
success: false,
error: {
message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT,
code: 'SCOPE_INVALID_FORMAT',
},
});
return;
}
// Check if scope already exists
const existing = await service.getBySlug(projectId, slug);
if (existing) {
res.status(409).json({
success: false,
error: {
message: 'Scope with this slug already exists',
code: 'SCOPE_ALREADY_EXISTS',
},
});
return;
}
// Create scope
const scope = await service.create({
projectId,
slug,
allowNewGenerations: allowNewGenerations ?? true,
newGenerationsLimit: newGenerationsLimit ?? 30,
meta: meta || {},
});
// Get with stats
const scopeWithStats = await service.getByIdWithStats(scope.id);
res.status(201).json({
success: true,
data: toLiveScopeResponse(scopeWithStats),
});
}),
);
/**
* GET /api/v1/live/scopes
* List all live scopes for a project (Section 8.5)
* @authentication Project Key required
*/
scopesRouter.get(
'/',
validateApiKey,
requireProjectKey,
asyncHandler(async (req: any, res: Response<ListLiveScopesResponse>) => {
const service = getScopeService();
const { slug, limit, offset } = req.query as ListLiveScopesQuery;
const projectId = req.apiKey.projectId;
const parsedLimit = Math.min(
(limit ? parseInt(limit.toString(), 10) : PAGINATION_LIMITS.DEFAULT_LIMIT) || PAGINATION_LIMITS.DEFAULT_LIMIT,
PAGINATION_LIMITS.MAX_LIMIT,
);
const parsedOffset = (offset ? parseInt(offset.toString(), 10) : 0) || 0;
const result = await service.list(
{ projectId, slug },
parsedLimit,
parsedOffset,
);
const scopeResponses = result.scopes.map(toLiveScopeResponse);
res.json({
success: true,
data: scopeResponses,
pagination: buildPaginationMeta(result.total, parsedLimit, parsedOffset),
});
}),
);
/**
* GET /api/v1/live/scopes/:slug
* Get single live scope by slug (Section 8.5)
* @authentication Project Key required
*/
scopesRouter.get(
'/:slug',
validateApiKey,
requireProjectKey,
asyncHandler(async (req: any, res: Response<GetLiveScopeResponse>) => {
const service = getScopeService();
const { slug } = req.params;
const projectId = req.apiKey.projectId;
const scopeWithStats = await service.getBySlugWithStats(projectId, slug);
res.json({
success: true,
data: toLiveScopeResponse(scopeWithStats),
});
}),
);
/**
* PUT /api/v1/live/scopes/:slug
* Update live scope settings (Section 8.5)
* @authentication Project Key required
*/
scopesRouter.put(
'/:slug',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response<UpdateLiveScopeResponse>) => {
const service = getScopeService();
const { slug } = req.params;
const { allowNewGenerations, newGenerationsLimit, meta } = req.body as UpdateLiveScopeRequest;
const projectId = req.apiKey.projectId;
// Get scope
const scope = await service.getBySlugOrThrow(projectId, slug);
// Update scope
const updates: {
allowNewGenerations?: boolean;
newGenerationsLimit?: number;
meta?: Record<string, unknown>;
} = {};
if (allowNewGenerations !== undefined) updates.allowNewGenerations = allowNewGenerations;
if (newGenerationsLimit !== undefined) updates.newGenerationsLimit = newGenerationsLimit;
if (meta !== undefined) updates.meta = meta;
await service.update(scope.id, updates);
// Get updated scope with stats
const updated = await service.getByIdWithStats(scope.id);
res.json({
success: true,
data: toLiveScopeResponse(updated),
});
}),
);
/**
* POST /api/v1/live/scopes/:slug/regenerate
* Regenerate images in scope (Section 8.5)
* @authentication Project Key required
*/
scopesRouter.post(
'/:slug/regenerate',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response<RegenerateScopeResponse>) => {
const scopeService = getScopeService();
const imgService = getImageService();
const genService = getGenerationService();
const { slug } = req.params;
const { imageId } = req.body as RegenerateScopeRequest;
const projectId = req.apiKey.projectId;
// Get scope
const scope = await scopeService.getBySlugWithStats(projectId, slug);
if (imageId) {
// Regenerate specific image
const image = await imgService.getById(imageId);
if (!image) {
res.status(404).json({
success: false,
error: {
message: ERROR_MESSAGES.IMAGE_NOT_FOUND,
code: 'IMAGE_NOT_FOUND',
},
});
return;
}
// Check if image belongs to this scope
const imageMeta = image.meta as Record<string, unknown>;
if (imageMeta['scope'] !== slug) {
res.status(400).json({
success: false,
error: {
message: 'Image does not belong to this scope',
code: 'IMAGE_NOT_IN_SCOPE',
},
});
return;
}
// Regenerate the image's generation
if (image.generationId) {
await genService.regenerate(image.generationId);
}
const regeneratedImage = await imgService.getById(imageId);
res.json({
success: true,
data: {
regenerated: 1,
images: regeneratedImage ? [toImageResponse(regeneratedImage)] : [],
},
});
} else {
// Regenerate all images in scope
if (!scope.images || scope.images.length === 0) {
res.json({
success: true,
data: {
regenerated: 0,
images: [],
},
});
return;
}
const regeneratedImages = [];
for (const image of scope.images) {
if (image.generationId) {
await genService.regenerate(image.generationId);
const regenerated = await imgService.getById(image.id);
if (regenerated) {
regeneratedImages.push(toImageResponse(regenerated));
}
}
}
res.json({
success: true,
data: {
regenerated: regeneratedImages.length,
images: regeneratedImages,
},
});
}
}),
);
/**
* DELETE /api/v1/live/scopes/:slug
* Delete live scope (Section 8.5)
* Deletes all images in scope following standard deletion rules
* @authentication Project Key required
*/
scopesRouter.delete(
'/:slug',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response<DeleteLiveScopeResponse>) => {
const scopeService = getScopeService();
const imgService = getImageService();
const { slug } = req.params;
const projectId = req.apiKey.projectId;
// Get scope with images
const scope = await scopeService.getBySlugWithStats(projectId, slug);
// Delete all images in scope (follows alias protection rules)
if (scope.images) {
for (const image of scope.images) {
await imgService.hardDelete(image.id);
}
}
// Delete scope record
await scopeService.delete(scope.id);
res.json({
success: true,
data: { id: scope.id },
});
}),
);

View File

@ -100,6 +100,33 @@ export interface LiveGenerationQuery {
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
}
// ========================================
// LIVE SCOPE ENDPOINTS
// ========================================
export interface CreateLiveScopeRequest {
slug: string;
allowNewGenerations?: boolean;
newGenerationsLimit?: number;
meta?: Record<string, unknown>;
}
export interface ListLiveScopesQuery {
slug?: string;
limit?: number;
offset?: number;
}
export interface UpdateLiveScopeRequest {
allowNewGenerations?: boolean;
newGenerationsLimit?: number;
meta?: Record<string, unknown>;
}
export interface RegenerateScopeRequest {
imageId?: string; // Optional: regenerate specific image
}
// ========================================
// ANALYTICS ENDPOINTS
// ========================================

View File

@ -2,6 +2,7 @@ import type {
Image,
GenerationWithRelations,
FlowWithCounts,
LiveScopeWithStats,
PaginationMeta,
AliasScope,
} from './models';
@ -130,6 +131,34 @@ export type DeleteFlowResponse = ApiResponse<{ id: string }>;
export type ListFlowGenerationsResponse = PaginatedResponse<GenerationResponse>;
export type ListFlowImagesResponse = PaginatedResponse<ImageResponse>;
// ========================================
// LIVE SCOPE RESPONSES
// ========================================
export interface LiveScopeResponse {
id: string;
projectId: string;
slug: string;
allowNewGenerations: boolean;
newGenerationsLimit: number;
currentGenerations: number;
lastGeneratedAt: string | null;
meta: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface LiveScopeWithImagesResponse extends LiveScopeResponse {
images?: ImageResponse[];
}
export type CreateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
export type GetLiveScopeResponse = ApiResponse<LiveScopeResponse>;
export type ListLiveScopesResponse = PaginatedResponse<LiveScopeResponse>;
export type UpdateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
export type DeleteLiveScopeResponse = ApiResponse<{ id: string }>;
export type RegenerateScopeResponse = ApiResponse<{ regenerated: number; images: ImageResponse[] }>;
// ========================================
// LIVE GENERATION RESPONSE
// ========================================
@ -268,3 +297,16 @@ export const toFlowResponse = (flow: FlowWithCounts): FlowResponse => ({
createdAt: flow.createdAt.toISOString(),
updatedAt: flow.updatedAt.toISOString(),
});
export const toLiveScopeResponse = (scope: LiveScopeWithStats): LiveScopeResponse => ({
id: scope.id,
projectId: scope.projectId,
slug: scope.slug,
allowNewGenerations: scope.allowNewGenerations,
newGenerationsLimit: scope.newGenerationsLimit,
currentGenerations: scope.currentGenerations,
lastGeneratedAt: scope.lastGeneratedAt?.toISOString() ?? null,
meta: scope.meta as Record<string, unknown>,
createdAt: scope.createdAt.toISOString(),
updatedAt: scope.updatedAt.toISOString(),
});