Files
HealthLog/src/app/api/send/route.ts
T
Marc-André BombeckandClaude Opus 4.7 8528c3b4fe chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the
finish of the outbound-fetch migration.

- /medications/[id] becomes a read-only history surface; every setting
  moves into a new advanced-settings sheet, and the edit trigger becomes
  a two-option picker. The list page is unchanged.
- A pg-boss job folds pre-v1.5.0 granular step rows into one canonical
  daily total per user per day and soft-deletes the originals; migration
  0087 adds a partial index for the converging discovery scan.
- Every remaining server-side fetch routes through safeFetch (Withings,
  Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new
  healthlog/safe-fetch-required ESLint rule guards the boundary.
- Fixes: intake-edit dialog seeded from real values, consolidation skips
  a unique-constraint day instead of stranding it, avatar upload reads
  the body once under a bounded cap, egress lint rejects absolute URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:07:10 +02:00

105 lines
3.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
// Use the canonical `isPublicUrl` guard rather than a hand-rolled
// allowlist. The previous local implementation predated the central
// helper and was missing CGNAT (100.64/10), IPv6 ULA (fc00::/7), the
// octal/hex/decimal IPv4 alt-notations, and the IPv4-mapped IPv6
// shapes — every one of which the shared helper catches. Dev mode is
// no longer special-cased here: a localhost-or-RFC1918 Umami URL was
// never a valid configuration anyway (the proxy ships the request to
// the configured host with the visitor's user-agent + body, which
// only makes sense against an externally-reachable analytics target).
function isAllowedUmamiUrl(url: string): boolean {
return isPublicUrl(url);
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
// Remove script file segment (e.g. script.js, umami.js) if present.
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates.filter(isAllowedUmamiUrl)));
} catch {
return [];
}
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return NextResponse.json({}, { status: 204 });
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
if (targetUrls.length === 0 || !settings.umamiWebsiteId) {
return NextResponse.json({}, { status: 204 });
}
const body = await request.arrayBuffer();
// Limit proxy body size to 64 KB to prevent abuse
if (body.byteLength > 65536) {
return NextResponse.json({}, { status: 413 });
}
let lastResponseStatus = 404;
for (const targetUrl of targetUrls) {
const upstream = await safeFetch(
targetUrl,
{
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
},
body,
cache: "no-store",
},
// Operator-configured Umami host — pin the connect-time IP.
{ requirePublicHost: true },
);
lastResponseStatus = upstream.status;
// Try next candidate only for path misses.
if (upstream.status === 404) {
continue;
}
return new NextResponse(null, { status: upstream.status });
}
return new NextResponse(null, { status: lastResponseStatus });
});