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:
@@ -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
@@ -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:
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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,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\`\`\``
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user