diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index ceb3ea15..386a4800 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -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", diff --git a/artifacts/api-server/src/lib/objectStorage.ts b/artifacts/api-server/src/lib/objectStorage.ts index 61fc9807..a953cac8 100644 --- a/artifacts/api-server/src/lib/objectStorage.ts +++ b/artifacts/api-server/src/lib/objectStorage.ts @@ -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 = { + "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 // --------------------------------------------------------------------------- diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 9fbabc82..23fd924f 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -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, diff --git a/artifacts/api-server/src/routes/storage.ts b/artifacts/api-server/src/routes/storage.ts index cd6bfb74..1845a486 100644 --- a/artifacts/api-server/src/routes/storage.ts +++ b/artifacts/api-server/src/routes/storage.ts @@ -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); nodeStream.pipe(res); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 09d6336d..6fae747d 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1774,6 +1774,8 @@ "linkMeeting": "ربط بلقاء خارجي", "noMeeting": "بدون ربط", "addPhotos": "إضافة صور", + "download": "تنزيل", + "downloadAll": "تنزيل الكل", "uploading": "جارٍ الرفع", "emptyAlbums": "لا توجد ألبومات.", "emptyPhotos": "لا توجد صور في هذا الألبوم.", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 9595eb57..fb2da3ba 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1647,6 +1647,8 @@ "linkMeeting": "Link to external meeting", "noMeeting": "No link", "addPhotos": "Add photos", + "download": "Download", + "downloadAll": "Download all", "uploading": "Uploading", "emptyAlbums": "No albums yet.", "emptyPhotos": "No photos in this album.", diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index df738ed8..4573b697 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -25,6 +25,7 @@ import { Camera, Upload, Loader2, + Download, Image as ImageIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -1982,6 +1983,15 @@ type Photo = { // Build the authorized image URL from a stored `/objects/` path. const photoSrc = (objectPath: string) => `${API}/storage${objectPath}`; +// Force-download URL for a single photo (attachment via ?download=1). The +// optional name base gets an extension appended server-side. +const photoDownloadSrc = (objectPath: string, name?: string) => { + const base = `${API}/storage${objectPath}?download=1`; + return name ? `${base}&name=${encodeURIComponent(name)}` : base; +}; +// ZIP download URL for a whole album. +const albumDownloadSrc = (albumId: number) => + `${API}/protocol/photo-albums/${albumId}/download`; function PhotosView({ canMutate, @@ -2090,31 +2100,42 @@ function PhotosView({ )} - {canMutate && ( - - )} +
+ {(photos.data?.length ?? 0) > 0 && ( + + + {t("protocol.photos.downloadAll")} + + )} + {canMutate && ( + + )} +
{photos.data && photos.data.length === 0 && ( @@ -2134,6 +2155,18 @@ function PhotosView({ className="w-full h-full object-cover" /> + + + {canMutate && (