56c8bcfb85
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.
162 lines
5.5 KiB
TypeScript
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;
|