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
+114 -1
View File
@@ -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 }) {
</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 */}
<div className="settings-section">
<h2 className="settings-title">// Kanäle</h2>
@@ -227,6 +282,64 @@ export default function Settings({ me }) {
</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 */}
<div className="settings-section">
<h2 className="settings-title">// Commit-Feed</h2>