Files
d4rkbot/src/web/server.js
T
D4rkst3randClaude Fable 5 137452da11
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Linked Roles: Rollen, die an geprueften Werten haengen
Neues Modul, standardmaessig aus. Ein Mitglied klickt in Discord auf "Rolle
holen", landet auf /linked-roles, bestaetigt den OAuth-Dialog — und der Bot
schiebt ihm drei Kennzahlen zu, die Discord dann gegen die Rollen-Einstellung
prueft:

  level        Zahl >= eingestelltem Wert
  dabei_seit   Datum <= "vor X Tagen", also Mitglied seit mindestens X Tagen
  playtester   Wahrheitswert

Der Bot vergibt dabei keine Rollen. Er liefert nur die Zahlen; was daraus
wird, entscheidet die Rollen-Einstellung im Server. Die Daten kommen aus dem,
was ohnehin da ist: Level-Tabelle, Playtester-Liste, Discords Beitrittsdatum.

Eigener OAuth-Weg statt /auth/login, weil die Berechtigungen andere sind —
role_connections.write hat der normale Login nicht.

Die Tokens werden gespeichert, und das ist keine Bequemlichkeit: ohne
Refresh-Token bliebe jede Rolle auf dem Stand des Verknuepfungs-Tages stehen,
ein Level-Up kaeme nie an. Discords eigene Anleitung sagt dasselbe. Der
Umfang ist eng (nur identify + role_connections.write), beim Widerruf oder
Entkoppeln fliegt der Datensatz sofort raus. Steht so auch als Kommentar an
der Tabelle.

Abgeglichen wird alle 6 Stunden, einstellbar. Dafuer kann everyTuned jetzt
auch Stunden — und wirft bei einer unbekannten Einheit, statt den Abstand
still um Faktor 60 oder 3600 danebenzulegen.

Beim Bauen gefunden: moduleEnabled fehlte im Import von client.js. Der Bot
waere beim ClientReady mit ReferenceError gestorben.

Geprueft: alle drei Kennzahlen gegen Discords Formatgrenzen (Schluessel-Regex,
Laengen, gueltige Typ-Codes), Modul- und Stellwert-Register, die
Stunden-Umrechnung (6 h = 21600000 ms), Speichern und Loeschen einer
Verknuepfung, dazu 186 SQL-Abfragen und alle Routen auf Rechtepruefung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 05:57:57 +02:00

352 lines
16 KiB
JavaScript

// Fastify-Webserver: Gitea-Webhook, Discord-OAuth2, REST-API + React-Frontend
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import crypto from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { config } from '../config.js';
import { saveCommits, saveRelease, takeBugReport, getDevlog, getPage } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName, hubUrl, botUrl } from '../runtime-settings.js';
import { postPushEmbed } from '../bot/commit-feed.js';
import { postReleaseEmbed } from '../bot/release-feed.js';
import { registerAuthRoutes } from './auth.js';
import { registerApiRoutes } from './api.js';
import { registerApiV1 } from './api-v1.js';
import { registerDevlogEndpoint } from './devlog-endpoint.js';
import { registerSsoRoutes } from './sso.js';
import { registerBrandRoutes } from './brand.js';
import { registerLinkedRoleRoutes } from './linked-roles.js';
import { imagesDir } from '../bot/devlog-archive.js';
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
// lokal nur nach `npm run build` im frontend/-Ordner
const frontendDist = join(dirname(fileURLToPath(import.meta.url)), '../../frontend/dist');
/** 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;
// Leerer Body (z. B. DELETE mit gesetztem Content-Type) → leeres Objekt statt Parse-Fehler
if (body.length === 0) return done(null, {});
try {
done(null, JSON.parse(body.toString('utf8')));
} catch (error) {
error.statusCode = 400;
done(error);
}
});
// Signierte Cookies (Session + OAuth-State)
await app.register(fastifyCookie, { secret: config.sessionSecret });
// Multipart für den Devlog-Endpoint (Bilder von devlog.py, max 10 MB pro Datei)
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } });
registerAuthRoutes(app, client);
registerApiRoutes(app, client);
registerApiV1(app, client);
registerDevlogEndpoint(app, client);
registerSsoRoutes(app, client);
registerBrandRoutes(app);
registerLinkedRoleRoutes(app, client);
// Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' }));
// Lokal gespeicherte Devlog-Bilder (Discord-CDN-Links laufen ab)
await app.register(fastifyStatic, {
root: imagesDir,
prefix: '/devlog-assets/',
decorateReply: false,
maxAge: '30d',
immutable: true,
});
// Screenshot-Galerie-Bilder
const { galleryDir } = await import('../bot/community.js');
await app.register(fastifyStatic, {
root: galleryDir,
prefix: '/gallery-assets/',
decorateReply: false,
maxAge: '30d',
immutable: true,
});
if (existsSync(frontendDist)) {
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing).
// Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,
// WhatsApp, …) eine hübsche Vorschau zeigen — Devlog-Links sogar mit Bild.
const indexHtml = readFileSync(join(frontendDist, 'index.html'), 'utf8');
const esc = (s) => String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('"', '&quot;');
function ogTagsFor(url, isBotSite) {
const base = isBotSite ? botUrl() : hubUrl();
let title = isBotSite
? `${brandName()} // Der Bot`
: `${brandName()} // Community-Hub`;
let description = isBotSite
? 'Devlogs, Level, Moderation, Server-Monitoring — ein selbst gebauter Discord-Bot ohne Paywall.'
: 'Devlogs, Roadmap, Server-Status und alles aus der Community — direkt aus dem Discord.';
let image = null;
const pageTitles = isBotSite ? {
'/features': ['Funktionen', 'Alles, was der Bot kann — von Devlogs bis Server-Monitoring.'],
'/commands': ['Befehle', 'Alle Slash-Commands im Überblick.'],
'/dashboard': ['Dashboard', 'Einstellungen des Bots.'],
} : {
'/devlogs': ['Devlog-Archiv', 'Entwicklungs-Updates, automatisch archiviert.'],
'/roadmap': ['Roadmap', 'Meilensteine und Community-Wünsche zum Abstimmen.'],
'/server': ['Server-Status', 'Alle Game-Server live — Spielerzahlen und Verlauf.'],
'/galerie': ['Galerie', 'Screenshots aus der Community.'],
'/level': ['Level', 'Die XP-Bestenliste des Discords.'],
'/events': ['Events', 'Playtests, Streams und was sonst ansteht.'],
'/changelog': ['Changelog', 'Alle Releases mit Notes.'],
};
const path = url.split('?')[0];
const devlogId = path.match(/^\/devlogs\/(\d{15,21})$/)?.[1];
const devlog = devlogId ? getDevlog(devlogId) : null;
if (devlog) {
const prose = devlog.content.replace(/^[-*>]\s+/gm, '').replace(/[#*`_]/g, '').replace(/\s+/g, ' ').trim();
title = `${brandName()} // Devlog — ${new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }).format(new Date(devlog.posted_at))}`;
description = prose.slice(0, 200) + (prose.length > 200 ? ' …' : '');
const firstImage = JSON.parse(devlog.images || '[]')[0];
if (firstImage) image = `${base}/devlog-assets/${firstImage}`;
} else if (pageTitles[path]) {
title = `${brandName()} // ${pageTitles[path][0]}`;
description = pageTitles[path][1];
} else if (!isBotSite && /^\/[a-z0-9-]+$/.test(path)) {
// Frei angelegte Seite? Titel und Anriss daraus übernehmen
const page = getPage(path.slice(1));
if (page?.published) {
const prose = page.content.replace(/^[-*>#]\s*/gm, '').replace(/[*`_]/g, '').replace(/\s+/g, ' ').trim();
title = `${brandName()} // ${page.title}`;
description = prose.slice(0, 200) + (prose.length > 200 ? ' …' : '');
}
}
// Ohne Bild zeigt Discord bei einem geteilten Link nur eine graue
// Textzeile. Devlogs mit Screenshot behalten ihren, alles andere
// bekommt das Logo.
if (!image) image = `${base}/og-default.png`;
return [
`<meta property="og:site_name" content="${esc(brandName())}">`,
`<meta property="og:title" content="${esc(title)}">`,
`<meta property="og:description" content="${esc(description)}">`,
`<meta property="og:url" content="${esc(base + path)}">`,
`<meta property="og:type" content="website">`,
`<meta name="theme-color" content="${isBotSite ? '#ff4d00' : '#f5c518'}">`,
`<meta name="description" content="${esc(description)}">`,
`<meta property="og:image" content="${esc(image)}">`,
`<meta name="twitter:card" content="summary_large_image">`,
].filter(Boolean).join('\n ');
}
// Welche der beiden Seiten ist gemeint? Solange keine getrennten
// Adressen konfiguriert sind, verhält sich alles wie bisher.
function isBotHost(request) {
const bot = botUrl();
const hub = hubUrl();
if (bot === hub) return false; // noch nicht getrennt
const host = String(request.headers.host ?? '').toLowerCase().split(':')[0];
return host === new URL(bot).hostname;
}
// Routen, die nur auf der Bot-Seite existieren — alles andere gehört zum Hub
const BOT_ROUTES = ['/features', '/commands', '/docs', '/dashboard'];
const SHARED_ROUTES = ['/impressum', '/datenschutz'];
const sendInjected = (request, reply) => {
const botSite = isBotHost(request);
const path = request.url.split('?')[0];
// Falsche Domain? Dauerhaft auf die richtige umleiten, damit alte
// Links (geteilte Devlog-Permalinks) nicht ins Leere laufen.
if (botUrl() !== hubUrl() && !SHARED_ROUTES.includes(path)) {
const wantsBot = path === '/' ? false : BOT_ROUTES.some((r) => path.startsWith(r));
if (wantsBot && !botSite) {
return reply.redirect(`${botUrl()}${request.url}`, 301);
}
if (!wantsBot && botSite && path !== '/') {
return reply.redirect(`${hubUrl()}${request.url}`, 301);
}
}
const html = indexHtml
.replace('</head>', ` ${ogTagsFor(request.url, botSite)}\n</head>`)
.replace('<body>', `<body data-site="${botSite ? 'bot' : 'hub'}">`);
return reply.type('text/html; charset=utf-8').send(html);
};
// index: false → "/" wird nicht statisch bedient, sondern von der eigenen
// Route darunter (sonst gäbe es keine OG-Tags auf der Startseite)
await app.register(fastifyStatic, { root: frontendDist, index: false });
app.get('/', sendInjected);
app.setNotFoundHandler((request, reply) => {
const isApiPath = ['/api', '/auth', '/webhooks', '/sso'].some((p) =>
request.url.startsWith(p)
);
if (request.method === 'GET' && !isApiPath) {
return sendInjected(request, reply);
}
return reply.code(404).send({ error: 'not found' });
});
} else {
// Ohne gebautes Frontend (lokale Dev ohne `npm run build`): JSON-Hinweis auf /
app.get('/', async () => ({
name: 'ecobot',
status: 'ok',
hint: 'Frontend nicht gebaut — cd frontend && npm run build (oder Vite-Dev-Server nutzen)',
}));
}
// 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' });
}
const event = request.headers['x-gitea-event'];
// Release-Events → Ankündigung + Changelog-Archiv
if (event === 'release') {
const payload = request.body;
const repo = payload.repository?.full_name ?? 'unbekannt';
if (repoIgnored(repo) || !payload.release) {
return { ok: true, ignored: `release ${repo}` };
}
const release = {
repo,
tag: payload.release.tag_name ?? 'unbekannt',
name: payload.release.name ?? null,
body: payload.release.body ?? null,
url: payload.release.html_url ?? null,
prerelease: payload.release.prerelease ? 1 : 0,
published_at: payload.release.published_at ?? new Date().toISOString(),
};
const isNew = saveRelease(release);
request.log.info(`Release ${release.tag} in ${repo} (${payload.action}, neu: ${isNew})`);
// Nur neue Releases ankündigen — Updates/Löschungen nur still archivieren
let posted = false;
if (isNew && payload.action === 'published') {
try {
posted = await postReleaseEmbed(client, {
...release,
prerelease: Boolean(release.prerelease),
});
} catch (error) {
request.log.error({ err: error }, 'Release-Embed konnte nicht gepostet werden');
}
}
return { ok: true, release: release.tag, posted };
}
// Issue geschlossen → Rückkanal-DM an den Discord-Reporter (/bug)
if (event === 'issues') {
const payload = request.body;
if (payload.action === 'closed' && payload.issue?.number) {
const repo = payload.repository?.full_name ?? '';
const report = takeBugReport(repo, payload.issue.number);
if (report) {
try {
const user = await client.users.fetch(report.discord_user_id);
await user.send(
`✅ Dein gemeldeter Bug **„${report.title}"** wurde behoben!\n` +
`Issue [#${payload.issue.number}](${payload.issue.html_url ?? ''}) ist geschlossen — danke für den Report! 🙌`
);
request.log.info(`Bug-Report-DM an ${report.discord_user_id} (Issue #${payload.issue.number})`);
} catch (error) {
request.log.warn(`Bug-Report-DM fehlgeschlagen: ${error.message}`);
}
}
}
return { ok: true, event: 'issues' };
}
// Andere Events (Tag, Issue, …) freundlich ignorieren
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/', '');
// Ignorierte Repos komplett überspringen (Settings-Seite)
if (repoIgnored(repo)) {
return { ok: true, ignored: `repo ${repo}` };
}
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`);
// Posten nur wenn Feed aktiv und Branch erlaubt (archiviert wird immer)
if (!commitFeedEnabled() || !branchAllowed(branch)) {
request.log.info(`Embed übersprungen (Feed aus oder Branch ${branch} gefiltert)`);
return { ok: true, commits: rows.length, new: inserted, posted: false };
}
// 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;
}