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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user