mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.
Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
131 lines
3.5 KiB
TypeScript
131 lines
3.5 KiB
TypeScript
import { envValue } from "@/lib/env";
|
|
import { NextRequest } from "next/server";
|
|
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { apiError, apiSuccess } from "@/lib/api-response";
|
|
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
|
import { safeFetch } from "@/lib/safe-fetch";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function redact(text: string): string {
|
|
return text.replace(/https?:\/\/\S+/gi, "[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];
|
|
|
|
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));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function resolveAppUrl(request: NextRequest): URL {
|
|
const configured =
|
|
envValue("APP_URL") ??
|
|
envValue("NEXT_PUBLIC_APP_URL") ??
|
|
`https://${request.headers.get("host") ?? "localhost:3000"}`;
|
|
|
|
try {
|
|
return new URL(configured);
|
|
} catch {
|
|
return new URL(
|
|
`https://${request.headers.get("host") ?? "localhost:3000"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
await requireAdmin();
|
|
annotate({ action: { name: "admin.monitoring.umami-test" } });
|
|
|
|
const settings = await getPublicMonitoringSettings();
|
|
if (!settings.umamiEnabled) {
|
|
return apiError("Umami is disabled", 422);
|
|
}
|
|
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
|
|
return apiError("Umami script URL and website ID must be configured", 422);
|
|
}
|
|
|
|
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
|
|
if (targetUrls.length === 0) {
|
|
return apiError("Umami script URL is invalid", 422);
|
|
}
|
|
|
|
const appUrl = resolveAppUrl(request);
|
|
const payload = {
|
|
type: "event",
|
|
payload: {
|
|
website: settings.umamiWebsiteId,
|
|
hostname: appUrl.hostname,
|
|
screen: "1920x1080",
|
|
language: "en-US",
|
|
title: "HealthLog Monitoring Test",
|
|
url: "/admin",
|
|
referrer: "",
|
|
name: "healthlog_monitoring_test",
|
|
data: {
|
|
source: "admin",
|
|
},
|
|
},
|
|
};
|
|
|
|
let lastStatus = 404;
|
|
let lastDetails = "";
|
|
|
|
for (const targetUrl of targetUrls) {
|
|
const upstream = await safeFetch(
|
|
targetUrl,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"user-agent": "healthlog-admin-monitoring-test",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
cache: "no-store",
|
|
},
|
|
// Operator-configured Umami host — pin the connect-time IP.
|
|
{ requirePublicHost: true },
|
|
);
|
|
|
|
if (upstream.ok) {
|
|
return apiSuccess({
|
|
sent: true,
|
|
message: "Umami test event sent",
|
|
});
|
|
}
|
|
|
|
lastStatus = upstream.status;
|
|
lastDetails = await upstream.text().catch(() => "");
|
|
|
|
// Try next candidate only for path misses.
|
|
if (upstream.status === 404) {
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
getEvent()?.addWarning(
|
|
`Umami test event rejected: ${lastStatus}${lastDetails ? ` ${redact(lastDetails)}` : ""}`,
|
|
);
|
|
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
|
|
});
|