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
+2 -2
View File
@@ -1,6 +1,6 @@
// Release-Ankündigungen: großes Embed in den konfigurierten Kanal (Setup-Seite)
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';
const BRAND_ORANGE = 0xff4d00;
@@ -36,7 +36,7 @@ export async function postReleaseEmbed(client, release) {
const buttons = new ActionRowBuilder().addComponents(
new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Changelog').setEmoji('📋')
.setURL(`${config.publicUrl}/changelog`)
.setURL(`${publicUrl()}/changelog`)
);
if (release.url) {
buttons.addComponents(
+1 -1
View File
@@ -34,7 +34,7 @@ export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALE
method: 'GET',
redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': 'ecobot-watchdog/1.0' },
headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' },
});
ok = res.status < 500;
detail = `HTTP ${res.status}`;
+2 -2
View File
@@ -2,7 +2,7 @@
// eine Zusammenfassung der Woche in den Devlog-Kanal.
import { EmbedBuilder } from 'discord.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';
const BRAND_YELLOW = 0xf5c518;
@@ -48,7 +48,7 @@ export async function postWeeklyRecap(client) {
const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setTitle('📊 Wochen-Rückblick')
.setURL(`${config.publicUrl}/devlogs`)
.setURL(`${publicUrl()}/devlogs`)
.setDescription(
`\`\`\`\n${buildBars(stats.perDay)}\n\`\`\``
)
+51
View File
@@ -1,5 +1,6 @@
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
import Database from 'better-sqlite3';
import crypto from 'node:crypto';
import { mkdirSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { config } from './config.js';
@@ -113,6 +114,56 @@ export function takeBugReport(repo, issueNumber) {
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
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 = ?');
+3 -2
View File
@@ -1,5 +1,6 @@
// Minimaler Gitea-API-Client (Token-Auth) — für /bug → Issues
import { config } from './config.js';
import { giteaUrl } from './runtime-settings.js';
function headers(extra = {}) {
return {
@@ -11,7 +12,7 @@ function headers(extra = {}) {
/** Issue anlegen; gibt { number, html_url } zurück */
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',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ title, body }),
@@ -28,7 +29,7 @@ export async function uploadIssueAsset(repo, issueNumber, filename, buffer) {
const form = new FormData();
form.append('attachment', new Blob([buffer]), filename);
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 }
);
if (!res.ok) {
+10
View File
@@ -11,6 +11,16 @@ export function 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 */
export function releaseChannelId() {
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
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 { 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';
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}` : ''}`;
return ` <item>
<title>${esc(title)}</title>
<link>${config.publicUrl}/devlogs</link>
<link>${publicUrl()}/devlogs</link>
<guid isPermaLink="false">${esc(d.message_id)}</guid>
<pubDate>${new Date(d.posted_at).toUTCString()}</pubDate>
<description>${esc(d.content)}</description>
@@ -74,7 +77,7 @@ export function registerApiRoutes(app, client) {
<rss version="2.0">
<channel>
<title>D4RKST3R // DEVLOG</title>
<link>${config.publicUrl}/devlogs</link>
<link>${publicUrl()}/devlogs</link>
<description>Entwicklungs-Updates, automatisch archiviert.</description>
<language>de</language>
${rssItems}
@@ -142,6 +145,8 @@ ${rssItems}
devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0',
bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame',
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());
}
}
// 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');
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)
app.post('/api/settings/test/:target', async (request, reply) => {
if (requireAdmin(request, reply)) return;
+7 -5
View File
@@ -1,12 +1,14 @@
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
import crypto from 'node:crypto';
import { config } from '../config.js';
import { publicUrl } from '../runtime-settings.js';
const DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'ecobot_session';
const STATE_COOKIE = 'ecobot_oauth_state';
const SESSION_COOKIE = 'd4rkbot_session';
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) */
export function getSessionUser(request) {
@@ -32,7 +34,7 @@ export function registerAuthRoutes(app) {
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
client_id: config.discordClientId,
redirect_uri: redirectUri,
redirect_uri: redirectUri(),
response_type: 'code',
scope: 'identify',
state,
@@ -62,7 +64,7 @@ export function registerAuthRoutes(app) {
client_secret: config.discordClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
redirect_uri: redirectUri(),
}),
});
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 { config } from '../config.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';
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)
const groupUrl = `${config.publicUrl}/devlogs`;
const groupUrl = `${publicUrl()}/devlogs`;
const dateStr = new Intl.DateTimeFormat('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
}).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 { registerAuthRoutes } from './auth.js';
import { registerApiRoutes } from './api.js';
import { registerApiV1 } from './api-v1.js';
import { registerDevlogEndpoint } from './devlog-endpoint.js';
import { imagesDir } from '../bot/devlog-archive.js';
@@ -62,6 +63,7 @@ export async function startWebServer(client) {
registerAuthRoutes(app);
registerApiRoutes(app, client);
registerApiV1(app, client);
registerDevlogEndpoint(app, client);
// Healthcheck (für Portainer/NPM)