138 lines
4.0 KiB
TypeScript
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",
|
|
});
|
|
}
|
|
}),
|
|
);
|