Feature 2: Commit-Feed — Gitea-Webhook, Discord-Embeds, SQLite-Archiv

- Fastify-Webserver mit /health und /webhooks/gitea (HMAC-SHA256-Signaturprüfung, timing-safe)
- Push-Commits werden in SQLite gespeichert (Dedupe per SHA) und als Embed gepostet
- Dockerfile auf node:22-slim (glibc-Prebuilds für better-sqlite3), Port 3080 published
- README: Anleitung für Cloudflare-DNS, Nginx Proxy Manager (bot.d4rkst3r.de) und Gitea-Webhook

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:27:33 +02:00
co-authored by Claude Opus 4.8
parent 2b3fa3eb69
commit 46d6a3f927
11 changed files with 1357 additions and 17 deletions
+101
View File
@@ -0,0 +1,101 @@
// Fastify-Webserver: Gitea-Webhook-Endpoint (später auch REST-API + Webinterface)
import Fastify from 'fastify';
import crypto from 'node:crypto';
import { config } from '../config.js';
import { saveCommits } from '../db.js';
import { postPushEmbed } from '../bot/commit-feed.js';
/** Gitea-Signatur prüfen: HMAC-SHA256 (hex) über den rohen Request-Body */
function verifySignature(rawBody, signatureHex) {
if (!signatureHex) return false;
const expected = crypto
.createHmac('sha256', config.giteaWebhookSecret)
.update(rawBody)
.digest();
let received;
try {
received = Buffer.from(signatureHex, 'hex');
} catch {
return false;
}
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
/**
* Webserver starten.
* @param {import('discord.js').Client} client — laufender Discord-Client
*/
export async function startWebServer(client) {
const app = Fastify({ logger: true });
// JSON als Buffer parsen, damit wir den Raw-Body für die Signatur-Prüfung behalten
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
req.rawBody = body;
try {
done(null, JSON.parse(body.toString('utf8')));
} catch (error) {
error.statusCode = 400;
done(error);
}
});
// Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' }));
// Gitea-Push-Webhook → Embed posten + Commits archivieren
app.post('/webhooks/gitea', async (request, reply) => {
if (!verifySignature(request.rawBody, request.headers['x-gitea-signature'])) {
request.log.warn('Webhook mit ungültiger Signatur abgelehnt');
return reply.code(401).send({ error: 'invalid signature' });
}
// Andere Events (Tag, Issue, …) freundlich ignorieren
const event = request.headers['x-gitea-event'];
if (event !== 'push') {
return { ok: true, ignored: event };
}
const payload = request.body;
const commits = payload.commits ?? [];
// Branch-Delete & Co. senden Pushes ohne Commits
if (commits.length === 0) {
return { ok: true, ignored: 'empty push' };
}
const repo = payload.repository?.full_name ?? 'unbekannt';
const branch = (payload.ref ?? '').replace('refs/heads/', '');
const rows = commits.map((c) => ({
sha: c.id,
repo,
branch,
message: c.message ?? '',
author_name: c.author?.name ?? 'unbekannt',
author_user: c.author?.username ?? null,
url: c.url ?? null,
committed_at: c.timestamp ?? new Date().toISOString(),
}));
const inserted = saveCommits(rows);
request.log.info(`Push auf ${repo}@${branch}: ${rows.length} Commit(s), ${inserted} neu gespeichert`);
// Embed posten — Fehler hier sollen den Webhook nicht scheitern lassen (Gitea würde sonst retrien)
try {
await postPushEmbed(client, {
repo,
repoUrl: payload.repository?.html_url ?? '',
branch,
compareUrl: payload.compare_url || null,
pusherName: payload.pusher?.username ?? payload.pusher?.login ?? 'unbekannt',
pusherAvatar: payload.sender?.avatar_url ?? null,
commits: rows,
});
} catch (error) {
request.log.error({ err: error }, 'Discord-Embed konnte nicht gepostet werden');
}
return { ok: true, commits: rows.length, new: inserted };
});
await app.listen({ port: config.httpPort, host: '0.0.0.0' });
return app;
}