banatie-service/apps/api-service/src/routes/images.ts

255 lines
7.8 KiB
TypeScript

import { Router, Request, Response } from 'express';
import type { Router as RouterType } from 'express';
import { StorageFactory } from '../services/StorageFactory';
import { asyncHandler } from '../middleware/errorHandler';
import { validateApiKey } from '../middleware/auth/validateApiKey';
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
export const imagesRouter: RouterType = Router();
/**
* GET /api/images/:orgId/:projectId/:category/:filename
* Serves images via presigned URLs (redirect approach)
*/
imagesRouter.get(
'/images/:orgId/:projectId/:category/:filename',
asyncHandler(async (req: Request, res: Response): Promise<void> => {
const { orgId, projectId, category, filename } = req.params;
// Validate required params (these are guaranteed by route pattern)
if (!orgId || !projectId || !category || !filename) {
res.status(400).json({
success: false,
message: 'Missing required parameters',
});
return;
}
// Validate category
if (!['uploads', 'generated', 'references'].includes(category)) {
res.status(400).json({
success: false,
message: 'Invalid category',
});
return;
}
const storageService = await StorageFactory.getInstance();
try {
// Check if file exists first (fast check)
const exists = await storageService.fileExists(
orgId,
projectId,
category as 'uploads' | 'generated' | 'references',
filename,
);
if (!exists) {
res.status(404).json({
success: false,
message: 'File not found',
});
return;
}
// Determine content type from filename
const ext = filename.toLowerCase().split('.').pop();
const contentType =
{
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
}[ext || ''] || 'application/octet-stream';
// Set headers for optimal caching and performance
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
// Handle conditional requests (304 Not Modified)
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
res.status(304).end(); // Not Modified
return;
}
// Stream the file directly through our API (memory efficient)
const fileStream = await storageService.streamFile(
orgId,
projectId,
category as 'uploads' | 'generated' | 'references',
filename,
);
// Handle stream errors
fileStream.on('error', (streamError) => {
console.error('Stream error:', streamError);
if (!res.headersSent) {
res.status(500).json({
success: false,
message: 'Error streaming file',
});
}
});
// Stream the file without loading into memory
fileStream.pipe(res);
} catch (error) {
console.error('Failed to stream file:', error);
res.status(404).json({
success: false,
message: 'File not found',
});
}
}),
);
/**
* GET /api/images/url/:orgId/:projectId/:category/:filename
* Returns a presigned URL instead of redirecting
*/
imagesRouter.get(
'/images/url/:orgId/:projectId/:category/:filename',
asyncHandler(async (req: Request, res: Response): Promise<void> => {
const { orgId, projectId, category, filename } = req.params;
const { expiry = '3600' } = req.query; // Default 1 hour
// Validate required params (these are guaranteed by route pattern)
if (!orgId || !projectId || !category || !filename) {
res.status(400).json({
success: false,
message: 'Missing required parameters',
});
return;
}
if (!['uploads', 'generated', 'references'].includes(category)) {
res.status(400).json({
success: false,
message: 'Invalid category',
});
return;
}
const storageService = await StorageFactory.getInstance();
try {
const presignedUrl = await storageService.getPresignedDownloadUrl(
orgId,
projectId,
category as 'uploads' | 'generated' | 'references',
filename,
parseInt(expiry as string, 10),
);
res.json({
success: true,
url: presignedUrl,
expiresIn: parseInt(expiry as string, 10),
});
} catch (error) {
console.error('Failed to generate presigned URL:', error);
res.status(404).json({
success: false,
message: 'File not found or access denied',
});
}
}),
);
/**
* GET /api/images/generated
* List generated images for the authenticated project
*/
imagesRouter.get(
'/images/generated',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response) => {
const timestamp = new Date().toISOString();
const requestId = req.requestId;
// Parse and validate query parameters
const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 30, 1), 100);
const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0);
const prefix = (req.query.prefix as string) || undefined;
// Validate query parameters
if (isNaN(limit) || isNaN(offset)) {
res.status(400).json({
success: false,
message: 'Invalid query parameters',
error: 'limit and offset must be valid numbers',
});
return;
}
// Extract org/project from validated API key
const orgId = req.apiKey?.organizationSlug || 'default';
const projectId = req.apiKey?.projectSlug!;
console.log(
`[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`,
);
try {
// Get storage service instance
const storageService = await StorageFactory.getInstance();
// List files in generated category
const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix);
// Sort by lastModified descending (newest first)
allFiles.sort((a, b) => {
const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0;
const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0;
return dateB - dateA;
});
// Apply pagination
const total = allFiles.length;
const paginatedFiles = allFiles.slice(offset, offset + limit);
// Map to response format with public URLs
const images = paginatedFiles.map((file) => ({
filename: file.filename,
url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename),
size: file.size,
contentType: file.contentType,
lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(),
}));
const hasMore = offset + limit < total;
console.log(
`[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`,
);
return res.status(200).json({
success: true,
data: {
images,
total,
offset,
limit,
hasMore,
},
});
} catch (error) {
console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error);
return res.status(500).json({
success: false,
message: 'Failed to list generated images',
error: error instanceof Error ? error.message : 'Unknown error occurred',
});
}
}),
);