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

138 lines
4.0 KiB
TypeScript

import { Router, Request, Response } from "express";
import { StorageFactory } from "../services/StorageFactory";
import { asyncHandler } from "../middleware/errorHandler";
export const imagesRouter = 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) => {
const { orgId, projectId, category, filename } = req.params;
// Validate category
if (!["uploads", "generated", "references"].includes(category)) {
return res.status(400).json({
success: false,
message: "Invalid category",
});
}
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) {
return res.status(404).json({
success: false,
message: "File not found",
});
}
// 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}"`) {
return res.status(304).end(); // Not Modified
}
// 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);
return 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) => {
const { orgId, projectId, category, filename } = req.params;
const { expiry = "3600" } = req.query; // Default 1 hour
if (!["uploads", "generated", "references"].includes(category)) {
return res.status(400).json({
success: false,
message: "Invalid category",
});
}
const storageService = await StorageFactory.getInstance();
try {
const presignedUrl = await storageService.getPresignedDownloadUrl(
orgId,
projectId,
category as "uploads" | "generated" | "references",
filename,
parseInt(expiry as string, 10),
);
return res.json({
success: true,
url: presignedUrl,
expiresIn: parseInt(expiry as string, 10),
});
} catch (error) {
console.error("Failed to generate presigned URL:", error);
return res.status(404).json({
success: false,
message: "File not found or access denied",
});
}
}),
);