trptk/lib/apiUtils.ts
2026-02-24 17:14:07 +01:00

122 lines
3.4 KiB
TypeScript

import { NextResponse } from "next/server";
import { headers } from "next/headers";
const ALLOWED_ORIGINS = (process.env.NEXT_PUBLIC_APP_URL ?? "")
.split(",")
.map((o) => o.trim())
.filter(Boolean);
/**
* Wrap an API route handler to catch errors and return a safe
* generic message instead of leaking backend details.
* Internal errors are logged server-side.
*/
export function safeRoute<T extends unknown[]>(
handler: (...args: T) => Promise<NextResponse>,
) {
return async (...args: T) => {
try {
return await handler(...args);
} catch (e) {
console.error("[api]", (e as Error).message);
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 },
);
}
};
}
/**
* Return a 400 response with a user-facing message.
*/
export function badRequest(message: string) {
return NextResponse.json({ error: message }, { status: 400 });
}
/**
* Check the Origin header on mutating requests to prevent CSRF.
* Returns a 403 response if the origin is invalid, or null if OK.
*/
export async function checkCsrf(): Promise<NextResponse | null> {
const hdrs = await headers();
const origin = hdrs.get("origin");
// If no Origin header (e.g. same-origin non-CORS), allow the request.
// If Origin is present, it must match our app URL.
if (origin && ALLOWED_ORIGINS.length > 0 && !ALLOWED_ORIGINS.includes(origin)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/**
* Validate that a value is a non-empty string.
*/
export function isNonEmptyString(v: unknown): v is string {
return typeof v === "string" && v.trim().length > 0;
}
/**
* Validate that a value is a positive integer.
*/
export function isPositiveInt(v: unknown): v is number {
return typeof v === "number" && Number.isInteger(v) && v > 0;
}
/**
* Validate a basic email format.
*/
export function isValidEmail(v: unknown): v is string {
return typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
}
/**
* Validate that an ID matches Medusa's expected format (prefix_alphanumeric).
* Prevents path traversal via ID parameters.
*/
export function isValidMedusaId(v: unknown): v is string {
return typeof v === "string" && /^[a-z]+_[A-Za-z0-9]+$/.test(v);
}
/**
* Pick only allowed address fields from an unknown object.
* Prevents mass assignment of unexpected fields.
*/
export function pickAddressFields(obj: unknown): Record<string, string | undefined> | null {
if (!obj || typeof obj !== "object") return null;
const src = obj as Record<string, unknown>;
const ALLOWED_KEYS = [
"first_name", "last_name", "address_1", "address_2",
"city", "province", "postal_code", "country_code", "phone",
] as const;
const result: Record<string, string | undefined> = {};
for (const key of ALLOWED_KEYS) {
const val = src[key];
if (typeof val === "string") {
result[key] = val;
}
}
return result;
}
/**
* Safely parse JSON from a request body.
* Returns null if the body is invalid.
*/
export async function parseBody<T = Record<string, unknown>>(
request: Request,
): Promise<T | null> {
try {
return await request.json();
} catch {
return null;
}
}
/**
* Escape a JSON string for safe embedding in a <script> tag.
* Prevents </script> injection in JSON-LD blocks.
*/
export function safeJsonLd(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}