feat(notify): Benachrichtigungen ueber ntfy, Webhook und E-Mail

Drei Anlaesse: fehlgeschlagener Abgleich, rote CI, neues Release. Die Daten
dafuer liegen seit dem CI-Status vor, es fehlte nur der Weg nach draussen.

Der Kern ist die Entprellung. Gemeldet wird ausschliesslich bei einer
Aenderung gegenueber dem zuletzt gemeldeten Stand, der am Projekt vermerkt
ist. Ein Repo mit abgelaufenem Token meldet sich damit einmal statt bei
jedem stuendlichen Durchlauf. Zwei Feinheiten:

- Beim allerersten Abgleich wird kein Release gemeldet, sonst waere jede
  bereits vorhandene Version eine "Neuigkeit".
- Der Merkstand wird auch ohne eingerichtetes Ziel fortgeschrieben, sonst
  kaeme beim ersten Ziel die ganze Vergangenheit hinterher.

Zustellwege:
- ntfy mit Titel, Prioritaet und Symbol als Kopfzeilen, optionalem
  Bearer-Token. Emojis werden aus den Kopfzeilen entfernt statt durch
  Fragezeichen ersetzt - Kopfzeilen duerfen nur Latin-1 tragen, und das
  Sinnbild liefert ohnehin der Tags-Header.
- Webhook mit JSON, Projektbezug und Tieflink. Mit Geheimnis wird der Rumpf
  per HMAC-SHA256 signiert, damit der Empfaenger die Herkunft pruefen kann.
- E-Mail ueber SMTP, verfuegbar sobald SMTP_HOST gesetzt ist.

Zieladressen sind Benutzereingaben, die der Server abruft - sie laufen
deshalb durch denselben SSRF-Schutz wie die Git-Verbindungen. Redirects
werden nicht verfolgt, weil ein Umleitungsziel nicht geprueft waere.
Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung darf
den Abgleich nicht scheitern lassen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:22:50 +02:00
co-authored by Claude Opus 5
parent d1b9dfe535
commit 3bfed6d9f6
10 changed files with 722 additions and 3 deletions
+213
View File
@@ -0,0 +1,213 @@
import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationTarget } from "@prisma/client";
import { tryDecryptSecret } from "@/lib/crypto";
import { APP_NAME, APP_URL } from "@/lib/config";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import type { DeliveryResult, NotificationMessage } from "./types";
/**
* Zustellung einer Nachricht ueber den gewaehlten Weg.
*
* Zwei Dinge gelten fuer alle Wege:
* - Die Zieladresse ist eine Benutzereingabe und wird serverseitig
* abgerufen. Sie laeuft deshalb durch denselben SSRF-Schutz wie die
* Git-Verbindungen. Ein ntfy im eigenen Netz bleibt damit erlaubt,
* Loopback und Metadaten-Endpunkte nicht.
* - Fehler werden zurueckgegeben, nicht geworfen. Eine kaputte
* Benachrichtigung darf einen Abgleich nicht scheitern lassen.
*/
const TIMEOUT_MS = Number(process.env.NOTIFY_TIMEOUT_MS ?? 10_000);
async function postJson(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<DeliveryResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: typeof body === "string" ? body : JSON.stringify(body),
signal: controller.signal,
// Ein Redirect koennte auf ein anderes Ziel fuehren, als geprueft
// wurde – deshalb gar nicht erst folgen.
redirect: "manual",
});
if (!response.ok && response.status !== 0) {
return { ok: false, message: `Antwort ${response.status} von ${new URL(url).host}` };
}
return { ok: true, message: `Zugestellt (${response.status})` };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { ok: false, message: `Zeitueberschreitung nach ${TIMEOUT_MS} ms` };
}
return { ok: false, message: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
async function sendWebhook(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
const payload = {
source: APP_NAME,
event: message.event,
title: message.title,
body: message.body,
url: message.url,
project: message.project,
at: message.at,
};
const raw = JSON.stringify(payload);
const headers: Record<string, string> = { "User-Agent": `${APP_NAME}-Notify` };
// Mit Geheimnis wird der Rumpf signiert, damit der Empfaenger pruefen
// kann, dass die Nachricht wirklich von hier kommt.
const secret = tryDecryptSecret(target.secretCipher);
if (secret) {
headers["X-Nebula-Signature"] = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`;
}
return postJson(target.target, raw, headers);
}
async function sendNtfy(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
const headers: Record<string, string> = {
Title: encodeHeader(message.title),
Priority: message.priority === "high" ? "high" : "default",
Tags: message.event === "TEST" ? "bell" : "warning",
};
if (message.url) headers.Click = message.url;
const secret = tryDecryptSecret(target.secretCipher);
if (secret) headers.Authorization = `Bearer ${secret}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(target.target, {
method: "POST",
// ntfy nimmt den Rumpf als reinen Text.
headers: { "Content-Type": "text/plain; charset=utf-8", ...headers },
body: message.body,
signal: controller.signal,
redirect: "manual",
});
if (!response.ok) {
return { ok: false, message: `Antwort ${response.status} von ntfy` };
}
return { ok: true, message: "An ntfy zugestellt" };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { ok: false, message: `Zeitueberschreitung nach ${TIMEOUT_MS} ms` };
}
return { ok: false, message: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
/**
* Kopfzeilen duerfen nur Latin-1 enthalten. Emojis werden deshalb entfernt,
* nicht durch Fragezeichen ersetzt – aus "🔴 Projekt: CI ist rot" wuerde
* sonst "?? Projekt: CI ist rot". Das Sinnbild traegt ohnehin der
* Tags-Header, den ntfy selbst in ein Symbol umsetzt.
*/
function encodeHeader(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[^\x20-\xFF]/g, "").replace(/\s+/g, " ").trim();
}
export interface SmtpConfig {
host: string;
port: number;
secure: boolean;
user?: string;
password?: string;
from: string;
}
/** Liest die SMTP-Einstellungen aus der Umgebung, falls vorhanden. */
export function smtpConfig(): SmtpConfig | null {
const host = process.env.SMTP_HOST?.trim();
if (!host) return null;
const port = Number(process.env.SMTP_PORT ?? 587);
return {
host,
port,
// Port 465 spricht von Anfang an TLS, 587 steigt per STARTTLS um.
secure: process.env.SMTP_SECURE
? /^(1|true|yes|on)$/i.test(process.env.SMTP_SECURE)
: port === 465,
user: process.env.SMTP_USER?.trim() || undefined,
password: process.env.SMTP_PASSWORD || undefined,
from: process.env.SMTP_FROM?.trim() || `${APP_NAME} <nebula@localhost>`,
};
}
export const emailAvailable = () => smtpConfig() !== null;
async function sendEmail(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
const config = smtpConfig();
if (!config) {
return { ok: false, message: "Kein SMTP hinterlegt – SMTP_HOST in der .env setzen." };
}
try {
const transport = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.password } : undefined,
connectionTimeout: TIMEOUT_MS,
greetingTimeout: TIMEOUT_MS,
});
const zeilen = [message.body];
if (message.url) zeilen.push("", message.url);
zeilen.push("", `— ${APP_NAME}, ${APP_URL}`);
await transport.sendMail({
from: config.from,
to: target.target,
subject: message.title,
text: zeilen.join("\n"),
});
return { ok: true, message: `An ${target.target} versendet` };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
/** Stellt eine Nachricht ueber den Weg des Ziels zu. */
export async function deliver(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
if (target.channel === "EMAIL") return sendEmail(target, message);
// Webhook und ntfy rufen eine vom Benutzer angegebene Adresse auf.
try {
await assertAllowedUrl(target.target);
} catch (error) {
if (error instanceof BlockedHostError) return { ok: false, message: error.message };
throw error;
}
return target.channel === "NTFY" ? sendNtfy(target, message) : sendWebhook(target, message);
}
+159
View File
@@ -0,0 +1,159 @@
import type { NotificationEvent, Project, RepoSnapshot } from "@prisma/client";
import { APP_NAME, APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { deliver } from "./channels";
import { EVENT_META, type NotificationMessage } from "./types";
export * from "./types";
export { emailAvailable, smtpConfig } from "./channels";
/**
* Entscheidet nach einem Abgleich, worueber zu benachrichtigen ist, und
* stellt zu.
*
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
* Der zuletzt gemeldete Zustand steht am Projekt.
*
* Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung
* darf den Abgleich nicht scheitern lassen.
*/
export async function notifyAfterSync(projectId: string): Promise<void> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { snapshot: true },
});
if (!project?.ownerId) return;
const faellig = pendingEvents(project, project.snapshot);
if (faellig.length === 0) return;
const ziele = await prisma.notificationTarget.findMany({
where: {
userId: project.ownerId,
isActive: true,
events: { hasSome: faellig.map((entry) => entry.event) },
},
});
// Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
await prisma.project.update({
where: { id: project.id },
data: {
notifiedSyncError: project.syncError,
notifiedCiState: project.snapshot?.ciState ?? null,
notifiedRelease: currentRelease(project.snapshot),
},
});
if (ziele.length === 0) return;
for (const eintrag of faellig) {
const passende = ziele.filter((ziel) => ziel.events.includes(eintrag.event));
for (const ziel of passende) {
const ergebnis = await deliver(ziel, eintrag.message);
await prisma.notificationTarget.update({
where: { id: ziel.id },
data: {
lastSentAt: new Date(),
lastOk: ergebnis.ok,
lastMessage: ergebnis.message,
},
});
}
}
} catch (error) {
console.error("[nebula] Benachrichtigung fehlgeschlagen:", error);
}
}
function currentRelease(snapshot: RepoSnapshot | null): string | null {
if (!snapshot) return null;
return snapshot.latestRelease ?? snapshot.latestTag ?? null;
}
/**
* Vergleicht den aktuellen Stand mit dem zuletzt gemeldeten.
* Exportiert, weil hier die eigentliche Entscheidung faellt – siehe Tests.
*/
export function pendingEvents(
project: Project,
snapshot: RepoSnapshot | null,
): Array<{ event: NotificationEvent; message: NotificationMessage }> {
const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = [];
const projektUrl = `${APP_URL}/projects/${project.id}`;
const bezug = { id: project.id, name: project.name, slug: project.slug };
const jetzt = new Date().toISOString();
if (project.syncError && project.syncError !== project.notifiedSyncError) {
treffer.push({
event: "SYNC_FAILED",
message: {
event: "SYNC_FAILED",
title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`,
body: project.syncError,
priority: "high",
url: projektUrl,
project: bezug,
at: jetzt,
},
});
}
const ci = snapshot?.ciState ?? null;
if (ci === "failure" && ci !== project.notifiedCiState) {
treffer.push({
event: "CI_FAILED",
message: {
event: "CI_FAILED",
title: `${EVENT_META.CI_FAILED.emoji} ${project.name}: CI ist rot`,
body:
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
priority: "high",
url: snapshot?.ciUrl ?? projektUrl,
project: bezug,
at: jetzt,
},
});
}
const release = currentRelease(snapshot);
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
// vorher schon einmal etwas festgehalten wurde.
if (release && project.notifiedRelease && release !== project.notifiedRelease) {
treffer.push({
event: "NEW_RELEASE",
message: {
event: "NEW_RELEASE",
title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`,
body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`,
priority: "normal",
url: snapshot?.htmlUrl ?? projektUrl,
project: bezug,
at: jetzt,
},
});
}
return treffer;
}
/** Nachricht fuer den Knopf "Testen" an einem Ziel. */
export function testMessage(): NotificationMessage {
return {
event: "TEST",
title: `🔔 ${APP_NAME}: Testnachricht`,
body:
"Wenn du das liest, ist der Zustellweg eingerichtet. " +
"Echte Meldungen kommen nur bei einer Aenderung – nicht bei jedem Abgleich.",
priority: "normal",
url: APP_URL,
project: null,
at: new Date().toISOString(),
};
}
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import type { Project, RepoSnapshot } from "@prisma/client";
import { pendingEvents } from "./index";
/**
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
*/
function projekt(overrides: Partial<Project> = {}): Project {
return {
id: "p1",
name: "Testprojekt",
slug: "testprojekt",
syncError: null,
notifiedSyncError: null,
notifiedCiState: null,
notifiedRelease: null,
...overrides,
} as Project;
}
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
return {
ciState: null,
ciBranch: "main",
ciName: null,
ciUrl: null,
latestTag: null,
latestRelease: null,
htmlUrl: null,
...overrides,
} as RepoSnapshot;
}
const events = (p: Project, s: RepoSnapshot | null) =>
pendingEvents(p, s).map((entry) => entry.event);
describe("Abgleichsfehler", () => {
it("meldet einen neuen Fehler", () => {
expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
});
it("meldet denselben Fehler kein zweites Mal", () => {
expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
});
it("meldet, wenn sich der Fehler aendert", () => {
expect(
events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null),
).toEqual(["SYNC_FAILED"]);
});
it("meldet nichts, wenn kein Fehler vorliegt", () => {
expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
});
});
describe("CI", () => {
it("meldet den Wechsel auf rot", () => {
expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
});
it("meldet dauerhaft rote CI nur einmal", () => {
expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
).toEqual([]);
});
it("meldet nicht, wenn die CI gruen ist", () => {
expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
).toEqual([]);
});
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
expect(
events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
).toEqual(["CI_FAILED"]);
});
});
describe("Release", () => {
it("schweigt beim allerersten Abgleich", () => {
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
expect(
events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
).toEqual([]);
});
it("meldet eine neue Version", () => {
expect(
events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual(["NEW_RELEASE"]);
});
it("meldet dieselbe Version nicht erneut", () => {
expect(
events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual([]);
});
it("zieht den Releasenamen dem Tag vor", () => {
const treffer = pendingEvents(
projekt({ notifiedRelease: "alt" }),
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
);
expect(treffer[0].message.title).toContain("Zweite Ausgabe");
});
});
describe("mehrere Anlaesse gleichzeitig", () => {
it("meldet Fehler und rote CI zusammen", () => {
expect(
events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
).toEqual(["SYNC_FAILED", "CI_FAILED"]);
});
it("haengt Projektbezug und Tieflink an jede Nachricht", () => {
const treffer = pendingEvents(projekt({ syncError: "404" }), null);
expect(treffer[0].message.project?.slug).toBe("testprojekt");
expect(treffer[0].message.url).toContain("/projects/p1");
expect(treffer[0].message.priority).toBe("high");
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { NotificationEvent } from "@prisma/client";
/** Reihenfolge fuer die Oberflaeche. */
export const NOTIFICATION_EVENTS: NotificationEvent[] = [
"SYNC_FAILED",
"CI_FAILED",
"NEW_RELEASE",
];
export const EVENT_META: Record<
NotificationEvent,
{ label: string; description: string; emoji: string }
> = {
SYNC_FAILED: {
label: "Abgleich fehlgeschlagen",
description: "Das Git-System war nicht erreichbar oder hat abgelehnt.",
emoji: "⚠️",
},
CI_FAILED: {
label: "CI rot",
description: "Der letzte Lauf auf dem Default-Branch ist fehlgeschlagen.",
emoji: "🔴",
},
NEW_RELEASE: {
label: "Neues Release",
description: "Im Repository ist ein neuer Tag oder ein Release aufgetaucht.",
emoji: "🏷️",
},
};
/** Eine fertig formulierte Nachricht, unabhaengig vom Zustellweg. */
export interface NotificationMessage {
event: NotificationEvent | "TEST";
title: string;
body: string;
/** Fuer ntfy: hoehere Zahl bedeutet auffaelliger. */
priority: "normal" | "high";
/** Link zurueck ins Dashboard oder ins Repository. */
url: string | null;
project: { id: string; name: string; slug: string } | null;
at: string;
}
export interface DeliveryResult {
ok: boolean;
message: string;
}
/**
* Felder eines Ziels, die an den Browser gehen duerfen – ohne Chiffrat.
* Liegt hier und nicht in der Route: Next laesst in route.ts nur bestimmte
* Exporte zu und bricht den Bau bei allem anderen ab.
*/
export const targetSafeSelect = {
id: true,
name: true,
channel: true,
target: true,
secretHint: true,
events: true,
isActive: true,
lastSentAt: true,
lastOk: true,
lastMessage: true,
createdAt: true,
} as const;
+4
View File
@@ -1,6 +1,7 @@
import type { Project } from "@prisma/client";
import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import { notifyAfterSync } from "@/lib/notify";
import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult {
@@ -54,6 +55,8 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
try {
const data = await client.fetchRepo(ref);
await persistSnapshot(project.id, data);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(project.id);
return {
ok: true,
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
@@ -70,6 +73,7 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
syncFailureCount: { increment: 1 },
},
});
await notifyAfterSync(project.id);
return { ok: false, message };
}
}
+34
View File
@@ -170,6 +170,40 @@ export const adminUserUpdateSchema = z.object({
password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
});
// ── Benachrichtigungen ───────────────────────────────────────
const CHANNELS = ["WEBHOOK", "NTFY", "EMAIL"] as const;
const EVENTS = ["SYNC_FAILED", "CI_FAILED", "NEW_RELEASE"] as const;
export const notificationTargetCreateSchema = z
.object({
name: z.string().trim().min(1, "Name darf nicht leer sein").max(60),
channel: z.enum(CHANNELS),
target: z.string().trim().min(1, "Ziel fehlt").max(500),
/** Bearer-Token fuer ntfy oder Signaturschluessel fuer den Webhook. */
secret: z.preprocess(emptyToNull, z.string().trim().max(300).nullable().optional()),
events: z.array(z.enum(EVENTS)).min(1, "Mindestens ein Anlass"),
isActive: z.boolean().default(true),
})
.refine(
(value) =>
value.channel === "EMAIL"
? /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value.target)
: /^https?:\/\//i.test(value.target),
{
message: "E-Mail-Adresse bzw. URL mit http:// oder https:// erwartet",
path: ["target"],
},
);
export const notificationTargetUpdateSchema = z.object({
name: z.string().trim().min(1).max(60).optional(),
target: z.string().trim().min(1).max(500).optional(),
secret: z.preprocess(emptyToNull, z.string().trim().max(300).nullable().optional()),
events: z.array(z.enum(EVENTS)).min(1).optional(),
isActive: z.boolean().optional(),
});
export const passkeyNameSchema = z.object({
name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()),
});