File
Blob: src/worker/api/request.ts
| 1 | import type { AppContext } from "@/worker/hono"; |
| 2 | import { HttpError } from "@/worker/http"; |
| 3 | |
| 4 | interface ErrorSpec { |
| 5 | status: number; |
| 6 | code: string; |
| 7 | message: string; |
| 8 | } |
| 9 | |
| 10 | export const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0; |
| 11 | |
| 12 | export 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. |
| 19 | const 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 | |
| 26 | const 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 | |
| 32 | const 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 | |
| 38 | export 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 | |
| 47 | export 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 | |
| 57 | export 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. |
| 69 | export 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 | }; |