mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Raising the middleware body cap to 512 MB for archive imports removed the implicit ceiling that protected JSON routes, so any bulk endpoint would buffer an arbitrarily large body before validation ran. Every JSON body read under /api now carries an explicit byte cap sized to its legitimate payload (64 KB for single-record routes up to 16 MB for the measurement import), webhooks cap before signature work, and oversized bodies return 413.
195 lines
6.5 KiB
TypeScript
195 lines
6.5 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { timingSafeEqual } from "node:crypto";
|
|
import { prisma } from "@/lib/db";
|
|
import { apiError } from "@/lib/api-response";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { getClientIp } from "@/lib/api-response";
|
|
import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
|
|
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
|
import { apiHandler } from "@/lib/api-handler";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* @deprecated The standalone moodLog webhook is superseded by native mood
|
|
* entries plus structured tags and rated factors — mood is now tracked
|
|
* fully inside HealthLog, so the external moodLog bridge no longer adds
|
|
* anything. Kept functional for existing integrations; slated for removal
|
|
* in a future major release. Do not build new callers against it.
|
|
*/
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
annotate({ action: { name: "moodlog.webhook" } });
|
|
|
|
// 1. Rate limit (30/min per IP)
|
|
const ip = getClientIp(request) ?? "unknown";
|
|
const rl = await checkRateLimit(`moodlog-webhook:${ip}`, 30, 60_000);
|
|
if (!rl.allowed) {
|
|
return apiError("Rate limit exceeded", 429);
|
|
}
|
|
|
|
// 2. Check global toggle
|
|
const appSettings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
select: { moodLogGlobal: true },
|
|
});
|
|
if (appSettings && !appSettings.moodLogGlobal) {
|
|
return apiError("moodLog integration is disabled", 403);
|
|
}
|
|
|
|
// 3. Get webhook secret from header
|
|
const webhookSecret = request.headers.get("x-webhook-secret");
|
|
if (!webhookSecret) {
|
|
return apiError("Missing X-Webhook-Secret header", 401);
|
|
}
|
|
|
|
// 4. Find all enabled users with webhook secrets and compare timing-safe
|
|
const candidates = await prisma.user.findMany({
|
|
where: {
|
|
moodLogEnabled: true,
|
|
moodLogWebhookSecret: { not: null },
|
|
},
|
|
select: { id: true, moodLogWebhookSecret: true },
|
|
});
|
|
|
|
// V3 audit STILL-V2-C-2: stored secret is now encrypted at rest. Decrypt
|
|
// each candidate before the timing-safe compare. `readMoodLogSecret`
|
|
// also tolerates legacy plaintext rows during the transition window.
|
|
const receivedBuf = Buffer.from(webhookSecret, "utf8");
|
|
const user = candidates.find((c) => {
|
|
const expected = readMoodLogSecret(c.moodLogWebhookSecret);
|
|
if (!expected) return false;
|
|
const expectedBuf = Buffer.from(expected, "utf8");
|
|
if (expectedBuf.length !== receivedBuf.length) return false;
|
|
return timingSafeEqual(expectedBuf, receivedBuf);
|
|
});
|
|
|
|
if (!user) {
|
|
return apiError("Invalid webhook secret", 401);
|
|
}
|
|
|
|
getEvent()?.setAuth({ user_id: user.id, auth_method: "webhook_secret" });
|
|
|
|
// 5. Parse body
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
const raw = await request.text();
|
|
if (raw.length > 256 * 1024) {
|
|
return apiError(`Request body exceeds ${256 * 1024} bytes`, 413);
|
|
}
|
|
body = JSON.parse(raw);
|
|
} catch {
|
|
return apiError("Invalid JSON", 400);
|
|
}
|
|
|
|
// Handle test pings from moodLog's "Test senden" button
|
|
if (body.event === "webhook.test") {
|
|
annotate({ meta: { webhook_event: "webhook.test" } });
|
|
return new Response(null, { status: 200 });
|
|
}
|
|
|
|
const parsed = moodLogWebhookPayloadSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return apiError("Invalid payload", 400);
|
|
}
|
|
|
|
const { event, entry } = parsed.data;
|
|
annotate({ meta: { webhook_event: event } });
|
|
|
|
// 5. Process event (non-blocking — fire and forget with error logging)
|
|
const moodLoggedAt = new Date(entry.time);
|
|
// v1.12.1 — when the upstream supplies a stable entry id, dedup on
|
|
// `(userId, source, externalId)` so a re-emit with a re-rounded /
|
|
// re-zoned `time` updates the same row instead of minting a duplicate.
|
|
// Absent → the legacy `(userId, date, moodLoggedAt)` key. MOODLOG is
|
|
// the fixed source here, matching the create/update writes below.
|
|
const externalId = entry.id ?? null;
|
|
|
|
try {
|
|
if (event === "mood.deleted") {
|
|
await prisma.moodEntry.deleteMany({
|
|
where: {
|
|
userId: user.id,
|
|
...(externalId
|
|
? { source: "MOODLOG", externalId }
|
|
: { date: entry.date, moodLoggedAt }),
|
|
},
|
|
});
|
|
} else if (externalId) {
|
|
// mood.created or mood.updated, keyed on the stable external id.
|
|
await prisma.moodEntry.upsert({
|
|
where: {
|
|
userId_source_externalId: {
|
|
userId: user.id,
|
|
source: "MOODLOG",
|
|
externalId,
|
|
},
|
|
},
|
|
update: {
|
|
mood: entry.mood,
|
|
score: entry.score,
|
|
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
|
source: "MOODLOG",
|
|
date: entry.date,
|
|
moodLoggedAt,
|
|
},
|
|
create: {
|
|
userId: user.id,
|
|
date: entry.date,
|
|
mood: entry.mood,
|
|
score: entry.score,
|
|
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
|
source: "MOODLOG",
|
|
externalId,
|
|
moodLoggedAt,
|
|
},
|
|
});
|
|
} else {
|
|
// mood.created or mood.updated, legacy wall-clock key.
|
|
await prisma.moodEntry.upsert({
|
|
where: {
|
|
userId_date_moodLoggedAt: {
|
|
userId: user.id,
|
|
date: entry.date,
|
|
moodLoggedAt,
|
|
},
|
|
},
|
|
update: {
|
|
mood: entry.mood,
|
|
score: entry.score,
|
|
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
|
source: "MOODLOG",
|
|
},
|
|
create: {
|
|
userId: user.id,
|
|
date: entry.date,
|
|
mood: entry.mood,
|
|
score: entry.score,
|
|
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
|
source: "MOODLOG",
|
|
moodLoggedAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
// v1.4.39 W-MOOD — refresh the persisted rollup for the entry's
|
|
// bucket. Inline best-effort: a failure here must not block the
|
|
// webhook 200 response (the rollup is a cache tier, the source-of-
|
|
// truth is the mood_entries write that already committed).
|
|
try {
|
|
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
|
|
} catch (rollupErr) {
|
|
getEvent()?.addWarning(
|
|
"Mood rollup recompute failed: " +
|
|
(rollupErr instanceof Error ? rollupErr.message : String(rollupErr)),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
getEvent()?.addWarning("DB operation failed: " + err);
|
|
}
|
|
|
|
// 6. Return 200 immediately
|
|
return new Response(null, { status: 200 });
|
|
});
|