patch.moi/src/server.ts
matamune 816881c2cc
All checks were successful
check / check (push) Successful in 35s
Add upstream feed watcher
2026-05-12 22:37:26 +00:00

144 lines
5.3 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { notifyDiscord, parseDiscordConfig, type DiscordConfig } from "./discord";
import { startFeedPolling } from "./feed";
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
import { normalizeGithubEvent } from "./providers/github";
import { normalizeJojoEvent } from "./providers/jojo";
import { EventStore, jobForEvent } from "./queue";
import { verifyGithubSignature, verifyJojoSignature } from "./signatures";
import type { GitWebhookEvent } from "./types";
const maxBodyBytes = 1024 * 1024;
export type ServerConfig = {
githubSecret: string;
jojoSecret: string;
dataDir: string;
discord?: DiscordConfig;
};
function getHeader(headers: Headers, name: string, fallback: string): string {
return headers.get(name) ?? fallback;
}
async function parseJsonBody(request: Request): Promise<{ body: string; payload: unknown } | Response> {
const body = await request.text();
if (body.length > maxBodyBytes) {
return jsonResponse({ error: "payload_too_large" }, { status: 413 });
}
try {
return { body, payload: JSON.parse(body) };
} catch {
return jsonResponse({ error: "invalid_json" }, { status: 400 });
}
}
async function persistAcceptedEvent(store: EventStore, event: GitWebhookEvent, discord?: DiscordConfig): Promise<Response> {
await store.appendEvent(event);
const job = jobForEvent(event);
if (job) {
await store.appendJob(job);
}
try {
await notifyDiscord(discord ?? parseDiscordConfig({}), { event, job });
} catch (error) {
console.error(JSON.stringify({
type: "discord.notify_failed",
provider: event.provider,
event: event.event,
deliveryId: event.deliveryId,
error: error instanceof Error ? error.message : String(error),
}));
}
console.log(JSON.stringify({ type: "webhook.accepted", provider: event.provider, event: event.event, deliveryId: event.deliveryId, job: job?.id }));
return jsonResponse({ status: event.event === "ping" ? "ok" : "accepted", event: event.event, deliveryId: event.deliveryId }, {
status: event.event === "ping" ? 200 : 202,
});
}
async function handleGithub(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
if (request.method !== "POST") return methodNotAllowed();
const parsed = await parseJsonBody(request);
if (parsed instanceof Response) return parsed;
const verified = await verifyGithubSignature(config.githubSecret, parsed.body, request.headers.get("x-hub-signature-256"));
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
const event = normalizeGithubEvent({
providerEvent: getHeader(request.headers, "x-github-event", "unknown"),
deliveryId: getHeader(request.headers, "x-github-delivery", randomUUID()),
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event, config.discord);
}
async function handleJojo(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
if (request.method !== "POST") return methodNotAllowed();
const parsed = await parseJsonBody(request);
if (parsed instanceof Response) return parsed;
const verified = await verifyJojoSignature(config.jojoSecret, parsed.body, request.headers);
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
const event = normalizeJojoEvent({
providerEvent: getHeader(request.headers, "x-forgejo-event", request.headers.get("x-gitea-event") ?? "unknown"),
deliveryId: getHeader(request.headers, "x-forgejo-delivery", request.headers.get("x-gitea-delivery") ?? randomUUID()),
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event, config.discord);
}
export function createHandler(config: ServerConfig): (request: Request) => Promise<Response> | Response {
const store = new EventStore(config.dataDir);
return async (request: Request) => {
const url = new URL(request.url);
if (url.pathname === "/healthz") {
return textResponse("ok\n");
}
if (url.pathname === "/git-webhooks/github") {
return handleGithub(request, config, store);
}
if (url.pathname === "/git-webhooks/jojo") {
return handleJojo(request, config, store);
}
return jsonResponse({ error: "not_found" }, { status: 404 });
};
}
if (import.meta.main) {
const port = Number(process.env.PORT ?? "3000");
const hostname = process.env.HOST ?? "0.0.0.0";
const config: ServerConfig = {
githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "",
jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "",
dataDir: process.env.DATA_DIR ?? "./data",
discord: parseDiscordConfig({
webhookUrl: process.env.DISCORD_WEBHOOK_URL,
notifyEvents: process.env.DISCORD_NOTIFY_EVENTS,
}),
};
if (process.env.FEED_SOURCES_PATH) {
startFeedPolling({
dataDir: config.dataDir,
sourcesPath: process.env.FEED_SOURCES_PATH,
discord: config.discord,
}).catch((error) => {
console.error(JSON.stringify({ type: "feed.start_failed", error: error instanceof Error ? error.message : String(error) }));
});
}
Bun.serve({
hostname,
port,
fetch: createHandler(config),
});
console.log(JSON.stringify({ type: "server.started", hostname, port }));
}