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
+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('/');
});
}