diff --git a/README.md b/README.md index f163dd9..bdc8610 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# EcoBot — D4RKST3R // DEVLOG +# d4rkbot — D4RKST3R // COMMUNITY BOT Discord-Bot + Webinterface: Devlog-Tagebuch, Commit-Feed, Changelog und 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) -1. **Stacks → Add stack** → Name `ecobot` → Build method **Repository** - - URL `https://git.d4rkst3r.de/D4rkst3r/ecobot`, Reference `refs/heads/main`, +1. **Stacks → Add stack** → Name `ecobot` (Bestands-Stack behalten! Der Name steckt im Volume-Prefix) → Build method **Repository** + - URL `https://git.d4rkst3r.de/D4rkst3r/d4rkbot`, Reference `refs/heads/main`, Compose path `docker-compose.yml` - Privates Repo: Authentication mit Gitea-Token (Scope `read:repository`) 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: ``` -git clone git@gitea:D4rkst3r/ecobot.git && cd ecobot +git clone git@gitea:D4rkst3r/d4rkbot.git && cd d4rkbot cp .env.example .env # Werte eintragen 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_ +``` + +| 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 | Route | Auth | Zweck | @@ -179,6 +212,8 @@ node tools/test-webhook.mjs | `GET /api/releases?page=` | — | Changelog | | `GET /feed.xml` | — | RSS | | `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 /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | | `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 ``` -ecobot/ +d4rkbot/ ├── src/ │ ├── index.js # Start: Bot, Webserver, Wochen-Rückblick, Watchdog │ ├── config.js # Env-Konfiguration mit Validierung diff --git a/docker-compose.yml b/docker-compose.yml index 806ed00..8d228bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: - ecobot: + d4rkbot: build: . - image: ecobot:latest - container_name: ecobot + image: d4rkbot:latest + container_name: d4rkbot restart: unless-stopped # Werte kommen per Interpolation: lokal aus der .env im Projektordner (liest # docker compose automatisch), in Portainer aus den Stack-Environment-Variables @@ -29,7 +31,8 @@ services: ports: - "3080:3080" 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 volumes: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d1fa3a..0dd6ad8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ecobot-frontend", + "name": "d4rkbot-frontend", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ecobot-frontend", + "name": "d4rkbot-frontend", "version": "0.1.0", "dependencies": { "@fontsource/barlow-condensed": "^5.3.0", diff --git a/frontend/package.json b/frontend/package.json index e9e0bf4..494ec7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "ecobot-frontend", + "name": "d4rkbot-frontend", "private": true, "version": "0.1.0", "type": "module", diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 0a6d6fa..2397c1f 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,5 +1,12 @@ 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" */ function fmtUptime(s) { @@ -19,6 +26,11 @@ export default function Settings({ me }) { const [form, setForm] = useState(null); const [error, setError] = 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(() => { if (!me.admin) return; @@ -28,8 +40,28 @@ export default function Settings({ me }) { setForm(d.settings); }) .catch((e) => setError(e.status)); + apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {}); }, [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) { setFeedback(msg); setTimeout(() => setFeedback(null), 3000); @@ -105,6 +137,29 @@ export default function Settings({ me }) { + {/* Allgemein */} +
+

// Allgemein

+
+ + setForm({ ...form, public_url: e.target.value })} + /> +
+
+ + setForm({ ...form, gitea_url: e.target.value })} + /> +
+
+ {/* Kanäle */}

// Kanäle

@@ -227,6 +282,64 @@ export default function Settings({ me }) {
+ {/* API-Keys */} +
+

// API-Keys (/api/v1)

+ + {keys.length > 0 && ( +
+ {keys.map((k) => ( +
+ {k.name} + {k.scopes} + + {k.last_used_at ? `zuletzt: ${k.last_used_at.slice(0, 16)}` : 'nie benutzt'} + + +
+ ))} +
+ )} + + {createdKey && ( +
+

Key {createdKey.name} erstellt — jetzt kopieren, er wird nie wieder angezeigt:

+ {createdKey.key} +
+ )} + +
+ +
+ setNewKeyName(e.target.value)} + /> + +
+
+ {API_SCOPES.map((s) => ( + + ))} +
+
+
+ {/* Commit-Feed */}

// Commit-Feed

diff --git a/frontend/src/style.css b/frontend/src/style.css index e78727f..2844bdb 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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 > 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; } .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); } diff --git a/package-lock.json b/package-lock.json index fee2e6a..02e7419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ecobot", + "name": "d4rkbot", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ecobot", + "name": "d4rkbot", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index f2de73b..c468780 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "ecobot", + "name": "d4rkbot", "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", "license": "MIT", "type": "module", diff --git a/src/bot/release-feed.js b/src/bot/release-feed.js index 59dfe61..eb19438 100644 --- a/src/bot/release-feed.js +++ b/src/bot/release-feed.js @@ -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( diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js index 37943a2..6a872be 100644 --- a/src/bot/watchdog.js +++ b/src/bot/watchdog.js @@ -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}`; diff --git a/src/bot/weekly-recap.js b/src/bot/weekly-recap.js index c6a507b..834d8fd 100644 --- a/src/bot/weekly-recap.js +++ b/src/bot/weekly-recap.js @@ -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\`\`\`` ) diff --git a/src/db.js b/src/db.js index 03583ae..5c45943 100644 --- a/src/db.js +++ b/src/db.js @@ -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 = ?'); diff --git a/src/gitea-api.js b/src/gitea-api.js index 81cfb36..3c55053 100644 --- a/src/gitea-api.js +++ b/src/gitea-api.js @@ -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) { diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 71bd2b3..fab272f 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -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; diff --git a/src/web/api-v1.js b/src/web/api-v1.js new file mode 100644 index 0000000..ef19361 --- /dev/null +++ b/src/web/api-v1.js @@ -0,0 +1,132 @@ +// Öffentliche v1-API für eigene Skripte/Dienste (devlog.py, Platform, FiveM, CI …) +// Auth: Authorization: Bearer d4rk_ — 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()), + }; + }); +} diff --git a/src/web/api.js b/src/web/api.js index 39b3c23..a5e8833 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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 ` ${esc(title)} - ${config.publicUrl}/devlogs + ${publicUrl()}/devlogs ${esc(d.message_id)} ${new Date(d.posted_at).toUTCString()} ${esc(d.content)} @@ -74,7 +77,7 @@ export function registerApiRoutes(app, client) { D4RKST3R // DEVLOG - ${config.publicUrl}/devlogs + ${publicUrl()}/devlogs Entwicklungs-Updates, automatisch archiviert. de ${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; diff --git a/src/web/auth.js b/src/web/auth.js index 44bde6b..d6f8280 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -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) { diff --git a/src/web/devlog-endpoint.js b/src/web/devlog-endpoint.js index eea816f..252e025 100644 --- a/src/web/devlog-endpoint.js +++ b/src/web/devlog-endpoint.js @@ -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()); diff --git a/src/web/server.js b/src/web/server.js index df2c0dc..d170265 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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)