d4rkbot: API v1 mit Key-System, Env-Diät, Rebranding

- API-Keys (SHA-256-Hash, Scopes, last_used) — Verwaltung auf der Setup-Seite,
  Klartext-Key wird genau einmal angezeigt
- /api/v1: message, dm, roles (add/remove), member/:id, stats — Bearer-Auth
  mit Scope-Prüfung, Embed-Sanitizing, README-Doku mit Python-Beispiel
- Env-Diät: PUBLIC_URL + GITEA_URL jetzt Settings (Env nur Fallback),
  OAuth-Redirect dynamisch; Env enthält nur noch Secrets/Bootstrap
- Rebranding ecobot → d4rkbot (Packages, Container, Cookies, README);
  Volume-Name bleibt ecobot_data (Datenerhalt), Portainer-Stack-Name bleibt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:56:48 +02:00
co-authored by Claude Opus 4.8
parent eb51be4ebc
commit 143288affa
19 changed files with 468 additions and 37 deletions
+41 -6
View File
@@ -1,4 +1,4 @@
# EcoBot — D4RKST3R // DEVLOG # d4rkbot — D4RKST3R // COMMUNITY BOT
Discord-Bot + Webinterface: Devlog-Tagebuch, Commit-Feed, Changelog und Discord-Bot + Webinterface: Devlog-Tagebuch, Commit-Feed, Changelog und
Community-Tools für die EcoGame-Entwicklung — alles gebrandet, alles über Community-Tools für die EcoGame-Entwicklung — alles gebrandet, alles über
@@ -122,8 +122,8 @@ Der Endpoint versteht das Discord-Webhook-Format (JSON + Multipart) —
## Deployment (Portainer) ## Deployment (Portainer)
1. **Stacks → Add stack** → Name `ecobot` → Build method **Repository** 1. **Stacks → Add stack** → Name `ecobot` (Bestands-Stack behalten! Der Name steckt im Volume-Prefix) → Build method **Repository**
- URL `https://git.d4rkst3r.de/D4rkst3r/ecobot`, Reference `refs/heads/main`, - URL `https://git.d4rkst3r.de/D4rkst3r/d4rkbot`, Reference `refs/heads/main`,
Compose path `docker-compose.yml` Compose path `docker-compose.yml`
- Privates Repo: Authentication mit Gitea-Token (Scope `read:repository`) - Privates Repo: Authentication mit Gitea-Token (Scope `read:repository`)
2. Environment-Variablen eintragen (Tabelle oben) 2. Environment-Variablen eintragen (Tabelle oben)
@@ -137,10 +137,10 @@ Daten liegen im Volume `ecobot_data` (`/app/data`): SQLite (`ecobot.db`, WAL)
Manuell statt Portainer: Manuell statt Portainer:
``` ```
git clone git@gitea:D4rkst3r/ecobot.git && cd ecobot git clone git@gitea:D4rkst3r/d4rkbot.git && cd d4rkbot
cp .env.example .env # Werte eintragen cp .env.example .env # Werte eintragen
docker compose up -d --build docker compose up -d --build
docker logs -f ecobot docker logs -f d4rkbot
``` ```
--- ---
@@ -168,6 +168,39 @@ node tools/test-webhook.mjs
--- ---
## API v1 — für eigene Skripte & Dienste
Der Bot ist die zentrale Discord-Brücke der Infrastruktur: devlog.py, Platform,
FiveM-Server, CI-Jobs … reden alle mit einer API statt mit zig Discord-Webhooks.
**Auth:** API-Keys auf der Setup-Seite erstellen (Name + Scopes, Key wird einmalig
angezeigt, Widerruf jederzeit). Jeder Request:
```
Authorization: Bearer d4rk_<key>
```
| Endpoint | Scope | Body / Antwort |
|---|---|---|
| `POST /api/v1/message` | `message` | `{ channel_id, content?, embed? }` → postet als Bot (Embed: title, description, color, url, image, thumbnail, footer, fields) |
| `POST /api/v1/dm` | `dm` | `{ user_id, content }` → Direktnachricht |
| `POST /api/v1/roles` | `roles` | `{ user_id, role_id, action: "add"\|"remove" }` → Rolle vergeben (z. B. Shop-Kauf → Kunden-Rolle) |
| `GET /api/v1/member/:id` | `read` | Member-Info: Name, Rollen, Beitritt — für Login-/Berechtigungs-Checks |
| `GET /api/v1/stats` | `read` | Devlog-/Commit-Zahlen, Guilds, Uptime |
Beispiel (Python):
```python
import urllib.request, json
req = urllib.request.Request(
"https://bot.d4rkst3r.de/api/v1/message",
data=json.dumps({"channel_id": "123", "embed": {"title": "Build fertig ✅", "color": 0xF5C518}}).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer d4rk_...",
"User-Agent": "mein-script/1.0"})
urllib.request.urlopen(req)
```
---
## HTTP-Endpoints ## HTTP-Endpoints
| Route | Auth | Zweck | | Route | Auth | Zweck |
@@ -179,6 +212,8 @@ node tools/test-webhook.mjs
| `GET /api/releases?page=` | — | Changelog | | `GET /api/releases?page=` | — | Changelog |
| `GET /feed.xml` | — | RSS | | `GET /feed.xml` | — | RSS |
| `GET /devlog-assets/*` | — | Lokal gespeicherte Devlog-Bilder | | `GET /devlog-assets/*` | — | Lokal gespeicherte Devlog-Bilder |
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
| `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung |
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite | | `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | | `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 | | `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
@@ -191,7 +226,7 @@ Archiv (MessageDelete-Sync) — zusätzlich gibt es den ✕-Button für Admins a
## Projektstruktur ## Projektstruktur
``` ```
ecobot/ d4rkbot/
├── src/ ├── src/
│ ├── index.js # Start: Bot, Webserver, Wochen-Rückblick, Watchdog │ ├── index.js # Start: Bot, Webserver, Wochen-Rückblick, Watchdog
│ ├── config.js # Env-Konfiguration mit Validierung │ ├── config.js # Env-Konfiguration mit Validierung
+8 -5
View File
@@ -1,9 +1,11 @@
# EcoBot Stack — deploybar über Portainer (Stacks → Add stack) # d4rkbot Stack — deploybar über Portainer (Stacks → Add stack)
# WICHTIG: Portainer-Stack-Name "ecobot" NICHT ändern — er ist Teil des
# Volume-Namens (ecobot_ecobot_data); ein neuer Stack-Name = leeres Volume!
services: services:
ecobot: d4rkbot:
build: . build: .
image: ecobot:latest image: d4rkbot:latest
container_name: ecobot container_name: d4rkbot
restart: unless-stopped restart: unless-stopped
# Werte kommen per Interpolation: lokal aus der .env im Projektordner (liest # Werte kommen per Interpolation: lokal aus der .env im Projektordner (liest
# docker compose automatisch), in Portainer aus den Stack-Environment-Variables # docker compose automatisch), in Portainer aus den Stack-Environment-Variables
@@ -29,7 +31,8 @@ services:
ports: ports:
- "3080:3080" - "3080:3080"
volumes: volumes:
# SQLite & Co. überleben Container-Neustarts # SQLite & Devlog-Bilder überleben Container-Neustarts.
# Volume-Name bleibt aus historischen Gründen "ecobot_data" (Daten!)
- ecobot_data:/app/data - ecobot_data:/app/data
volumes: volumes:
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "ecobot-frontend", "name": "d4rkbot-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ecobot-frontend", "name": "d4rkbot-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/barlow-condensed": "^5.3.0", "@fontsource/barlow-condensed": "^5.3.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "ecobot-frontend", "name": "d4rkbot-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+114 -1
View File
@@ -1,5 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { apiGet, apiPut, apiPost } from '../api.js'; import { apiGet, apiPut, apiPost, apiDelete } from '../api.js';
const API_SCOPES = [
{ id: 'message', label: 'message', hint: 'Nachrichten/Embeds in Kanäle posten' },
{ id: 'dm', label: 'dm', hint: 'Direktnachrichten senden' },
{ id: 'roles', label: 'roles', hint: 'Rollen geben/nehmen' },
{ id: 'read', label: 'read', hint: 'Member-Infos & Statistiken lesen' },
];
/** Sekunden → "2d 4h 13m" */ /** Sekunden → "2d 4h 13m" */
function fmtUptime(s) { function fmtUptime(s) {
@@ -19,6 +26,11 @@ export default function Settings({ me }) {
const [form, setForm] = useState(null); const [form, setForm] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [feedback, setFeedback] = useState(null); const [feedback, setFeedback] = useState(null);
// API-Keys
const [keys, setKeys] = useState([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState(['message']);
const [createdKey, setCreatedKey] = useState(null);
useEffect(() => { useEffect(() => {
if (!me.admin) return; if (!me.admin) return;
@@ -28,8 +40,28 @@ export default function Settings({ me }) {
setForm(d.settings); setForm(d.settings);
}) })
.catch((e) => setError(e.status)); .catch((e) => setError(e.status));
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
}, [me.admin]); }, [me.admin]);
async function createKey() {
if (!newKeyName.trim() || newKeyScopes.length === 0) return;
try {
const res = await apiPost('/api/apikeys', { name: newKeyName.trim(), scopes: newKeyScopes });
setCreatedKey(res);
setNewKeyName('');
setKeys((await apiGet('/api/apikeys')).keys);
} catch {
flash('✗ Key-Erstellung fehlgeschlagen');
}
}
async function revokeKey(id) {
if (!window.confirm('Diesen API-Key widerrufen? Skripte mit diesem Key verlieren sofort den Zugriff.')) return;
await apiDelete(`/api/apikeys/${id}`).catch(() => {});
setKeys((await apiGet('/api/apikeys')).keys);
if (createdKey?.id === id) setCreatedKey(null);
}
function flash(msg) { function flash(msg) {
setFeedback(msg); setFeedback(msg);
setTimeout(() => setFeedback(null), 3000); setTimeout(() => setFeedback(null), 3000);
@@ -105,6 +137,29 @@ export default function Settings({ me }) {
</div> </div>
</div> </div>
{/* Allgemein */}
<div className="settings-section">
<h2 className="settings-title">// Allgemein</h2>
<div className="field">
<label>Öffentliche URL <span className="field-hint">Basis für Buttons, RSS und OAuth-Redirect (Redirect auch im Dev-Portal eintragen)</span></label>
<input
type="text"
placeholder="https://bot.d4rkst3r.de"
value={form.public_url}
onChange={(e) => setForm({ ...form, public_url: e.target.value })}
/>
</div>
<div className="field">
<label>Gitea-URL <span className="field-hint">Basis für die Issue-API (/bug)</span></label>
<input
type="text"
placeholder="https://git.d4rkst3r.de"
value={form.gitea_url}
onChange={(e) => setForm({ ...form, gitea_url: e.target.value })}
/>
</div>
</div>
{/* Kanäle */} {/* Kanäle */}
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Kanäle</h2> <h2 className="settings-title">// Kanäle</h2>
@@ -227,6 +282,64 @@ export default function Settings({ me }) {
</div> </div>
</div> </div>
{/* API-Keys */}
<div className="settings-section">
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
{keys.length > 0 && (
<div className="key-list">
{keys.map((k) => (
<div className="key-row" key={k.id}>
<span className="key-name">{k.name}</span>
<span className="key-scopes">{k.scopes}</span>
<span className="key-used">
{k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'}
</span>
<button className="card-delete" title="Key widerrufen" onClick={() => revokeKey(k.id)}></button>
</div>
))}
</div>
)}
{createdKey && (
<div className="key-created">
<p>Key <strong>{createdKey.name}</strong> erstellt <strong>jetzt kopieren</strong>, er wird nie wieder angezeigt:</p>
<code>{createdKey.key}</code>
</div>
)}
<div className="field">
<label>Neuer Key <span className="field-hint">Name des Skripts/Dienstes + Scopes</span></label>
<div className="field-row">
<input
type="text"
placeholder="z. B. devlog.py, platform, fivem"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
<button className="btn" onClick={createKey}>Erstellen</button>
</div>
<div className="scope-row">
{API_SCOPES.map((s) => (
<label className="scope-pick" key={s.id} title={s.hint}>
<input
type="checkbox"
checked={newKeyScopes.includes(s.id)}
onChange={(e) =>
setNewKeyScopes(
e.target.checked
? [...newKeyScopes, s.id]
: newKeyScopes.filter((x) => x !== s.id)
)
}
/>
{s.label}
</label>
))}
</div>
</div>
</div>
{/* Commit-Feed */} {/* Commit-Feed */}
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Commit-Feed</h2> <h2 className="settings-title">// Commit-Feed</h2>
+38
View File
@@ -503,6 +503,44 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.toggle input:checked::after { transform: translateX(16px); background: var(--neon); } .toggle input:checked::after { transform: translateX(16px); background: var(--neon); }
.toggle > span:first-of-type { color: var(--text); font-size: .95rem; } .toggle > span:first-of-type { color: var(--text); font-size: .95rem; }
/* API-Keys */
.key-list { margin-bottom: 1.2rem; border: 1px solid rgba(255, 255, 255, .06); }
.key-row {
display: flex; align-items: center; gap: 1rem;
padding: .5rem .8rem;
border-bottom: 1px solid rgba(255, 255, 255, .05);
font-size: .9rem;
}
.key-row:last-child { border-bottom: none; }
.key-name { font-weight: 600; min-width: 8rem; }
.key-scopes {
font-family: var(--mono); font-size: .65rem; letter-spacing: .1em;
color: var(--neon); flex: 1;
}
.key-used { font-family: var(--mono); font-size: .62rem; color: var(--muted2); white-space: nowrap; }
.key-created {
border: 1px solid rgba(0, 255, 136, .3);
background: rgba(0, 255, 136, .04);
padding: .8rem 1rem;
margin-bottom: 1.2rem;
font-size: .9rem;
}
.key-created p { margin: 0 0 .5rem; }
.key-created code {
display: block;
font-family: var(--mono); font-size: .8rem;
color: var(--success);
background: #000; padding: .5rem .7rem;
user-select: all; word-break: break-all;
}
.scope-row { display: flex; gap: 1.2rem; margin-top: .5rem; flex-wrap: wrap; }
.scope-pick {
display: flex; align-items: center; gap: .4rem;
font-family: var(--mono); font-size: .7rem; letter-spacing: .1em;
color: var(--muted); cursor: pointer;
}
.scope-pick input { accent-color: var(--neon); }
.settings-actions { display: flex; align-items: center; gap: 1rem; } .settings-actions { display: flex; align-items: center; gap: 1rem; }
.btn-save { background: var(--neon); color: #0a0a0a; border-color: var(--neon); font-weight: 600; } .btn-save { background: var(--neon); color: #0a0a0a; border-color: var(--neon); font-weight: 600; }
.btn-save:hover:not(:disabled) { background: var(--text); border-color: var(--text); } .btn-save:hover:not(:disabled) { background: var(--text); border-color: var(--text); }
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "ecobot", "name": "d4rkbot",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ecobot", "name": "d4rkbot",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "ecobot", "name": "d4rkbot",
"version": "0.1.0", "version": "0.1.0",
"description": "Discord-Bot + Webinterface für die EcoGame-Community (Commit-Feed, Devlog-Archiv)", "description": "D4RKST3R Community-Bot: Devlogs, Commits, Releases, Bug-Reports + API für eigene Dienste",
"author": "D4rkst3r", "author": "D4rkst3r",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
// Release-Ankündigungen: großes Embed in den konfigurierten Kanal (Setup-Seite) // Release-Ankündigungen: großes Embed in den konfigurierten Kanal (Setup-Seite)
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
import { releaseChannelId } from '../runtime-settings.js'; import { releaseChannelId, publicUrl } from '../runtime-settings.js';
import { config } from '../config.js'; import { config } from '../config.js';
const BRAND_ORANGE = 0xff4d00; const BRAND_ORANGE = 0xff4d00;
@@ -36,7 +36,7 @@ export async function postReleaseEmbed(client, release) {
const buttons = new ActionRowBuilder().addComponents( const buttons = new ActionRowBuilder().addComponents(
new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Changelog').setEmoji('📋') new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Changelog').setEmoji('📋')
.setURL(`${config.publicUrl}/changelog`) .setURL(`${publicUrl()}/changelog`)
); );
if (release.url) { if (release.url) {
buttons.addComponents( buttons.addComponents(
+1 -1
View File
@@ -34,7 +34,7 @@ export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALE
method: 'GET', method: 'GET',
redirect: 'follow', redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT_MS), signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': 'ecobot-watchdog/1.0' }, headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' },
}); });
ok = res.status < 500; ok = res.status < 500;
detail = `HTTP ${res.status}`; detail = `HTTP ${res.status}`;
+2 -2
View File
@@ -2,7 +2,7 @@
// eine Zusammenfassung der Woche in den Devlog-Kanal. // eine Zusammenfassung der Woche in den Devlog-Kanal.
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { weeklyStats, getSetting, setSetting } from '../db.js'; import { weeklyStats, getSetting, setSetting } from '../db.js';
import { devlogChannelId } from '../runtime-settings.js'; import { devlogChannelId, publicUrl } from '../runtime-settings.js';
import { config } from '../config.js'; import { config } from '../config.js';
const BRAND_YELLOW = 0xf5c518; const BRAND_YELLOW = 0xf5c518;
@@ -48,7 +48,7 @@ export async function postWeeklyRecap(client) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW) .setColor(BRAND_YELLOW)
.setTitle('📊 Wochen-Rückblick') .setTitle('📊 Wochen-Rückblick')
.setURL(`${config.publicUrl}/devlogs`) .setURL(`${publicUrl()}/devlogs`)
.setDescription( .setDescription(
`\`\`\`\n${buildBars(stats.perDay)}\n\`\`\`` `\`\`\`\n${buildBars(stats.perDay)}\n\`\`\``
) )
+51
View File
@@ -1,5 +1,6 @@
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt // SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import crypto from 'node:crypto';
import { mkdirSync, statSync } from 'node:fs'; import { mkdirSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { config } from './config.js'; import { config } from './config.js';
@@ -113,6 +114,56 @@ export function takeBugReport(repo, issueNumber) {
return row ?? null; return row ?? null;
} }
// API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT
);
`);
const insertApiKey = db.prepare(
'INSERT INTO api_keys (name, key_hash, scopes) VALUES (?, ?, ?)'
);
const findApiKeyStmt = db.prepare('SELECT * FROM api_keys WHERE key_hash = ?');
const touchApiKeyStmt = db.prepare(
`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`
);
const listApiKeysStmt = db.prepare(
'SELECT id, name, scopes, created_at, last_used_at FROM api_keys ORDER BY id'
);
const deleteApiKeyStmt = db.prepare('DELETE FROM api_keys WHERE id = ?');
const hashKey = (key) => crypto.createHash('sha256').update(key).digest('hex');
/** Neuen Key erzeugen — der Klartext-Key wird nur einmal zurückgegeben! */
export function createApiKey(name, scopes) {
const key = `d4rk_${crypto.randomBytes(24).toString('hex')}`;
const info = insertApiKey.run(name, hashKey(key), scopes.join(','));
return { id: info.lastInsertRowid, key };
}
/** Key prüfen: gibt { id, name, scopes: [] } zurück oder null; aktualisiert last_used */
export function verifyApiKey(key) {
if (!key?.startsWith('d4rk_')) return null;
const row = findApiKeyStmt.get(hashKey(key));
if (!row) return null;
touchApiKeyStmt.run(row.id);
return { id: row.id, name: row.name, scopes: row.scopes.split(',') };
}
export function listApiKeys() {
return listApiKeysStmt.all();
}
export function deleteApiKey(id) {
return deleteApiKeyStmt.run(id).changes > 0;
}
// Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults // Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?'); const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?');
+3 -2
View File
@@ -1,5 +1,6 @@
// Minimaler Gitea-API-Client (Token-Auth) — für /bug → Issues // Minimaler Gitea-API-Client (Token-Auth) — für /bug → Issues
import { config } from './config.js'; import { config } from './config.js';
import { giteaUrl } from './runtime-settings.js';
function headers(extra = {}) { function headers(extra = {}) {
return { return {
@@ -11,7 +12,7 @@ function headers(extra = {}) {
/** Issue anlegen; gibt { number, html_url } zurück */ /** Issue anlegen; gibt { number, html_url } zurück */
export async function createIssue(repo, title, body) { export async function createIssue(repo, title, body) {
const res = await fetch(`${config.giteaUrl}/api/v1/repos/${repo}/issues`, { const res = await fetch(`${giteaUrl()}/api/v1/repos/${repo}/issues`, {
method: 'POST', method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }), headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ title, body }), body: JSON.stringify({ title, body }),
@@ -28,7 +29,7 @@ export async function uploadIssueAsset(repo, issueNumber, filename, buffer) {
const form = new FormData(); const form = new FormData();
form.append('attachment', new Blob([buffer]), filename); form.append('attachment', new Blob([buffer]), filename);
const res = await fetch( const res = await fetch(
`${config.giteaUrl}/api/v1/repos/${repo}/issues/${issueNumber}/assets?name=${encodeURIComponent(filename)}`, `${giteaUrl()}/api/v1/repos/${repo}/issues/${issueNumber}/assets?name=${encodeURIComponent(filename)}`,
{ method: 'POST', headers: headers(), body: form } { method: 'POST', headers: headers(), body: form }
); );
if (!res.ok) { if (!res.ok) {
+10
View File
@@ -11,6 +11,16 @@ export function devlogChannelId() {
return getSetting('devlog_channel_id') || config.devlogChannelId; return getSetting('devlog_channel_id') || config.devlogChannelId;
} }
/** Öffentliche Basis-URL (Buttons, RSS, OAuth-Redirect) */
export function publicUrl() {
return (getSetting('public_url') || config.publicUrl).replace(/\/$/, '');
}
/** Gitea-Basis-URL (API für /bug) */
export function giteaUrl() {
return (getSetting('gitea_url') || config.giteaUrl).replace(/\/$/, '');
}
/** Release-Ankündigungs-Kanal — nur per Settings-Seite, leer = Feature aus */ /** Release-Ankündigungs-Kanal — nur per Settings-Seite, leer = Feature aus */
export function releaseChannelId() { export function releaseChannelId() {
return getSetting('release_channel_id') || null; return getSetting('release_channel_id') || null;
+132
View File
@@ -0,0 +1,132 @@
// Öffentliche v1-API für eigene Skripte/Dienste (devlog.py, Platform, FiveM, CI …)
// Auth: Authorization: Bearer d4rk_<key> — Keys + Scopes über die Setup-Seite.
import { verifyApiKey, archiveStats } from '../db.js';
/** Bearer-Key prüfen; bei Erfolg Key-Info zurückgeben, sonst Fehler-Antwort senden */
function requireScope(request, reply, scope) {
const header = request.headers.authorization ?? '';
const key = header.startsWith('Bearer ') ? header.slice(7).trim() : null;
const info = key ? verifyApiKey(key) : null;
if (!info) {
reply.code(401).send({ error: 'invalid api key' });
return null;
}
if (!info.scopes.includes(scope) && !info.scopes.includes('*')) {
reply.code(403).send({ error: `scope '${scope}' required` });
return null;
}
request.log.info(`[api-v1] ${info.name}${request.method} ${request.url}`);
return info;
}
/** Nur erlaubte Embed-Felder durchreichen (kein Blind-Passthrough) */
function sanitizeEmbed(raw) {
if (!raw || typeof raw !== 'object') return null;
const embed = {};
for (const key of ['title', 'description', 'url', 'color', 'timestamp']) {
if (raw[key] !== undefined) embed[key] = raw[key];
}
if (raw.footer?.text) embed.footer = { text: String(raw.footer.text) };
if (raw.image?.url) embed.image = { url: String(raw.image.url) };
if (raw.thumbnail?.url) embed.thumbnail = { url: String(raw.thumbnail.url) };
if (Array.isArray(raw.fields)) {
embed.fields = raw.fields.slice(0, 25).map((f) => ({
name: String(f.name ?? ''), value: String(f.value ?? ''), inline: Boolean(f.inline),
}));
}
return Object.keys(embed).length > 0 ? embed : null;
}
export function registerApiV1(app, client) {
// Nachricht/Embed in einen Kanal posten
app.post('/api/v1/message', async (request, reply) => {
if (!requireScope(request, reply, 'message')) return;
const { channel_id: channelId, content, embed } = request.body ?? {};
const cleanEmbed = sanitizeEmbed(embed);
if (!channelId || (!content && !cleanEmbed)) {
return reply.code(400).send({ error: 'channel_id und content oder embed nötig' });
}
const channel = await client.channels.fetch(String(channelId)).catch(() => null);
if (!channel?.isTextBased()) {
return reply.code(404).send({ error: 'Kanal nicht gefunden' });
}
const message = await channel.send({
...(content ? { content: String(content).slice(0, 2000) } : {}),
...(cleanEmbed ? { embeds: [cleanEmbed] } : {}),
});
return { ok: true, message_id: message.id };
});
// Direktnachricht an einen User
app.post('/api/v1/dm', async (request, reply) => {
if (!requireScope(request, reply, 'dm')) return;
const { user_id: userId, content } = request.body ?? {};
if (!userId || !content) {
return reply.code(400).send({ error: 'user_id und content nötig' });
}
const user = await client.users.fetch(String(userId)).catch(() => null);
if (!user) return reply.code(404).send({ error: 'User nicht gefunden' });
try {
await user.send(String(content).slice(0, 2000));
return { ok: true };
} catch {
return reply.code(502).send({ error: 'DM nicht zustellbar (DMs deaktiviert?)' });
}
});
// Rolle geben/nehmen (z. B. Shop-Kauf → Kunden-Rolle)
app.post('/api/v1/roles', async (request, reply) => {
if (!requireScope(request, reply, 'roles')) return;
const { user_id: userId, role_id: roleId, action } = request.body ?? {};
if (!userId || !roleId || !['add', 'remove'].includes(action)) {
return reply.code(400).send({ error: "user_id, role_id und action ('add'|'remove') nötig" });
}
const guild = [...client.guilds.cache.values()].find((g) => g.roles.cache.has(String(roleId)));
if (!guild) return reply.code(404).send({ error: 'Rolle nicht gefunden' });
const member = await guild.members.fetch(String(userId)).catch(() => null);
if (!member) return reply.code(404).send({ error: 'User nicht auf dem Server' });
try {
if (action === 'add') await member.roles.add(String(roleId));
else await member.roles.remove(String(roleId));
return { ok: true, action, user_id: userId, role_id: roleId };
} catch {
return reply.code(502).send({ error: 'Rolle nicht änderbar (Rechte/Rollen-Reihenfolge prüfen)' });
}
});
// Member-Info (für Login-/Berechtigungs-Checks externer Dienste)
app.get('/api/v1/member/:id', async (request, reply) => {
if (!requireScope(request, reply, 'read')) return;
for (const guild of client.guilds.cache.values()) {
const member = await guild.members.fetch(String(request.params.id)).catch(() => null);
if (member) {
return {
id: member.id,
username: member.user?.username ?? null,
display_name: member.displayName ?? null,
guild: guild.name,
joined_at: member.joinedAt?.toISOString() ?? null,
roles: member.roles.cache
.filter((r) => r.id !== guild.id)
.map((r) => ({ id: r.id, name: r.name })),
};
}
}
return reply.code(404).send({ error: 'nicht auf dem Server' });
});
// Statistiken (Dashboards)
app.get('/api/v1/stats', async (request, reply) => {
if (!requireScope(request, reply, 'read')) return;
return {
...archiveStats(),
guilds: client.guilds.cache.size,
uptime_seconds: Math.floor(process.uptime()),
};
});
}
+48 -4
View File
@@ -1,9 +1,12 @@
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin // REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats, getSetting, setSetting } from '../db.js'; import {
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
} from '../db.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js'; import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId } from '../runtime-settings.js'; import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl } from '../runtime-settings.js';
import { getSessionUser, isAdmin } from './auth.js'; import { getSessionUser, isAdmin } from './auth.js';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -62,7 +65,7 @@ export function registerApiRoutes(app, client) {
const title = `Devlog ${dateFmt.format(new Date(d.posted_at))}${project ? `${project}` : ''}`; const title = `Devlog ${dateFmt.format(new Date(d.posted_at))}${project ? `${project}` : ''}`;
return ` <item> return ` <item>
<title>${esc(title)}</title> <title>${esc(title)}</title>
<link>${config.publicUrl}/devlogs</link> <link>${publicUrl()}/devlogs</link>
<guid isPermaLink="false">${esc(d.message_id)}</guid> <guid isPermaLink="false">${esc(d.message_id)}</guid>
<pubDate>${new Date(d.posted_at).toUTCString()}</pubDate> <pubDate>${new Date(d.posted_at).toUTCString()}</pubDate>
<description>${esc(d.content)}</description> <description>${esc(d.content)}</description>
@@ -74,7 +77,7 @@ export function registerApiRoutes(app, client) {
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
<title>D4RKST3R // DEVLOG</title> <title>D4RKST3R // DEVLOG</title>
<link>${config.publicUrl}/devlogs</link> <link>${publicUrl()}/devlogs</link>
<description>Entwicklungs-Updates, automatisch archiviert.</description> <description>Entwicklungs-Updates, automatisch archiviert.</description>
<language>de</language> <language>de</language>
${rssItems} ${rssItems}
@@ -142,6 +145,8 @@ ${rssItems}
devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0', devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0',
bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame', bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame',
watchdog_urls: getSetting('watchdog_urls') ?? '', watchdog_urls: getSetting('watchdog_urls') ?? '',
public_url: publicUrl(),
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
}; };
} }
@@ -209,11 +214,50 @@ ${rssItems}
setSetting(key, String(body[key]).trim()); setSetting(key, String(body[key]).trim());
} }
} }
// URLs: müssen mit http(s) beginnen
for (const key of ['public_url', 'gitea_url']) {
if (body[key] === undefined) continue;
const value = String(body[key]).trim().replace(/\/$/, '');
if (!/^https?:\/\/.+/.test(value)) {
return reply.code(400).send({ error: `${key}: muss mit http(s):// beginnen` });
}
setSetting(key, value);
}
request.log.info('Settings per Web-UI aktualisiert'); request.log.info('Settings per Web-UI aktualisiert');
return { ok: true, settings: currentSettings() }; return { ok: true, settings: currentSettings() };
}); });
// --- API-Keys (Admin) — für /api/v1/* ---
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
app.get('/api/apikeys', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { keys: listApiKeys() };
});
app.post('/api/apikeys', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const name = String(request.body?.name ?? '').trim();
const scopes = (request.body?.scopes ?? []).filter((s) => VALID_SCOPES.includes(s));
if (!name || scopes.length === 0) {
return reply.code(400).send({ error: 'name und mindestens ein Scope nötig' });
}
const { id, key } = createApiKey(name, scopes);
request.log.info(`API-Key '${name}' erstellt (Scopes: ${scopes.join(',')})`);
// Klartext-Key nur in dieser einen Antwort!
return { id, key, name, scopes };
});
app.delete('/api/apikeys/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const deleted = deleteApiKey(Number(request.params.id));
request.log.info(`API-Key ${request.params.id} widerrufen: ${deleted}`);
return { deleted };
});
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog) // Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
app.post('/api/settings/test/:target', async (request, reply) => { app.post('/api/settings/test/:target', async (request, reply) => {
if (requireAdmin(request, reply)) return; if (requireAdmin(request, reply)) return;
+7 -5
View File
@@ -1,12 +1,14 @@
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie // Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { config } from '../config.js'; import { config } from '../config.js';
import { publicUrl } from '../runtime-settings.js';
const DISCORD_API = 'https://discord.com/api/v10'; const DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'ecobot_session'; const SESSION_COOKIE = 'd4rkbot_session';
const STATE_COOKIE = 'ecobot_oauth_state'; const STATE_COOKIE = 'd4rkbot_oauth_state';
const redirectUri = `${config.publicUrl}/auth/callback`; // Dynamisch, damit die Setup-Seite die URL ändern kann (Redirect auch im Dev-Portal eintragen!)
const redirectUri = () => `${publicUrl()}/auth/callback`;
/** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */ /** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */
export function getSessionUser(request) { export function getSessionUser(request) {
@@ -32,7 +34,7 @@ export function registerAuthRoutes(app) {
const state = crypto.randomBytes(16).toString('hex'); const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: config.discordClientId, client_id: config.discordClientId,
redirect_uri: redirectUri, redirect_uri: redirectUri(),
response_type: 'code', response_type: 'code',
scope: 'identify', scope: 'identify',
state, state,
@@ -62,7 +64,7 @@ export function registerAuthRoutes(app) {
client_secret: config.discordClientSecret, client_secret: config.discordClientSecret,
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
redirect_uri: redirectUri, redirect_uri: redirectUri(),
}), }),
}); });
if (!tokenRes.ok) { if (!tokenRes.ok) {
+2 -2
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path';
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { saveDevlog } from '../db.js'; import { saveDevlog } from '../db.js';
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled } from '../runtime-settings.js'; import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled, publicUrl } from '../runtime-settings.js';
import { imagesDir } from '../bot/devlog-archive.js'; import { imagesDir } from '../bot/devlog-archive.js';
const BRAND_YELLOW = 0xf5c518; const BRAND_YELLOW = 0xf5c518;
@@ -90,7 +90,7 @@ export function registerDevlogEndpoint(app, client) {
} }
// Gebrandetes Embed; bis zu 4 Bilder als Grid (Discord gruppiert Embeds mit gleicher URL) // Gebrandetes Embed; bis zu 4 Bilder als Grid (Discord gruppiert Embeds mit gleicher URL)
const groupUrl = `${config.publicUrl}/devlogs`; const groupUrl = `${publicUrl()}/devlogs`;
const dateStr = new Intl.DateTimeFormat('de-DE', { const dateStr = new Intl.DateTimeFormat('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
}).format(new Date()); }).format(new Date());
+2
View File
@@ -14,6 +14,7 @@ import { postPushEmbed } from '../bot/commit-feed.js';
import { postReleaseEmbed } from '../bot/release-feed.js'; import { postReleaseEmbed } from '../bot/release-feed.js';
import { registerAuthRoutes } from './auth.js'; import { registerAuthRoutes } from './auth.js';
import { registerApiRoutes } from './api.js'; import { registerApiRoutes } from './api.js';
import { registerApiV1 } from './api-v1.js';
import { registerDevlogEndpoint } from './devlog-endpoint.js'; import { registerDevlogEndpoint } from './devlog-endpoint.js';
import { imagesDir } from '../bot/devlog-archive.js'; import { imagesDir } from '../bot/devlog-archive.js';
@@ -62,6 +63,7 @@ export async function startWebServer(client) {
registerAuthRoutes(app); registerAuthRoutes(app);
registerApiRoutes(app, client); registerApiRoutes(app, client);
registerApiV1(app, client);
registerDevlogEndpoint(app, client); registerDevlogEndpoint(app, client);
// Healthcheck (für Portainer/NPM) // Healthcheck (für Portainer/NPM)