Ctrl K
ComunidadGuías…

src/features/response.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Response builder utilities for route handlers
 */

import { addPageImageUrls } from "#db/image-urls.ts";
import { settings } from "#db/settings.ts";
import type {
  AdminDestinationId,
  AdminPathParams,
} from "#shared/admin-surface/ids.ts";
import { adminPath } from "#shared/admin-surface.ts";
import { buildFlashCookie, type FlashLevel } from "#shared/cookies.ts";
import { stashForm } from "#shared/form-stash.ts";
import { getSavedFormData } from "#shared/forms/saved-data.ts";
import { appendIframeParam, getRequestId } from "#shared/request-context.ts";
import {
  databaseBusyPage,
  MIGRATION_IN_PROGRESS_REFRESH_SECONDS,
  migrationInProgressPage,
  notFoundPage,
  TEMPORARY_ERROR_REFRESH_SECONDS,
  temporaryErrorPage,
} from "#templates/public/errors.tsx";

/**
 * Shared TextEncoder for pre-encoding string response bodies to Uint8Array.
 * Bunny Edge's runtime intermittently fails to decode JS string bodies
 * ("Unknown: error decoding response body"). Pre-encoding to bytes
 * bypasses the runtime's string-to-UTF8 path entirely.
 */
const encoder = new TextEncoder();
export const encodeBody = (text: string): ArrayBuffer =>
  encoder.encode(text).buffer as ArrayBuffer;

/** One response builder of a kind: a body, and a status that defaults to 200. */
export type EncodedResponse = (body: string, status?: number) => Response;

/**
 * A response of one kind, ready to take a body. Every kind we send is text we
 * built ourselves, so the charset is always UTF-8.
 */
const encodedResponse =
  (contentType: string): EncodedResponse =>
  (body, status = 200) =>
    new Response(encodeBody(body), {
      headers: { "content-type": `${contentType}; charset=utf-8` },
      status,
    });

/**
 * Hand on something that was loaded, unless it came back as a Response. A
 * Response is already the answer — a 404, a 400 — so it goes straight back.
 */
export const orResponse = async <T>(
  loaded: T | Response,
  use: (value: T) => Response | Promise<Response>,
): Promise<Response> => (loaded instanceof Response ? loaded : use(loaded));

/** Create HTML response */
export const htmlResponse: EncodedResponse = encodedResponse("text/html");

/**
 * Respond with a rendered public page. The page renders the site footer, so
 * the footer's img#N references resolve like the page's own before the page
 * renders. The lookup no-ops outside a request. A failed lookup still renders
 * the page: an unresolved img#N falls back to its alt text.
 */
export const publicPageResponse = async (
  renderPage: () => string,
  status = 200,
): Promise<Response> => {
  try {
    await addPageImageUrls([settings.siteFooter]);
  } catch {
    // The database is unavailable. The footer shows its alt text instead.
  }
  return htmlResponse(renderPage(), status);
};

/**
 * Create 404 not found response.
 */
export const notFoundResponse = (): Promise<Response> =>
  publicPageResponse(notFoundPage, 404);

type RequestErrorResponse = (method: string) => Promise<Response>;

// A refresh loads the URL as GET. It cannot preserve a submitted form.
const requestErrorResponse =
  (
    page: (autoRefresh: boolean) => string,
    retryAfterSeconds: number,
  ): RequestErrorResponse =>
  async (method) =>
    withHeaders(
      await publicPageResponse(
        () => page(method === "GET" || method === "HEAD"),
        503,
      ),
      { "retry-after": `${retryAfterSeconds}` },
    );

export const temporaryErrorResponse: RequestErrorResponse =
  requestErrorResponse(temporaryErrorPage, TEMPORARY_ERROR_REFRESH_SECONDS);

export const databaseBusyResponse: RequestErrorResponse = requestErrorResponse(
  databaseBusyPage,
  TEMPORARY_ERROR_REFRESH_SECONDS,
);

/**
 * Create "migration in progress" response, shown while another isolate runs a
 * database migration and pre-migration backup. 503 with an auto-refresh, like
 * temporaryErrorResponse, but with a message that explains the wait instead of
 * presenting it as an error.
 */
export const migrationInProgressResponse = async (): Promise<Response> =>
  withHeaders(await publicPageResponse(migrationInProgressPage, 503), {
    "retry-after": `${MIGRATION_IN_PROGRESS_REFRESH_SECONDS}`,
  });

/**
 * Create bare 302 redirect response (no message).
 * Use for external URLs, setup flow, public pages, and other cases
 * where the target page doesn't render success/error banners.
 * For admin redirects that should show a message, use `redirect` instead.
 * Automatically appends ?iframe=true when the current request is in iframe mode.
 */
export const redirectResponse = (url: string, cookie?: string): Response => {
  const headers: HeadersInit = {
    "content-type": "text/html; charset=utf-8",
    location: appendIframeParam(url),
  };
  if (cookie) {
    headers["set-cookie"] = cookie;
  }
  return new Response(null, { headers, status: 302 });
};

/** Options for redirect */
type RedirectOpts = {
  formId?: string;
  cookie?: string;
  form?: URLSearchParams;
  result?: string;
  /** Override the flash level; defaults to success/error from `succeeded`. */
  level?: FlashLevel;
};

type RedirectFeedback = [
  message: string,
  succeeded: boolean,
  opts?: RedirectOpts,
];

type UrlRedirect = (url: string, ...feedback: RedirectFeedback) => Response;

type AdminRedirect = <Id extends AdminDestinationId>(
  endpoint: Id,
  params: AdminPathParams<Id>,
  ...feedback: RedirectFeedback
) => Response;

/**
 * Field-name segments whose values must never be written to the re-fill stash,
 * even briefly: secrets the user should re-enter rather than have persisted in
 * server memory (passwords, API/secret keys, signing tokens, the CSRF token).
 * Matched as whole underscore-delimited segments, so "monkey"/"keyword" are
 * safe. PII is deliberately not listed — restoring it is the point of the
 * feature, and it is never reflected for sensitive field *types* anyway.
 */
const SENSITIVE_STASH_KEY = /(?:^|_)(?:password|secret|token|key)(?:_|$)/i;

/**
 * On a failed PRG redirect, stash the just-submitted form values so the
 * follow-up GET can re-fill the fields. Prefers an explicitly passed form,
 * otherwise the per-request submission captured during CSRF parsing. Secret
 * fields are dropped — they should be re-entered, never persisted. Returns the
 * redemption token, or null when there is nothing (eligible) to stash.
 */
const maybeStashForm = (explicit?: URLSearchParams): string | null => {
  const source = explicit ?? getSavedFormData();
  if (!source) return null;
  const safe = new URLSearchParams();
  for (const [key, value] of source) {
    if (!SENSITIVE_STASH_KEY.test(key)) safe.append(key, value);
  }
  const serialized = safe.toString();
  return serialized ? stashForm(serialized) : null;
};

/**
 * Redirect with a success or error message (PRG pattern).
 * Stores the message in a flash cookie instead of the query string
 * to avoid leaking potentially sensitive data in URLs, browser history,
 * and referrer headers.
 * When formId is provided, adds a `form` param and `#formId` anchor so the
 * browser scrolls to the form that was just submitted.
 */
export const redirect: UrlRedirect = (url, message, succeeded, opts) => {
  const target = opts?.form?.get("return_url") || url;
  if (!URL.canParse(target, "http://localhost")) {
    throw new TypeError("Invalid redirect URL");
  }

  const u = new URL(target, "http://localhost");
  const flashId = getRequestId();
  u.searchParams.set("flash", flashId);
  if (opts?.formId) {
    u.searchParams.set("form", opts.formId);
    u.hash = opts.formId;
  }
  const flash = buildFlashCookie(
    flashId,
    message,
    succeeded,
    opts?.result,
    opts?.level,
    succeeded ? undefined : (maybeStashForm(opts?.form) ?? undefined),
  );
  const response = redirectResponse(u.pathname + u.search + u.hash, flash);
  if (opts?.cookie) withCookie(response, opts.cookie);
  return response;
};

/** Redirect to a declared admin destination with the existing flash and form rules. */
export const adminRedirect: AdminRedirect = (
  endpoint,
  params,
  message,
  succeeded,
  opts,
) => redirect(adminPath(endpoint, params), message, succeeded, opts);

/**
 * Redirect with an error message (PRG pattern).
 * Shorthand for `redirect(url, message, false, { formId })`.
 */
export const errorRedirect = (
  url: string,
  message: string,
  formId?: string,
): Response => redirect(url, message, false, formId ? { formId } : undefined);

/** The `onInvalid` handler that returns to one row's own detail page. */
export const redirectToDetail =
  <Id extends AdminDestinationId>(endpoint: Id) =>
  (args: { error: string; params: AdminPathParams<Id> }): Response =>
    adminRedirect(endpoint, args.params, args.error, false);

/**
 * Redirect with a neutral informational message (PRG pattern), e.g. confirming
 * an opt-out. Rendered in the info style rather than success or error.
 */
export const infoRedirect = (url: string, message: string): Response =>
  redirect(url, message, true, { level: "info" });

const jsonBody = encodedResponse("application/json");

/** Create JSON response */
export const jsonResponse = (data: unknown, status = 200): Response =>
  jsonBody(JSON.stringify(data), status);

/** One JSON response whose body is a record re-read from the primary, so a
 * lagging replica cannot 404 the row the write just made. */
export const reReadResponse =
  (key: string, read: (id: number) => Promise<unknown>) =>
  async (id: number, status?: number): Promise<Response> =>
    jsonResponse({ [key]: await read(id) }, status);

/** Create plain text response */
export const plainResponse: EncodedResponse = encodedResponse("text/plain");

/** Create iCalendar response */
export const icsResponse: EncodedResponse = encodedResponse("text/calendar");

/** Create RSS/XML response */
export const rssResponse: EncodedResponse = encodedResponse(
  "application/rss+xml",
);

/**
 * Build a file-download response: the body, a fixed content-type, and a
 * `Content-Disposition: attachment` header that names the download. The
 * `filename` is already validated by the caller (it is operator-chosen and
 * constrained — a backup leaf name, a slug-based catalog name, or a CSV
 * name), so it is quoted directly without RFC 5987 encoding.
 */
export const downloadResponse = (
  body: BodyInit,
  filename: string,
  contentType: string,
): Response =>
  new Response(body, {
    headers: {
      "content-disposition": `attachment; filename="${filename}"`,
      "content-type": contentType,
    },
  });

/**
 * Set headers on a built response.
 * Takes a response the app built: a redirect or fetched response has
 * immutable headers.
 * Mutates headers in-place to avoid re-reading the response body.
 */
export const withHeaders = (
  response: Response,
  headers: Record<string, string>,
): Response => {
  for (const [name, value] of Object.entries(headers)) {
    response.headers.set(name, value);
  }
  return response;
};

/**
 * Add cookie header to response.
 * Mutates headers in-place to avoid re-reading the response body.
 */
export const withCookie = (response: Response, cookie: string): Response => {
  response.headers.append("set-cookie", cookie);
  return response;
};