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
+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)