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>
352 lines
16 KiB
JavaScript
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('&', '&').replaceAll('<', '<').replaceAll('"', '"');
|
|
|
|
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;
|
|
}
|