122 lines
3.4 KiB
TypeScript
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");
|
|
}
|