255 lines
7.8 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
}),
|
|
);
|