Skip to content
File

Blob: src/worker/api/request.ts

typescript76 lines
1import type { AppContext } from "@/worker/hono";
2import { HttpError } from "@/worker/http";
3 
4interface ErrorSpec {
5 status: number;
6 code: string;
7 message: string;
8}
9 
10export const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0;
11 
12export const isStringArray = (value: unknown): value is string[] =>
13 Array.isArray(value) && value.every(isNonEmptyString);
14 
15// Accept `application/json`, `application/json; charset=utf-8`, and the
16// `+json` family (RFC 6839). Anything else for a tessera-owned mutating
17// route is treated as a 400 invalid_content_type so a `text/plain` body
18// containing JSON cannot slip through readJsonBody's body parser.
19const isJsonContentType = (raw: string | undefined | null): boolean => {
20 if (!raw) return false;
21 const value = raw.split(";")[0]?.trim().toLowerCase();
22 if (!value) return false;
23 return value === "application/json" || value.endsWith("+json");
24};
25 
26const requireJsonContentType = (c: AppContext): void => {
27 if (!isJsonContentType(c.req.header("content-type"))) {
28 throw new HttpError(400, "invalid_content_type", "Request body must be JSON (Content-Type: application/json).");
29 }
30};
31 
32const hasBody = (c: AppContext): boolean => {
33 const contentLength = c.req.header("content-length");
34 if (contentLength !== undefined) return Number.parseInt(contentLength, 10) > 0;
35 return Boolean(c.req.header("content-type"));
36};
37 
38export const readJsonBody = async <T>(c: AppContext, message = "JSON body required."): Promise<T> => {
39 requireJsonContentType(c);
40 try {
41 return (await c.req.json()) as T;
42 } catch {
43 throw new HttpError(400, "invalid_body", message);
44 }
45};
46 
47export const readOptionalJsonBody = async <T>(c: AppContext, fallback: T): Promise<T> => {
48 if (!hasBody(c)) return fallback;
49 requireJsonContentType(c);
50 try {
51 return (await c.req.json()) as T;
52 } catch {
53 return fallback;
54 }
55};
56 
57export const requiredParam = (c: AppContext, name: string, error: ErrorSpec): string => {
58 const value = c.req.param(name);
59 if (!value) {
60 throw new HttpError(error.status, error.code, error.message);
61 }
62 return value;
63};
64 
65// Cloudflare-fronted production always sets CF-Connecting-IP. Failing
66// closed when it is missing keeps every public Turnstile / rate-limited
67// flow from collapsing into a shared `unknown` bucket and removes the
68// X-Forwarded-For fallback which is forgeable everywhere it is exposed.
69export const remoteIp = (c: AppContext): string => {
70 const ip = c.req.header("CF-Connecting-IP")?.trim();
71 if (!ip) {
72 throw new HttpError(400, "missing_client_ip", "Client IP could not be determined.");
73 }
74 return ip;
75};