src/features/attachments.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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 };