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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
Generated
+1876
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+295
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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'])) {
|
||||||
|
|||||||
Reference in New Issue
Block a user