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
+17
View File
@@ -25,6 +25,23 @@ COMMIT_CHANNEL_ID=
# Denselben Wert im Gitea-Webhook als "Secret" eintragen! # Denselben Wert im Gitea-Webhook als "Secret" eintragen!
GITEA_WEBHOOK_SECRET= GITEA_WEBHOOK_SECRET=
# --- Webinterface (Feature 4) ---
# Discord Developer Portal → deine App → OAuth2 → "Client Secret" (ggf. Reset Secret)
DISCORD_CLIENT_SECRET=
# Zufälliger String zum Signieren der Session-Cookies — generieren mit:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=
# Deine Discord-User-ID (Rechtsklick auf deinen Namen → "Benutzer-ID kopieren")
# Nur dieser Account sieht die Commit-Seite
ADMIN_DISCORD_ID=
# Öffentliche Basis-URL für den OAuth2-Redirect
# Lokal zum Testen: http://localhost:3080 (auch im Developer Portal eintragen!)
PUBLIC_URL=https://bot.d4rkst3r.de
# Optional: HTTP-Port des Webservers (Default: 3080) # Optional: HTTP-Port des Webservers (Default: 3080)
# HTTP_PORT=3080 # HTTP_PORT=3080
+2 -1
View File
@@ -1,8 +1,9 @@
# Secrets — niemals committen! # Secrets — niemals committen!
.env .env
# Dependencies # Dependencies & Build-Artefakte
node_modules/ node_modules/
frontend/dist/
# Laufzeitdaten (SQLite etc.) # Laufzeitdaten (SQLite etc.)
data/ data/
+12 -3
View File
@@ -1,5 +1,13 @@
# EcoBot — Discord-Bot + (später) Webinterface # EcoBot — Discord-Bot + Webinterface
# slim statt alpine: better-sqlite3 liefert glibc-Prebuilds → kein Compiler im Image nötig # Stage 1: React-Frontend bauen
FROM node:22-slim AS frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend ./
RUN npm run build
# Stage 2: Runtime (slim statt alpine: better-sqlite3 liefert glibc-Prebuilds)
FROM node:22-slim FROM node:22-slim
WORKDIR /app WORKDIR /app
@@ -9,8 +17,9 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY src ./src COPY src ./src
COPY --from=frontend /build/dist ./frontend/dist
# Laufzeitdaten (SQLite ab Feature 2) landen in /app/data → Volume # Laufzeitdaten (SQLite) landen in /app/data → Volume
RUN mkdir -p /app/data RUN mkdir -p /app/data
ENV NODE_ENV=production ENV NODE_ENV=production
+43 -4
View File
@@ -6,9 +6,9 @@ Discord-Bot + Webinterface für die EcoGame-Community.
1. ✅ Bot online + `/ping` Slash-Command 1. ✅ Bot online + `/ping` Slash-Command
2. ✅ Commit-Feed: Gitea-Push-Webhooks → Discord-Embeds + SQLite-Archiv 2. ✅ Commit-Feed: Gitea-Push-Webhooks → Discord-Embeds + SQLite-Archiv
3. ✅ Devlog-Archiv: Devlog-Kanal live archivieren + `/devlog-backfill` für die Historie 3. ✅ Devlog-Archiv: Devlog-Kanal live archivieren + `/devlog-backfill` für die Historie
4. Webinterface: Discord-Login, Devlog- & Commit-Seiten 4. Webinterface: Devlog-Archiv (öffentlich) + Commit-Feed (Discord-Login, nur Admin)
**Stack:** Node.js 20+, discord.js v14, Fastify, React (ab Feature 4), SQLite (better-sqlite3), Docker **Stack:** Node.js 20+, discord.js v14, Fastify, React + Vite, SQLite (better-sqlite3), Docker
--- ---
@@ -116,6 +116,38 @@ können sich nicht doppeln, der Command ist beliebig oft wiederholbar.
--- ---
## Setup: Webinterface
**Zugriffsmodell:** Devlog-Archiv ist öffentlich (wie der Discord-Kanal),
die Commit-Seite erfordert Discord-Login und ist auf deine Discord-ID beschränkt.
### 1. OAuth2 im Developer Portal konfigurieren
1. [Developer Portal](https://discord.com/developers/applications) → deine App → **OAuth2**
2. **Client Secret** kopieren (ggf. „Reset Secret") → `DISCORD_CLIENT_SECRET`
3. Unter **Redirects** BEIDE URLs eintragen:
- `https://bot.d4rkst3r.de/auth/callback` (Produktion)
- `http://localhost:3080/auth/callback` (lokale Entwicklung)
### 2. Neue .env-Werte
| Variable | Woher |
|---|---|
| `DISCORD_CLIENT_SECRET` | Developer Portal → OAuth2 (Schritt 1) |
| `SESSION_SECRET` | Generieren: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
| `ADMIN_DISCORD_ID` | Discord: Rechtsklick auf deinen Namen → **Benutzer-ID kopieren** |
| `PUBLIC_URL` | Produktion: `https://bot.d4rkst3r.de` · lokal: `http://localhost:3080` |
### 3. Frontend lokal entwickeln
```
cd frontend
npm install
npm run dev # Vite-Dev-Server auf :5173, proxied /api + /auth zum Bot auf :3080
```
Für den „Produktions-Look" lokal: `npm run build` im frontend/-Ordner —
der Bot liefert `frontend/dist` dann selbst unter `http://localhost:3080` aus.
Im Docker-Image wird das Frontend automatisch mitgebaut (Multi-Stage).
---
## Lokal starten (Entwicklung) ## Lokal starten (Entwicklung)
``` ```
@@ -188,9 +220,16 @@ ecobot/
│ │ ├── ping.js # /ping — Lebenszeichen │ │ ├── ping.js # /ping — Lebenszeichen
│ │ └── devlog-backfill.js # /devlog-backfill — Historie archivieren (Admin) │ │ └── devlog-backfill.js # /devlog-backfill — Historie archivieren (Admin)
│ └── web/ │ └── web/
── server.js # Fastify: /health, /webhooks/gitea (HMAC-geprüft) ── server.js # Fastify: Webhook, Static-Serving, SPA-Fallback
│ ├── auth.js # Discord-OAuth2-Flow + Session-Cookies
│ └── api.js # REST-API: /api/me, /api/devlogs, /api/commits
├── frontend/ # React + Vite (Devlogs öffentlich, Commits admin-only)
│ └── src/
│ ├── App.jsx # Layout, Router, Login-Status
│ ├── markdown.jsx # Mini-Markdown-Renderer für Devlog-Prosa
│ └── pages/ # Devlogs.jsx, Commits.jsx
├── .env.example # Vorlage für Secrets (nach .env kopieren) ├── .env.example # Vorlage für Secrets (nach .env kopieren)
├── Dockerfile ├── Dockerfile # Multi-Stage: Frontend-Build + Runtime
├── docker-compose.yml ├── docker-compose.yml
└── README.md └── README.md
``` ```
+4
View File
@@ -14,6 +14,10 @@ services:
COMMIT_CHANNEL_ID: ${COMMIT_CHANNEL_ID} COMMIT_CHANNEL_ID: ${COMMIT_CHANNEL_ID}
DEVLOG_CHANNEL_ID: ${DEVLOG_CHANNEL_ID} DEVLOG_CHANNEL_ID: ${DEVLOG_CHANNEL_ID}
GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET} GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET}
SESSION_SECRET: ${SESSION_SECRET}
ADMIN_DISCORD_ID: ${ADMIN_DISCORD_ID}
PUBLIC_URL: ${PUBLIC_URL:-https://bot.d4rkst3r.de}
DB_PATH: /app/data/ecobot.db DB_PATH: /app/data/ecobot.db
# HTTP-Endpoint für Gitea-Webhooks + Webinterface # HTTP-Endpoint für Gitea-Webhooks + Webinterface
# NPM leitet bot.d4rkst3r.de → host.docker.internal:3080 # NPM leitet bot.d4rkst3r.de → host.docker.internal:3080
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EcoGame — Devlog & Commits</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1876
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "ecobot-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.0"
}
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { apiGet } from './api.js';
import Devlogs from './pages/Devlogs.jsx';
import Commits from './pages/Commits.jsx';
export default function App() {
// Login-Status einmal beim Laden holen
const [me, setMe] = useState({ user: null, admin: false, loading: true });
useEffect(() => {
apiGet('/api/me')
.then((data) => setMe({ ...data, loading: false }))
.catch(() => setMe({ user: null, admin: false, loading: false }));
}, []);
return (
<div className="app">
<header className="header">
<div className="header-inner">
<NavLink to="/" className="brand">
<span className="brand-dot" /> EcoGame
</NavLink>
<nav className="nav">
<NavLink to="/devlogs">Devlogs</NavLink>
{me.admin && <NavLink to="/commits">Commits</NavLink>}
</nav>
<div className="auth">
{me.loading ? null : me.user ? (
<>
{me.user.avatar && (
<img className="avatar" src={me.user.avatar} alt="" />
)}
<span className="username">{me.user.username}</span>
<a className="btn btn-ghost" href="/auth/logout">Logout</a>
</>
) : (
<a className="btn" href="/auth/login">Login mit Discord</a>
)}
</div>
</div>
</header>
<main className="main">
<Routes>
<Route path="/" element={<Navigate to="/devlogs" replace />} />
<Route path="/devlogs" element={<Devlogs />} />
<Route path="/commits" element={<Commits me={me} />} />
<Route path="*" element={<Navigate to="/devlogs" replace />} />
</Routes>
</main>
<footer className="footer">
EcoGame Devlog-Archiv ·{' '}
<a href="https://git.d4rkst3r.de/D4rkst3r" target="_blank" rel="noreferrer">
git.d4rkst3r.de
</a>
</footer>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
// Kleiner Fetch-Wrapper — Cookies (Session) laufen automatisch mit (same-origin)
export async function apiGet(path) {
const res = await fetch(path, { headers: { Accept: 'application/json' } });
if (!res.ok) {
const error = new Error(`API ${res.status}`);
error.status = res.status;
throw error;
}
return res.json();
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+41
View File
@@ -0,0 +1,41 @@
// Mini-Markdown-Renderer für Devlog-Prosa (Discord-Stil): **fett**, *kursiv*,
// `code`, [Links](url). Bewusst ohne Library — Input wird escaped, kein HTML-Injection.
const TOKEN = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\(https?:\/\/[^\s)]+\))/g;
function renderInline(text) {
return text.split(TOKEN).map((part, i) => {
// Fett/Kursiv rekursiv rendern, damit z. B. Links darin funktionieren
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i}>{renderInline(part.slice(2, -2))}</strong>;
}
if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
return <em key={i}>{renderInline(part.slice(1, -1))}</em>;
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={i}>{part.slice(1, -1)}</code>;
}
const link = part.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/);
if (link) {
return (
<a key={i} href={link[2]} target="_blank" rel="noreferrer">
{link[1]}
</a>
);
}
return part;
});
}
/** Devlog-Text als React-Elemente (Absätze + Inline-Formatierung) */
export function Markdown({ text }) {
return text.split(/\n{2,}/).map((block, i) => (
<p key={i}>
{block.split('\n').map((line, j, arr) => (
<span key={j}>
{renderInline(line)}
{j < arr.length - 1 && <br />}
</span>
))}
</p>
));
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
export default function Commits({ me }) {
const [data, setData] = useState(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(null);
useEffect(() => {
if (!me.admin) return;
setError(null);
apiGet(`/api/commits?page=${page}`).then(setData).catch((e) => setError(e.status));
}, [page, me.admin]);
// Zugriffs-Zustände: nicht eingeloggt / eingeloggt aber kein Admin
if (me.loading) return <p className="notice">Lade </p>;
if (!me.user) {
return (
<div className="notice center">
<p>Der Commit-Feed ist privat.</p>
<a className="btn" href="/auth/login">Login mit Discord</a>
</div>
);
}
if (!me.admin) return <p className="notice">Kein Zugriff der Commit-Feed ist privat.</p>;
if (error) return <p className="notice">Commits konnten nicht geladen werden ({error}).</p>;
if (!data) return <p className="notice">Lade </p>;
if (data.total === 0) return <p className="notice">Noch keine Commits archiviert.</p>;
const pages = Math.ceil(data.total / data.pageSize);
return (
<>
<h1>Commits</h1>
<p className="subtitle">{data.total} archivierte Commits</p>
<div className="table-wrap">
<table className="commits">
<thead>
<tr>
<th>SHA</th>
<th>Message</th>
<th>Repo</th>
<th>Branch</th>
<th>Zeit</th>
</tr>
</thead>
<tbody>
{data.items.map((c) => (
<tr key={c.sha}>
<td>
{c.url ? (
<a href={c.url} target="_blank" rel="noreferrer">
<code>{c.sha.slice(0, 7)}</code>
</a>
) : (
<code>{c.sha.slice(0, 7)}</code>
)}
</td>
<td className="msg">{c.message.split('\n')[0]}</td>
<td>{c.repo}</td>
<td>{c.branch}</td>
<td className="time">{dateFmt.format(new Date(c.committed_at))}</td>
</tr>
))}
</tbody>
</table>
</div>
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
</button>
<span>Seite {page} / {pages}</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
</button>
</div>
)}
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { Markdown } from '../markdown.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
});
export default function Devlogs() {
const [data, setData] = useState(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(false);
useEffect(() => {
setError(false);
apiGet(`/api/devlogs?page=${page}`).then(setData).catch(() => setError(true));
}, [page]);
if (error) return <p className="notice">Devlogs konnten nicht geladen werden.</p>;
if (!data) return <p className="notice">Lade </p>;
if (data.total === 0) return <p className="notice">Noch keine Devlogs archiviert.</p>;
const pages = Math.ceil(data.total / data.pageSize);
return (
<>
<h1>Devlogs</h1>
<p className="subtitle">{data.total} Einträge aus der Entwicklung von EcoGame</p>
{data.items.map((d) => (
<article className="card" key={d.message_id}>
<div className="card-date">{dateFmt.format(new Date(d.posted_at))}</div>
<div className="card-body">
<Markdown text={d.content} />
</div>
</article>
))}
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
</button>
<span>Seite {page} / {pages}</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
</button>
</div>
)}
</>
);
}
+161
View File
@@ -0,0 +1,161 @@
/* EcoGame Webinterface — dunkles Theme, EcoGame-Grün als Akzent */
:root {
--bg: #0f1412;
--bg-card: #171e1a;
--bg-header: #131916;
--border: #263129;
--text: #e4ece6;
--text-dim: #93a297;
--accent: #2e8b57;
--accent-bright: #3fae6f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, 'Segoe UI', sans-serif;
line-height: 1.6;
}
.app { min-height: 100vh; display: flex; flex-direction: column; }
/* Header */
.header {
background: var(--bg-header);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
}
.header-inner {
max-width: 880px;
margin: 0 auto;
padding: 0.7rem 1rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.brand {
font-weight: 700;
font-size: 1.15rem;
color: var(--text);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
.brand-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--accent-bright);
box-shadow: 0 0 8px var(--accent-bright);
}
.nav { display: flex; gap: 1rem; flex: 1; }
.nav a {
color: var(--text-dim);
text-decoration: none;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
}
.nav a:hover { color: var(--text); }
.nav a.active { color: var(--text); border-bottom-color: var(--accent); }
.auth { display: flex; align-items: center; gap: 0.6rem; }
.avatar { width: 28px; height: 28px; border-radius: 50%; }
.username { color: var(--text-dim); font-size: 0.9rem; }
/* Buttons */
.btn {
display: inline-block;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.45rem 0.9rem;
font-size: 0.9rem;
text-decoration: none;
cursor: pointer;
}
.btn:hover:not(:disabled) { background: var(--accent-bright); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-dim); }
.btn-ghost:hover { color: var(--text); background: transparent; }
/* Inhalt */
.main { flex: 1; max-width: 880px; width: 100%; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
h1 { margin: 0.5rem 0 0.2rem; font-size: 1.6rem; }
.subtitle { color: var(--text-dim); margin: 0 0 1.5rem; font-size: 0.95rem; }
.notice { color: var(--text-dim); padding: 2rem 0; }
.notice.center { text-align: center; }
.notice .btn { margin-top: 0.5rem; }
/* Devlog-Karten */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.card-date {
color: var(--accent-bright);
font-size: 0.85rem;
font-weight: 600;
text-transform: capitalize;
margin-bottom: 0.3rem;
}
.card-body p { margin: 0.5rem 0; }
.card-body a { color: var(--accent-bright); }
.card-body code {
background: #0d1210;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.05rem 0.35rem;
font-size: 0.85em;
}
/* Commit-Tabelle */
.table-wrap { overflow-x: auto; }
.commits { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.commits th {
text-align: left;
color: var(--text-dim);
font-weight: 600;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.commits td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.commits .msg { min-width: 240px; }
.commits .time { white-space: nowrap; color: var(--text-dim); }
.commits a { color: var(--accent-bright); text-decoration: none; }
.commits a:hover { text-decoration: underline; }
.commits code { font-size: 0.85em; }
/* Pagination */
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
color: var(--text-dim);
font-size: 0.9rem;
}
/* Footer */
.footer {
border-top: 1px solid var(--border);
color: var(--text-dim);
text-align: center;
padding: 1rem;
font-size: 0.85rem;
}
.footer a { color: var(--accent-bright); text-decoration: none; }
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Dev-Server proxied API/Auth zum lokalen Bot (npm run dev im Hauptordner, Port 3080)
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3080',
'/auth': 'http://localhost:3080',
},
},
});
+295
View File
@@ -9,6 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/static": "^10.1.2",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"discord.js": "^14.16.3", "discord.js": "^14.16.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@@ -148,6 +150,22 @@
"url": "https://github.com/discordjs/discord.js?sponsor" "url": "https://github.com/discordjs/discord.js?sponsor"
} }
}, },
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": { "node_modules/@fastify/ajv-compiler": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -169,6 +187,39 @@
"fast-uri": "^3.0.0" "fast-uri": "^3.0.0"
} }
}, },
"node_modules/@fastify/cookie": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
"integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^2.0.0",
"fastify-plugin": "^6.0.0"
}
},
"node_modules/@fastify/cookie/node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@fastify/error": { "node_modules/@fastify/error": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@@ -259,6 +310,63 @@
"ipaddr.js": "^2.1.0" "ipaddr.js": "^2.1.0"
} }
}, },
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz",
"integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -394,6 +502,15 @@
"fastq": "^1.17.1" "fastq": "^1.17.1"
} }
}, },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -448,6 +565,18 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -478,6 +607,19 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -515,6 +657,15 @@
"node": ">=4.0.0" "node": ">=4.0.0"
} }
}, },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dequal": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -590,6 +741,12 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/expand-template": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -709,6 +866,22 @@
"toad-cache": "^3.7.0" "toad-cache": "^3.7.0"
} }
}, },
"node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -750,6 +923,43 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -865,12 +1075,33 @@
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-bytes.js": { "node_modules/magic-bytes.js": {
"version": "1.13.0", "version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/mimic-response": { "node_modules/mimic-response": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -883,6 +1114,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -892,6 +1138,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp-classic": { "node_modules/mkdirp-classic": {
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -934,6 +1189,22 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pino": { "node_modules/pino": {
"version": "10.3.1", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
@@ -1187,6 +1458,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/simple-concat": { "node_modules/simple-concat": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -1250,6 +1527,15 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -1323,6 +1609,15 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/ts-mixer": { "node_modules/ts-mixer": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
+2
View File
@@ -14,6 +14,8 @@
"dev": "node --watch src/index.js" "dev": "node --watch src/index.js"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/static": "^10.1.2",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"discord.js": "^14.16.3", "discord.js": "^14.16.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
+11 -1
View File
@@ -28,9 +28,19 @@ export const config = {
// Shared Secret — muss identisch im Gitea-Webhook eingetragen sein // Shared Secret — muss identisch im Gitea-Webhook eingetragen sein
giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'), giteaWebhookSecret: required('GITEA_WEBHOOK_SECRET'),
// HTTP-Server (Webhooks, später Webinterface) // HTTP-Server (Webhooks + Webinterface)
httpPort: Number(process.env.HTTP_PORT) || 3080, 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) // SQLite-Datei (im Container: /app/data → ecobot_data-Volume)
dbPath: process.env.DB_PATH || './data/ecobot.db', dbPath: process.env.DB_PATH || './data/ecobot.db',
}; };
+22
View File
@@ -58,3 +58,25 @@ const insertDevlog = db.prepare(`
export function saveDevlog(devlog) { export function saveDevlog(devlog) {
return insertDevlog.run(devlog).changes > 0; 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 Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import crypto from 'node:crypto'; 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 { config } from '../config.js';
import { saveCommits } from '../db.js'; import { saveCommits } from '../db.js';
import { postPushEmbed } from '../bot/commit-feed.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 */ /** Gitea-Signatur prüfen: HMAC-SHA256 (hex) über den rohen Request-Body */
function verifySignature(rawBody, signatureHex) { function verifySignature(rawBody, signatureHex) {
@@ -39,16 +50,36 @@ export async function startWebServer(client) {
} }
}); });
// Root — bis hier das Webinterface (Feature 4) wohnt // Signierte Cookies (Session + OAuth-State)
app.get('/', async () => ({ await app.register(fastifyCookie, { secret: config.sessionSecret });
name: 'ecobot',
status: 'ok', registerAuthRoutes(app);
endpoints: ['/health', '/webhooks/gitea'], registerApiRoutes(app);
}));
// Healthcheck (für Portainer/NPM) // Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' })); 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 // Gitea-Push-Webhook → Embed posten + Commits archivieren
app.post('/webhooks/gitea', async (request, reply) => { app.post('/webhooks/gitea', async (request, reply) => {
if (!verifySignature(request.rawBody, request.headers['x-gitea-signature'])) { if (!verifySignature(request.rawBody, request.headers['x-gitea-signature'])) {