// 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 [ ``, ``, ``, ``, ``, ``, ``, ``, ``, ].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('', ` ${ogTagsFor(request.url, botSite)}\n`) .replace('', ``); 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; }