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
+11 -1
View File
@@ -28,9 +28,19 @@ export const config = {
// Shared Secret — muss identisch im Gitea-Webhook eingetragen sein
giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'),
// HTTP-Server (Webhooks, später Webinterface)
// HTTP-Server (Webhooks + Webinterface)
httpPort: Number(process.env.HTTP_PORT) || 3080,
// Webinterface (Feature 4)
// OAuth2 Client Secret: Developer Portal → OAuth2 → Client Secret
discordClientSecret: required('DISCORD_CLIENT_SECRET'),
// Zufälliger String (>=32 Zeichen) zum Signieren der Session-Cookies
sessionSecret: required('SESSION_SECRET'),
// Deine Discord-User-ID — nur dieser Account sieht die Commit-Seite
adminDiscordId: required('ADMIN_DISCORD_ID'),
// Öffentliche Basis-URL (für den OAuth2-Redirect); lokal: http://localhost:3080
publicUrl: (process.env.PUBLIC_URL || 'https://bot.d4rkst3r.de').replace(/\/$/, ''),
// SQLite-Datei (im Container: /app/data → ecobot_data-Volume)
dbPath: process.env.DB_PATH || './data/ecobot.db',
};
+22
View File
@@ -58,3 +58,25 @@ const insertDevlog = db.prepare(`
export function saveDevlog(devlog) {
return insertDevlog.run(devlog).changes > 0;
}
// --- Lese-Queries für die Web-API (Feature 4) ---
const selectDevlogs = db.prepare(`
SELECT message_id, content, author_name, posted_at
FROM devlogs ORDER BY posted_at DESC LIMIT ? OFFSET ?
`);
const countDevlogsStmt = db.prepare('SELECT COUNT(*) AS n FROM devlogs');
export function listDevlogs(limit, offset) {
return { items: selectDevlogs.all(limit, offset), total: countDevlogsStmt.get().n };
}
const selectCommits = db.prepare(`
SELECT sha, repo, branch, message, author_name, url, committed_at
FROM commits ORDER BY committed_at DESC LIMIT ? OFFSET ?
`);
const countCommitsStmt = db.prepare('SELECT COUNT(*) AS n FROM commits');
export function listCommits(limit, offset) {
return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n };
}
+37
View File
@@ -0,0 +1,37 @@
// REST-API fürs Webinterface: Devlogs öffentlich, Commits nur für den Admin
import { listDevlogs, listCommits } from '../db.js';
import { getSessionUser, isAdmin } from './auth.js';
const PAGE_SIZE = 20;
/** Query-Parameter ?page=1.. in LIMIT/OFFSET übersetzen */
function paging(request) {
const page = Math.max(1, Number(request.query.page) || 1);
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
}
export function registerApiRoutes(app) {
// Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag)
app.get('/api/me', async (request) => {
const user = getSessionUser(request);
return user ? { user, admin: isAdmin(user) } : { user: null, admin: false };
});
// Devlog-Archiv — öffentlich (wie der Discord-Kanal)
app.get('/api/devlogs', async (request) => {
const { limit, offset, page } = paging(request);
const { items, total } = listDevlogs(limit, offset);
return { items, total, page, pageSize: PAGE_SIZE };
});
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
app.get('/api/commits', async (request, reply) => {
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'login required' });
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
const { limit, offset, page } = paging(request);
const { items, total } = listCommits(limit, offset);
return { items, total, page, pageSize: PAGE_SIZE };
});
}
+101
View File
@@ -0,0 +1,101 @@
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
import crypto from 'node:crypto';
import { config } from '../config.js';
const DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'ecobot_session';
const STATE_COOKIE = 'ecobot_oauth_state';
const redirectUri = `${config.publicUrl}/auth/callback`;
/** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */
export function getSessionUser(request) {
const raw = request.cookies[SESSION_COOKIE];
if (!raw) return null;
const unsigned = request.unsignCookie(raw);
if (!unsigned.valid) return null;
try {
return JSON.parse(unsigned.value);
} catch {
return null;
}
}
/** true, wenn der eingeloggte User der Admin (du) ist */
export function isAdmin(user) {
return user?.id === config.adminDiscordId;
}
export function registerAuthRoutes(app) {
// Login: mit CSRF-State zu Discord weiterleiten
app.get('/auth/login', async (request, reply) => {
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
client_id: config.discordClientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'identify',
state,
});
return reply
.setCookie(STATE_COOKIE, state, {
path: '/auth', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true,
})
.redirect(`https://discord.com/oauth2/authorize?${params}`);
});
// Callback: Code gegen Token tauschen, User holen, Session-Cookie setzen
app.get('/auth/callback', async (request, reply) => {
const { code, state } = request.query;
const stateCookie = request.cookies[STATE_COOKIE]
? request.unsignCookie(request.cookies[STATE_COOKIE])
: null;
if (!code || !state || !stateCookie?.valid || stateCookie.value !== state) {
return reply.code(400).send('Ungültiger OAuth-State — bitte erneut einloggen.');
}
const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.discordClientId,
client_secret: config.discordClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) {
request.log.error(`OAuth-Token-Tausch fehlgeschlagen: ${tokenRes.status}`);
return reply.code(502).send('Discord-Login fehlgeschlagen — bitte erneut versuchen.');
}
const { access_token: accessToken } = await tokenRes.json();
const userRes = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userRes.ok) {
return reply.code(502).send('Discord-Userdaten nicht abrufbar — bitte erneut versuchen.');
}
const me = await userRes.json();
// Nur das Nötigste in die Session — keine Tokens speichern
const session = {
id: me.id,
username: me.global_name || me.username,
avatar: me.avatar
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png?size=64`
: null,
};
return reply
.clearCookie(STATE_COOKIE, { path: '/auth' })
.setCookie(SESSION_COOKIE, JSON.stringify(session), {
path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true,
})
.redirect('/');
});
app.get('/auth/logout', async (request, reply) => {
return reply.clearCookie(SESSION_COOKIE, { path: '/' }).redirect('/');
});
}
+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'])) {