2026-04-20 10:55:11 +00:00
|
|
|
import { Router, type IRouter, type Request, type Response } from "express";
|
|
|
|
|
import { Readable } from "stream";
|
|
|
|
|
import {
|
|
|
|
|
RequestUploadUrlBody,
|
|
|
|
|
RequestUploadUrlResponse,
|
|
|
|
|
} from "@workspace/api-zod";
|
|
|
|
|
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
|
2026-05-13 09:54:35 +00:00
|
|
|
import { canUserReadObjectPath } from "../lib/objectAuthz";
|
2026-04-20 11:06:15 +00:00
|
|
|
import { requireAuth } from "../middlewares/auth";
|
2026-04-20 10:55:11 +00:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
*/
|
2026-04-20 11:06:15 +00:00
|
|
|
router.post("/storage/uploads/request-url", requireAuth, async (req: Request, res: Response) => {
|
2026-04-20 10:55:11 +00:00
|
|
|
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<Uint8Array>);
|
|
|
|
|
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.
|
|
|
|
|
*/
|
2026-04-20 11:06:15 +00:00
|
|
|
router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Response) => {
|
2026-04-20 10:55:11 +00:00
|
|
|
try {
|
|
|
|
|
const raw = req.params.path;
|
|
|
|
|
const wildcardPath = Array.isArray(raw) ? raw.join("/") : raw;
|
|
|
|
|
const objectPath = `/objects/${wildcardPath}`;
|
2026-05-13 09:54:35 +00:00
|
|
|
|
|
|
|
|
// 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/<id>` 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 10:55:11 +00:00
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
if (response.body) {
|
|
|
|
|
const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
|
|
|
|
|
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;
|