Files
TX/artifacts/api-server/src/routes/storage.ts
T
Replit Agent 56c8bcfb85 Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
  types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
  Content-Disposition: attachment (with optional ?name base + derived ext);
  default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
  photos as a ZIP (guarded by requireProtocolAccess), named from the album;
  unreadable objects are skipped rather than failing the archive. Empty album
  returns 404.
- Added archiver@8 dependency (+@types/archiver dev).

Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header (hidden when empty).
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:34:06 +00:00

162 lines
5.5 KiB
TypeScript

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<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.
*/
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/<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;
}
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<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;