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.
This commit is contained in:
Replit Agent
2026-07-07 09:34:06 +00:00
parent 3a2f70c831
commit 56c8bcfb85
8 changed files with 584 additions and 27 deletions
+2
View File
@@ -20,6 +20,7 @@
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"arabic-persian-reshaper": "1.0.1",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.3",
"bidi-js": "^1.0.3",
"connect-pg-simple": "^10.0.0",
@@ -41,6 +42,7 @@
"zod": "catalog:"
},
"devDependencies": {
"@types/archiver": "^8.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10",
@@ -25,6 +25,30 @@ export class ObjectNotFoundError extends Error {
}
}
// Map a stored object's content type to a sensible file extension so
// downloads land with a usable name (stored paths are extension-less UUIDs).
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/heic": "heic",
"image/heif": "heif",
"image/bmp": "bmp",
"image/tiff": "tiff",
"image/svg+xml": "svg",
"application/pdf": "pdf",
};
export function extensionForContentType(
contentType: string | undefined,
): string {
if (!contentType) return "bin";
const base = contentType.split(";")[0].trim().toLowerCase();
return CONTENT_TYPE_EXTENSIONS[base] ?? "bin";
}
// ---------------------------------------------------------------------------
// Driver interface
// ---------------------------------------------------------------------------
+85 -1
View File
@@ -23,7 +23,12 @@ import {
getEffectiveRoleIds,
userHasPermission,
} from "../middlewares/auth";
import { ObjectStorageService } from "../lib/objectStorage";
import {
ObjectStorageService,
extensionForContentType,
} from "../lib/objectStorage";
import { ZipArchive } from "archiver";
import { Readable } from "stream";
import type { Request, Response, NextFunction } from "express";
const router: IRouter = Router();
@@ -2063,6 +2068,85 @@ router.get(
},
);
// Stream every photo of an album as a single ZIP. Guarded by protocol
// read access; each entry is named sequentially with an extension derived
// from the stored object's content type. Missing/unreadable objects are
// skipped rather than failing the whole archive.
router.get(
"/protocol/photo-albums/:id/download",
requireProtocolAccess,
async (req: Request, res: Response) => {
const albumId = Number(req.params.id);
const [album] = await db
.select({
id: protocolPhotoAlbumsTable.id,
name: protocolPhotoAlbumsTable.name,
})
.from(protocolPhotoAlbumsTable)
.where(eq(protocolPhotoAlbumsTable.id, albumId));
if (!album) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const rows = await db
.select()
.from(protocolPhotosTable)
.where(eq(protocolPhotosTable.albumId, albumId))
.orderBy(asc(protocolPhotosTable.sortOrder), asc(protocolPhotosTable.id));
if (rows.length === 0) {
res.status(404).json({ error: "album is empty", code: "not_found" });
return;
}
const safeName =
album.name
.replace(/[\r\n"\\/]/g, "")
.replace(/[\u0000-\u001f]/g, "")
.trim()
.slice(0, 180) || "album";
const asciiName = safeName.replace(/[^\x20-\x7e]/g, "_") || "album";
res.setHeader("Content-Type", "application/zip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodeURIComponent(
`${safeName}.zip`,
)}`,
);
const archive = new ZipArchive({ zlib: { level: 6 } });
archive.on("error", (err: Error) => {
req.log.error({ err }, "Error building album zip");
if (!res.headersSent) {
res.status(500).json({ error: "Failed to build archive" });
} else {
res.destroy();
}
});
archive.pipe(res);
let index = 0;
for (const photo of rows) {
index += 1;
try {
const file = await objectStorage.getObjectEntityFile(photo.objectPath);
const meta = await file.getMetadata();
const ext = extensionForContentType(meta.contentType);
const entryName = `${String(index).padStart(3, "0")}.${ext}`;
archive.append(file.createReadStream() as Readable, {
name: entryName,
});
} catch (err) {
req.log.warn(
{ err, photoId: photo.id },
"Skipping unreadable photo in album zip",
);
}
}
await archive.finalize();
},
);
router.post(
"/protocol/photo-albums/:id/photos",
requireMutate,
+33 -1
View File
@@ -4,7 +4,11 @@ import {
RequestUploadUrlBody,
RequestUploadUrlResponse,
} from "@workspace/api-zod";
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
import {
ObjectStorageService,
ObjectNotFoundError,
extensionForContentType,
} from "../lib/objectStorage";
import { canUserReadObjectPath } from "../lib/objectAuthz";
import { requireAuth } from "../middlewares/auth";
@@ -109,6 +113,34 @@ router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Resp
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);