Ctrl K
ComunidadGuías…

src/features/attachments.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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 };