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:
@@ -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
@@ -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
@@ -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'])) {
|
||||
|
||||
Reference in New Issue
Block a user