Feature 4: Webinterface — React-Frontend, Discord-OAuth2, REST-API

- Discord-OAuth2-Login (identify-Scope, CSRF-State, signierte Session-Cookies, keine Token-Speicherung)
- REST-API: /api/devlogs (öffentlich), /api/commits (nur ADMIN_DISCORD_ID), /api/me
- React + Vite Frontend: Devlog-Archiv mit Mini-Markdown-Renderer, Commit-Tabelle, dunkles EcoGame-Theme
- Fastify liefert frontend/dist mit SPA-Fallback aus; Vite-Dev-Proxy für lokale Entwicklung
- Multi-Stage-Dockerfile (Frontend-Build im Image), neue Env-Vars in Compose + .env.example
- README: OAuth2-Setup (Redirect-URLs, Client Secret) und Frontend-Workflow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 00:31:39 +02:00
co-authored by Claude Opus 4.8
parent 2f9d548bc8
commit a70eac08d7
23 changed files with 2931 additions and 16 deletions
+38 -7
View File
@@ -1,9 +1,20 @@
// Fastify-Webserver: Gitea-Webhook-Endpoint (später auch REST-API + Webinterface)
// Fastify-Webserver: Gitea-Webhook, Discord-OAuth2, REST-API + React-Frontend
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import crypto from 'node:crypto';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { config } from '../config.js';
import { saveCommits } from '../db.js';
import { postPushEmbed } from '../bot/commit-feed.js';
import { registerAuthRoutes } from './auth.js';
import { registerApiRoutes } from './api.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) {
@@ -39,16 +50,36 @@ export async function startWebServer(client) {
}
});
// Root — bis hier das Webinterface (Feature 4) wohnt
app.get('/', async () => ({
name: 'ecobot',
status: 'ok',
endpoints: ['/health', '/webhooks/gitea'],
}));
// Signierte Cookies (Session + OAuth-State)
await app.register(fastifyCookie, { secret: config.sessionSecret });
registerAuthRoutes(app);
registerApiRoutes(app);
// Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' }));
if (existsSync(frontendDist)) {
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing)
await app.register(fastifyStatic, { root: frontendDist });
app.setNotFoundHandler((request, reply) => {
const isApiPath = ['/api', '/auth', '/webhooks'].some((p) =>
request.url.startsWith(p)
);
if (request.method === 'GET' && !isApiPath) {
return reply.sendFile('index.html');
}
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'])) {