import { Router, type IRouter, type Request, type Response } from "express"; import { Readable } from "stream"; import { RequestUploadUrlBody, RequestUploadUrlResponse, } from "@workspace/api-zod"; import { ObjectStorageService, ObjectNotFoundError, extensionForContentType, } from "../lib/objectStorage"; import { canUserReadObjectPath } from "../lib/objectAuthz"; import { requireAuth } from "../middlewares/auth"; const router: IRouter = Router(); const objectStorageService = new ObjectStorageService(); /** * POST /storage/uploads/request-url * * Request a presigned URL for file upload. * The client sends JSON metadata (name, size, contentType) — NOT the file. * Then uploads the file directly to the returned presigned URL. */ router.post("/storage/uploads/request-url", requireAuth, async (req: Request, res: Response) => { const parsed = RequestUploadUrlBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: "Missing or invalid required fields" }); return; } try { const { name, size, contentType } = parsed.data; const uploadURL = await objectStorageService.getObjectEntityUploadURL(); const objectPath = objectStorageService.normalizeObjectEntityPath(uploadURL); res.json( RequestUploadUrlResponse.parse({ uploadURL, objectPath, metadata: { name, size, contentType }, }), ); } catch (error) { req.log.error({ err: error }, "Error generating upload URL"); res.status(500).json({ error: "Failed to generate upload URL" }); } }); /** * GET /storage/public-objects/* * * Serve public assets from PUBLIC_OBJECT_SEARCH_PATHS. * These are unconditionally public — no authentication or ACL checks. * IMPORTANT: Always provide this endpoint when object storage is set up. */ router.get("/storage/public-objects/*filePath", async (req: Request, res: Response) => { try { const raw = req.params.filePath; const filePath = Array.isArray(raw) ? raw.join("/") : raw; const file = await objectStorageService.searchPublicObject(filePath); if (!file) { res.status(404).json({ error: "File not found" }); return; } const response = await objectStorageService.downloadObject(file); res.status(response.status); response.headers.forEach((value, key) => res.setHeader(key, value)); if (response.body) { const nodeStream = Readable.fromWeb(response.body as ReadableStream); nodeStream.pipe(res); } else { res.end(); } } catch (error) { req.log.error({ err: error }, "Error serving public object"); res.status(500).json({ error: "Failed to serve public object" }); } }); /** * GET /storage/objects/* * * Serve object entities from PRIVATE_OBJECT_DIR. * These are served from a separate path from /public-objects and can optionally * be protected with authentication or ACL checks based on the use case. */ router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Response) => { try { const raw = req.params.path; const wildcardPath = Array.isArray(raw) ? raw.join("/") : raw; const objectPath = `/objects/${wildcardPath}`; // MR-H1: object-level authorization. Look up which entity (if any) // references this path and apply that entity's read rule. Orphan // paths and unauthorized callers BOTH get the same 404 response so // we do not leak which `/objects/` paths exist in storage. const userId = req.session.userId!; const allowed = await canUserReadObjectPath(userId, objectPath); if (!allowed) { res.status(404).json({ error: "Object not found" }); return; } const objectFile = await objectStorageService.getObjectEntityFile(objectPath); const response = await objectStorageService.downloadObject(objectFile); res.status(response.status); response.headers.forEach((value, key) => res.setHeader(key, value)); // Opt-in force-download: `?download=1` serves the object as an // attachment (Content-Disposition) so browsers save it instead of // rendering inline. An optional `name` base is combined with the // extension derived from the object's content type. if (req.query.download !== undefined) { const contentType = response.headers.get("content-type") ?? undefined; const ext = extensionForContentType(contentType); const baseRaw = typeof req.query.name === "string" && req.query.name.trim() ? req.query.name : (wildcardPath.split("/").pop() ?? "download"); // Strip characters that would break the header or the filesystem. const safeBase = baseRaw .replace(/[\r\n"\\/]/g, "") .replace(/[\u0000-\u001f]/g, "") .trim() .slice(0, 180) || "download"; const filename = `${safeBase}.${ext}`; const asciiFallback = filename.replace(/[^\x20-\x7e]/g, "_") || `download.${ext}`; res.setHeader( "Content-Disposition", `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent( filename, )}`, ); } if (response.body) { const nodeStream = Readable.fromWeb(response.body as ReadableStream); nodeStream.pipe(res); } else { res.end(); } } catch (error) { if (error instanceof ObjectNotFoundError) { req.log.warn({ err: error }, "Object not found"); res.status(404).json({ error: "Object not found" }); return; } req.log.error({ err: error }, "Error serving object"); res.status(500).json({ error: "Failed to serve object" }); } }); export default router;