mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Hoist the ownership-check boilerplate that nine medication sub-routes
were each carrying inline into the shared route-guard helper, and align
the three W19 routes that hand-wrote the rate-limit header loop on the
new apiError option-bag form.
Routes touched:
- titration: shared guard + replaced parseDoseString with the shared
parseDoseMgOrNull + replaced the inline GLP1_DRUGS reverse-lookup
with findDrugIdByBrand.
- side-effects: shared guard + option-bag headers + dropped the
client-supplied 'category' field per code-M6 (server derives via
categoryForEntry).
- inventory: shared guard + option-bag headers.
- cadence: shared guard layered on top of the W21 Fix-O timezone wiring.
- intake (POST + GET): shared guard.
- compliance: shared guard.
- phase-config (GET + PUT + DELETE): shared guard.
- api-endpoint (GET + PUT): shared guard.
- glp1: shared guard replacing Fix-K's inline copy + option-bag headers.
apiError now accepts { headers: Record<string,string> } in its third
argument bag; the headers flow through NextResponse and are stripped
from the meta envelope so the existing { errorCode, ... } meta usage
keeps its shape.
121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
|
|
export function apiSuccess<T>(data: T, status = 200) {
|
|
return NextResponse.json({ data, error: null }, { status });
|
|
}
|
|
|
|
/**
|
|
* `meta` is additive — clients that ignore it see the unchanged
|
|
* `{ data: null, error: <string> }` envelope. New callers can pass
|
|
* `{ errorCode: "credentials_rejected" }` so the UI translates the message
|
|
* via `t("settings.testConnection.errors." + errorCode)` instead of
|
|
* displaying the server's English fallback.
|
|
*
|
|
* v1.4.25 W21 Fix-N — the third argument may also carry a `headers`
|
|
* record (e.g. `{ headers: rateLimitHeaders(rl) }`) so 429 responses
|
|
* can attach the `X-RateLimit-*` triple in the same call. The `headers`
|
|
* key is *not* echoed into the JSON body — it is consumed by the
|
|
* NextResponse constructor and stripped from the meta envelope.
|
|
*/
|
|
export function apiError(
|
|
message: string,
|
|
status = 400,
|
|
meta?: {
|
|
errorCode?: string;
|
|
headers?: Record<string, string>;
|
|
} & Record<string, unknown>,
|
|
) {
|
|
const { headers, ...rest } = meta ?? {};
|
|
const metaKeys = Object.keys(rest);
|
|
return NextResponse.json(
|
|
{
|
|
data: null,
|
|
error: message,
|
|
...(metaKeys.length > 0 ? { meta: rest } : {}),
|
|
},
|
|
{
|
|
status,
|
|
...(headers ? { headers } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Safely parse JSON body from a request.
|
|
* Returns the parsed body or a 400 error response if parsing fails.
|
|
*/
|
|
export async function safeJson<T = unknown>(
|
|
request: Request,
|
|
): Promise<{ data: T; error?: never } | { data?: never; error: Response }> {
|
|
const ct = request.headers.get("content-type");
|
|
if (!ct || !ct.includes("application/json")) {
|
|
return { error: apiError("Content-Type must be application/json", 415) };
|
|
}
|
|
try {
|
|
const data = (await request.json()) as T;
|
|
return { data };
|
|
} catch {
|
|
return { error: apiError("Invalid JSON body", 400) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the real client IP from a request, respecting trusted-proxy
|
|
* configuration. V3 audit: previously took the leftmost XFF entry blindly,
|
|
* letting a client rotate `X-Forwarded-For: 1.2.3.4` per request to defeat
|
|
* IP-based rate-limits.
|
|
*
|
|
* Trust model (`TRUST_PROXY_HOPS` env):
|
|
* - "0" → ignore XFF entirely, fall back to x-real-ip / null
|
|
* (use this if HealthLog is internet-facing without a
|
|
* reverse proxy you control)
|
|
* - "1" (default)→ trust exactly one hop (typical Coolify / Caddy /
|
|
* Cloudflare-Tunnel single-proxy deployment); read the
|
|
* rightmost XFF entry which is the IP your proxy
|
|
* observed when the request arrived.
|
|
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
|
|
*
|
|
* Returns the resolved IP or null when no trusted source is available.
|
|
*/
|
|
function looksLikeIp(s: string): boolean {
|
|
return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45;
|
|
}
|
|
|
|
function parseTrustProxyHops(raw: string | undefined): number {
|
|
if (raw === undefined) return 1;
|
|
const trimmed = raw.trim();
|
|
if (!/^\d+$/.test(trimmed)) {
|
|
// Reject explicitly-invalid values so an operator typo doesn't silently
|
|
// switch a real-proxy deployment to "no XFF trust" mode (review
|
|
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
|
|
throw new Error(
|
|
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
|
|
);
|
|
}
|
|
return parseInt(trimmed, 10);
|
|
}
|
|
|
|
export function getClientIp(request: Request): string | null {
|
|
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
|
|
|
if (hops > 0) {
|
|
const forwarded = request.headers.get("x-forwarded-for");
|
|
if (forwarded) {
|
|
const chain = forwarded
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(looksLikeIp);
|
|
// Review finding M-3: when the chain is shorter than the configured
|
|
// hops count, the operator misconfigured TRUST_PROXY_HOPS or a
|
|
// proxy was bypassed. Falling back to the leftmost (now
|
|
// attacker-controlled) entry would re-introduce the very rotation
|
|
// attack TRUST_PROXY_HOPS was meant to close. Refuse to read XFF.
|
|
if (chain.length >= hops) {
|
|
return chain[chain.length - hops];
|
|
}
|
|
}
|
|
}
|
|
const realIp = request.headers.get("x-real-ip");
|
|
return realIp && looksLikeIp(realIp) ? realIp : null;
|
|
}
|