/** * Attachment download route โ€” serves encrypted listing attachments from Bunny CDN. * GET /attachment/:id?a=attendeeId&exp=timestamp&sig=hmacSignature * * URLs are signed with HMAC and time-limited (1 hour) to prevent sharing. * Each download increments the attendee's attachment_downloads counter. */ import { hasActiveBookingLine } from "#db/attendees/queries.ts"; import { incrementAttachmentDownloads } from "#db/attendees/update.ts"; import { getListingWithCount } from "#db/listings/records.ts"; import { notFoundResponse } from "#routes/response.ts"; import { defineRoutes, type TypedRouteHandler } from "#routes/router.ts"; import { getAttachmentMediaType } from "#shared/attachment-media-type.ts"; import { verifyAttachmentUrl } from "#shared/attachment-url.ts"; import { downloadImage, getBasename, isStorageEnabled, sanitizeBasename, } from "#shared/storage.ts"; import { parsePositiveInt } from "#shared/validation/number.ts"; /** * Sanitize an attachment name for use in the Content-Disposition header. * Strips characters that can be used for HTTP header injection or path traversal. */ const sanitizeAsciiFilename = (name: string): string => sanitizeBasename(name, [/[^\x20-\x7E]/g, ""], [/[":;\\]/g, "_"]); /** RFC 5987-encode a filename for the filename* parameter (UTF-8 percent-encoded). */ const encodeRfc5987 = (name: string): string => { const bytes = new TextEncoder().encode(getBasename(name) || "file"); let out = ""; for (const byte of bytes) { // attr-char per RFC 5987 ยง3.2.1 โ€” alphanumerics plus a small set of safe punctuation if ( (byte >= 0x30 && byte <= 0x39) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) || byte === 0x2d || byte === 0x2e || byte === 0x5f || byte === 0x7e ) { out += String.fromCharCode(byte); } else { out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; } } return out; }; /** Build a Content-Disposition value with both ASCII filename and RFC 5987 filename*. */ const buildContentDisposition = (name: string): string => { const ascii = sanitizeAsciiFilename(name); const encoded = encodeRfc5987(name); return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; }; /** Handle GET /attachment/:id */ const handleAttachmentDownload: TypedRouteHandler< "GET /attachment/:id" > = async (request, { id }) => { if (!isStorageEnabled()) return notFoundResponse(); // Extract and validate query params const url = new URL(request.url); const attendeeIdStr = url.searchParams.get("a"); const exp = url.searchParams.get("exp"); const sig = url.searchParams.get("sig"); if (!attendeeIdStr || !exp || !sig) { return new Response("Forbidden", { status: 403 }); } const attendeeId = parsePositiveInt(attendeeIdStr); if (attendeeId === null) return new Response("Forbidden", { status: 403 }); // Verify signature and expiry const valid = await verifyAttachmentUrl(id, attendeeId, exp, sig); if (!valid) return new Response("Forbidden", { status: 403 }); // Look up listing and verify it has an attachment const listing = await getListingWithCount(id); if (!listing?.attachment_url) return notFoundResponse(); // Authorize against the EXACT (attendee, listing) booking row with a real // (quantity > 0) line โ€” not a left-joined sibling row. A line later marked // no-quantity stops authorizing the protected attachment. if (!(await hasActiveBookingLine(attendeeId, id))) { return new Response("Forbidden", { status: 403 }); } // Download and decrypt from CDN const data = await downloadImage(listing.attachment_url); if (!data) return notFoundResponse(); // Increment download counter (fire-and-forget) await incrementAttachmentDownloads(attendeeId, id); // Serve with Content-Disposition for proper download filename const contentType = getAttachmentMediaType(listing.attachment_name); const disposition = buildContentDisposition(listing.attachment_name); return new Response(data.buffer as BodyInit, { headers: { "content-disposition": disposition, "content-type": contentType, }, }); }; /** Attachment routes */ const attachmentRoutes = defineRoutes({ "GET /attachment/:id": handleAttachmentDownload, }); export { attachmentRoutes };