Server-Tab (DiscordGSM-Stil), Team-Rechte und Bot-Profil-Beschreibung
- Game-Server: eigene Tabelle statt CSV-Setting (automatische Migration), eigener Setup-Tab mit Server-Builder (Name, Typ fivem/http, Query-URL, Anzeige-Adresse); pro Server ein Live-Embed (gruen/rot, Spieler-Balken, Map, Adresse als Code-Block), edit in place; Embed wird beim Server-Loeschen mit entfernt - Team-System: web_admins mit Bereichs-Scopes (content/community/rollen/bewerbungen/ server/settings/devlogs); Owner behaelt Brand/System/API-Keys/Team exklusiv; 32 Routen-Guards auf Scopes umgestellt, sensible PUT-Felder fuer Team gestrippt; Team-Tab (Owner) + Tab-Filterung im Frontend, /api/me liefert Scopes - Bot-Karte: Profil-Beschreibung (Ueber mich) via application.edit - Fix: settings-Tabelle wird vor der Gameserver-Migration angelegt (frische DBs crashten sonst beim Start) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,8 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite |
|
| 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite |
|
||||||
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
||||||
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
||||||
| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence |
|
| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Copy-Paste-Adresse); Typen FiveM + HTTP-Check; Spielerzahl in der Presence |
|
||||||
|
| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only |
|
||||||
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
|
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
|
||||||
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf |
|
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf |
|
||||||
| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite |
|
| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite |
|
||||||
@@ -69,7 +70,8 @@ Share Tech Mono, selbst gehostet).
|
|||||||
|
|
||||||
### Setup-Seite (`/settings`)
|
### Setup-Seite (`/settings`)
|
||||||
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community ·
|
Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community ·
|
||||||
Rollen · Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in
|
Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) —
|
||||||
|
Team-Mitglieder sehen nur die Tabs ihrer Bereiche. Alles zur Laufzeit änderbar — gespeichert in
|
||||||
SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
|
SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
|
||||||
|
|
||||||
- **Allgemein:** Öffentliche URL, Gitea-URL
|
- **Allgemein:** Öffentliche URL, Gitea-URL
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ function Particles() {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// Login-Status einmal beim Laden holen
|
// Login-Status einmal beim Laden holen
|
||||||
const [me, setMe] = useState({ user: null, admin: false, loading: true });
|
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet('/api/me')
|
apiGet('/api/me')
|
||||||
.then((data) => setMe({ ...data, loading: false }))
|
.then((data) => setMe({ scopes: [], ...data, loading: false }))
|
||||||
.catch(() => setMe({ user: null, admin: false, loading: false }));
|
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,7 +80,7 @@ export default function App() {
|
|||||||
<NavLink to="/events">Events</NavLink>
|
<NavLink to="/events">Events</NavLink>
|
||||||
<NavLink to="/changelog">Changelog</NavLink>
|
<NavLink to="/changelog">Changelog</NavLink>
|
||||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="auth">
|
<div className="auth">
|
||||||
|
|||||||
+219
-19
@@ -17,10 +17,38 @@ const TABS = [
|
|||||||
{ id: 'support', icon: '📬', label: 'Support' },
|
{ id: 'support', icon: '📬', label: 'Support' },
|
||||||
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
|
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
|
||||||
{ id: 'composer', icon: '📝', label: 'Composer' },
|
{ id: 'composer', icon: '📝', label: 'Composer' },
|
||||||
|
{ id: 'server', icon: '🎮', label: 'Server' },
|
||||||
{ id: 'system', icon: '⚙️', label: 'System' },
|
{ id: 'system', icon: '⚙️', label: 'System' },
|
||||||
{ id: 'api', icon: '🔑', label: 'API' },
|
{ id: 'api', icon: '🔑', label: 'API' },
|
||||||
|
{ id: 'team', icon: '👥', label: 'Team' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
||||||
|
const TAB_SCOPES = {
|
||||||
|
status: 'any',
|
||||||
|
brand: null,
|
||||||
|
feeds: 'settings',
|
||||||
|
community: 'community',
|
||||||
|
rollen: 'rollen',
|
||||||
|
support: 'settings',
|
||||||
|
bewerbungen: 'bewerbungen',
|
||||||
|
composer: 'content',
|
||||||
|
server: 'server',
|
||||||
|
system: null,
|
||||||
|
api: null,
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEAM_SCOPE_LABELS = {
|
||||||
|
content: 'Composer, Tags, Triggers & geplante Posts',
|
||||||
|
community: 'Playtester & Alpha-Keys',
|
||||||
|
rollen: 'Rollen-Menüs',
|
||||||
|
bewerbungen: 'Bewerbungen',
|
||||||
|
server: 'Game-Server',
|
||||||
|
settings: 'Kanäle & Feed-Einstellungen',
|
||||||
|
devlogs: 'Devlogs löschen',
|
||||||
|
};
|
||||||
|
|
||||||
/** Sekunden → "2d 4h 13m" */
|
/** Sekunden → "2d 4h 13m" */
|
||||||
function fmtUptime(s) {
|
function fmtUptime(s) {
|
||||||
const d = Math.floor(s / 86400);
|
const d = Math.floor(s / 86400);
|
||||||
@@ -68,6 +96,22 @@ export default function Settings({ me }) {
|
|||||||
// Triggers
|
// Triggers
|
||||||
const [triggers, setTriggers] = useState([]);
|
const [triggers, setTriggers] = useState([]);
|
||||||
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
||||||
|
// Game-Server
|
||||||
|
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '' };
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [serverDraft, setServerDraft] = useState(emptyServer);
|
||||||
|
// Bot-Beschreibung + Team
|
||||||
|
const [botDesc, setBotDesc] = useState('');
|
||||||
|
const [team, setTeam] = useState([]);
|
||||||
|
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
||||||
|
|
||||||
|
const canTab = (id) => {
|
||||||
|
if (me.admin) return true;
|
||||||
|
const need = TAB_SCOPES[id];
|
||||||
|
if (need === 'any') return (me.scopes?.length ?? 0) > 0;
|
||||||
|
return need ? me.scopes?.includes(need) : false;
|
||||||
|
};
|
||||||
|
const visibleTabs = TABS.filter((t) => canTab(t.id));
|
||||||
// Tags + geplante Posts
|
// Tags + geplante Posts
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||||||
@@ -83,7 +127,7 @@ export default function Settings({ me }) {
|
|||||||
const [createdKey, setCreatedKey] = useState(null);
|
const [createdKey, setCreatedKey] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!me.admin) return;
|
if (!me.admin && !(me.scopes?.length > 0)) return;
|
||||||
apiGet('/api/settings')
|
apiGet('/api/settings')
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
setData(d);
|
setData(d);
|
||||||
@@ -98,12 +142,64 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {});
|
apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {});
|
||||||
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
||||||
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||||||
}, [me.admin]);
|
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||||||
|
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
||||||
|
}, [me.admin, me.scopes?.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.status?.botName) setBotName(data.status.botName);
|
if (data?.status?.botName) setBotName(data.status.botName);
|
||||||
|
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id);
|
||||||
|
}, [me.admin, me.scopes]);
|
||||||
|
|
||||||
|
async function saveTeam() {
|
||||||
|
if (!teamDraft.user_id.trim() || teamDraft.scopes.length === 0) {
|
||||||
|
flash('✗ User-ID und mindestens ein Bereich nötig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiPut('/api/webadmins', teamDraft);
|
||||||
|
setTeam(res.admins);
|
||||||
|
setTeamDraft({ user_id: '', scopes: [] });
|
||||||
|
flash('✓ Team-Mitglied gespeichert');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Fehlgeschlagen — User-ID prüfen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTeam(userId) {
|
||||||
|
if (!window.confirm('Team-Mitglied entfernen? Der Web-Zugriff erlischt sofort.')) return;
|
||||||
|
const res = await apiDelete(`/api/webadmins/${userId}`).catch(() => null);
|
||||||
|
if (res) setTeam(res.admins);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveServer() {
|
||||||
|
if (!serverDraft.name.trim() || !serverDraft.query_url.trim()) {
|
||||||
|
flash('✗ Name und Query-URL nötig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (serverDraft.id) await apiPut(`/api/gameservers/${serverDraft.id}`, serverDraft);
|
||||||
|
else await apiPost('/api/gameservers', serverDraft);
|
||||||
|
setServers((await apiGet('/api/gameservers')).servers);
|
||||||
|
setServerDraft(emptyServer);
|
||||||
|
flash('✓ Server gespeichert — Embed erscheint beim nächsten Tick');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Speichern fehlgeschlagen (URL mit http/https?)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeServer(id) {
|
||||||
|
if (!window.confirm('Server entfernen? Das Status-Embed wird mit gelöscht.')) return;
|
||||||
|
await apiDelete(`/api/gameservers/${id}`).catch(() => {});
|
||||||
|
setServers((await apiGet('/api/gameservers')).servers);
|
||||||
|
if (serverDraft.id === id) setServerDraft(emptyServer);
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadAlphaKeys() {
|
async function uploadAlphaKeys() {
|
||||||
if (!keyUpload.trim()) return;
|
if (!keyUpload.trim()) return;
|
||||||
try {
|
try {
|
||||||
@@ -190,7 +286,7 @@ export default function Settings({ me }) {
|
|||||||
|
|
||||||
async function saveBrand() {
|
async function saveBrand() {
|
||||||
try {
|
try {
|
||||||
const body = { ...form, bot_name: botName };
|
const body = { ...form, bot_name: botName, bot_description: botDesc };
|
||||||
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
|
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
|
||||||
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||||||
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||||||
@@ -377,7 +473,7 @@ export default function Settings({ me }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||||||
if (!me.user || !me.admin) {
|
if (!me.user || (!me.admin && !(me.scopes?.length > 0))) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{header}
|
{header}
|
||||||
@@ -448,6 +544,10 @@ export default function Settings({ me }) {
|
|||||||
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
|
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
|
||||||
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
|
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Beschreibung <span className="field-hint">„Über mich" im Bot-Profil (max 400 Zeichen)</span></label>
|
||||||
|
<textarea rows={3} maxLength={400} placeholder="z. B. Community-Bot für EcoGame — Devlogs, Level, Tickets & mehr. 🤖" value={botDesc} onChange={(e) => setBotDesc(e.target.value)} />
|
||||||
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Bot-Status</label>
|
<label>Bot-Status</label>
|
||||||
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
|
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
|
||||||
@@ -1085,20 +1185,6 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
|
||||||
<h2 className="settings-title">// Game-Server</h2>
|
|
||||||
<div className="field">
|
|
||||||
<label>Status-Kanal <span className="field-hint">persistentes Status-Embed, alle 2 min</span></label>
|
|
||||||
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
|
|
||||||
<option value="">— deaktiviert —</option>
|
|
||||||
{channelOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>Server <span className="field-hint">kommagetrennt Name=URL — FiveM-kompatibel (/dynamic.json)</span></label>
|
|
||||||
<input type="text" placeholder="z. B. FiveM=http://1.2.3.4:30120" value={form.gameservers} onChange={(e) => setForm({ ...form, gameservers: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Events, Social & Voice</h2>
|
<h2 className="settings-title">// Events, Social & Voice</h2>
|
||||||
@@ -1216,6 +1302,118 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabServer = (
|
||||||
|
<>
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Game-Server</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Jeder Server bekommt sein eigenes Live-Status-Embed im Status-Kanal (🟢/🔴,
|
||||||
|
Spieler-Balken, Map, Adresse) — aktualisiert alle 2 Minuten, DiscordGSM-Style.
|
||||||
|
</p>
|
||||||
|
<div className="field">
|
||||||
|
<label>Status-Kanal</label>
|
||||||
|
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="settings-actions" style={{ marginBottom: '1.4rem' }}>
|
||||||
|
<button className="btn btn-save" onClick={save}>Kanal speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{servers.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<div className="key-row" key={s.id}>
|
||||||
|
<span className="key-name">{s.name}</span>
|
||||||
|
<span className="key-scopes">{s.type}</span>
|
||||||
|
<span className="key-used" title={s.query_url}>{s.query_url}</span>
|
||||||
|
<button className="btn btn-mini" onClick={() => setServerDraft({
|
||||||
|
id: s.id, name: s.name, type: s.type, query_url: s.query_url, address: s.address ?? '',
|
||||||
|
})}>Bearbeiten</button>
|
||||||
|
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{serverDraft.id ? `Server #${serverDraft.id} bearbeiten` : 'Neuer Server'}</label>
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} />
|
||||||
|
<select style={{ flex: 'none', width: '8rem' }} value={serverDraft.type} onChange={(e) => setServerDraft({ ...serverDraft, type: e.target.value })}>
|
||||||
|
<option value="fivem">FiveM</option>
|
||||||
|
<option value="http">HTTP-Check</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Anzeige-Adresse <span className="field-hint">optional — steht als Copy-Paste-Block im Embed (z. B. connect play.d4rkst3r.de)</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
|
||||||
|
<button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
|
||||||
|
{serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabTeam = (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Team</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Gib Team-Mitgliedern gezielten Zugriff aufs Webinterface — sie loggen sich ganz
|
||||||
|
normal mit Discord ein und sehen nur ihre Bereiche. Brand, System, API-Keys
|
||||||
|
und diese Team-Verwaltung bleiben immer dir vorbehalten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{team.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
{team.map((t) => (
|
||||||
|
<div className="key-row" key={t.user_id}>
|
||||||
|
<span className="key-name">{t.username ?? t.user_id}</span>
|
||||||
|
<span className="key-scopes">{t.scopes}</span>
|
||||||
|
<button className="btn btn-mini" onClick={() => setTeamDraft({ user_id: t.user_id, scopes: t.scopes.split(',').filter(Boolean) })}>Bearbeiten</button>
|
||||||
|
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Discord-User-ID <span className="field-hint">Rechtsklick auf den User → „Benutzer-ID kopieren"</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" placeholder="z. B. 123456789012345678" value={teamDraft.user_id} onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })} />
|
||||||
|
<button className="btn btn-save" onClick={saveTeam}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Bereiche</label>
|
||||||
|
<div className="scope-row" style={{ flexDirection: 'column', gap: '.5rem', alignItems: 'flex-start' }}>
|
||||||
|
{Object.entries(TEAM_SCOPE_LABELS).map(([id, label]) => (
|
||||||
|
<label className="scope-pick" key={id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={teamDraft.scopes.includes(id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTeamDraft({
|
||||||
|
...teamDraft,
|
||||||
|
scopes: e.target.checked
|
||||||
|
? [...teamDraft.scopes, id]
|
||||||
|
: teamDraft.scopes.filter((s) => s !== id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{id} — {label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const content = {
|
const content = {
|
||||||
status: tabStatus,
|
status: tabStatus,
|
||||||
brand: tabBrand,
|
brand: tabBrand,
|
||||||
@@ -1225,8 +1423,10 @@ export default function Settings({ me }) {
|
|||||||
support: tabSupport,
|
support: tabSupport,
|
||||||
bewerbungen: tabBewerbungen,
|
bewerbungen: tabBewerbungen,
|
||||||
composer: tabComposer,
|
composer: tabComposer,
|
||||||
|
server: tabServer,
|
||||||
system: tabSystem,
|
system: tabSystem,
|
||||||
api: tabApi,
|
api: tabApi,
|
||||||
|
team: tabTeam,
|
||||||
}[tab];
|
}[tab];
|
||||||
|
|
||||||
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
|
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
|
||||||
@@ -1238,7 +1438,7 @@ export default function Settings({ me }) {
|
|||||||
<section className="content content-wide">
|
<section className="content content-wide">
|
||||||
<div className="settings-layout">
|
<div className="settings-layout">
|
||||||
<nav className="settings-nav">
|
<nav className="settings-nav">
|
||||||
{TABS.map((t) => (
|
{visibleTabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
|
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
|
||||||
|
|||||||
+58
-48
@@ -1,96 +1,106 @@
|
|||||||
// Game-Server-Monitor: pollt FiveM-kompatible HTTP-Endpoints (/dynamic.json)
|
// Game-Server-Monitor (DiscordGSM-Stil): pro Server ein eigenes Status-Embed
|
||||||
// und pflegt ein persistentes Status-Embed im Status-Kanal + Bot-Presence.
|
// im Status-Kanal (edit in place, grün/rot), alle 2 Minuten aktualisiert.
|
||||||
// Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…"
|
// Server werden im Setup-Tab "Server" verwaltet (Typ: fivem | http).
|
||||||
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
|
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
|
||||||
import { getSetting, setSetting } from '../db.js';
|
import { getSetting, listGameservers, setGameserverMessage } from '../db.js';
|
||||||
import { brandColor, brandFooter, botStatusText } from '../runtime-settings.js';
|
import { brandFooter, botStatusText } from '../runtime-settings.js';
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
|
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
|
||||||
const TIMEOUT_MS = 8000;
|
const TIMEOUT_MS = 8000;
|
||||||
|
const GREEN = 0x23a55a;
|
||||||
|
const RED = 0xf23f43;
|
||||||
|
|
||||||
function serverList() {
|
/** Einen Server abfragen — je nach Typ */
|
||||||
return (getSetting('gameservers') ?? '')
|
export async function queryServer(server) {
|
||||||
.split(',')
|
const base = { ...server, online: false, players: null, max: null, map: null };
|
||||||
.map((entry) => {
|
|
||||||
const [name, ...rest] = entry.split('=');
|
|
||||||
const url = rest.join('=').trim().replace(/\/$/, '');
|
|
||||||
return name?.trim() && /^https?:\/\//.test(url) ? { name: name.trim(), url } : null;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Einen Server abfragen (FiveM-Format: clients, sv_maxclients, hostname) */
|
|
||||||
async function queryServer(server) {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${server.url}/dynamic.json`, {
|
if (server.type === 'fivem') {
|
||||||
|
const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, {
|
||||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
headers: { 'User-Agent': 'd4rkbot-monitor/1.0' },
|
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return {
|
return {
|
||||||
...server,
|
...base,
|
||||||
online: true,
|
online: true,
|
||||||
players: Number(data.clients) || 0,
|
players: Number(data.clients) || 0,
|
||||||
max: Number(data.sv_maxclients) || 0,
|
max: Number(data.sv_maxclients) || 0,
|
||||||
map: data.mapname ?? null,
|
map: data.mapname ?? null,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
// Typ 'http': generischer Healthcheck (2xx/3xx/4xx = erreichbar)
|
||||||
|
const res = await fetch(server.query_url, {
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
redirect: 'follow',
|
||||||
|
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
|
||||||
|
});
|
||||||
|
return { ...base, online: res.status < 500 };
|
||||||
} catch {
|
} catch {
|
||||||
return { ...server, online: false, players: 0, max: 0, map: null };
|
return base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Status-Embed bauen */
|
/** Status-Embed für einen Server bauen (DiscordGSM-Look) */
|
||||||
function buildEmbed(results) {
|
export function buildServerEmbed(r) {
|
||||||
const lines = results.map((r) =>
|
const embed = new EmbedBuilder()
|
||||||
r.online
|
.setColor(r.online ? GREEN : RED)
|
||||||
? `🟢 **${r.name}** — ${r.players}/${r.max} Spieler${r.map ? ` · ${r.map}` : ''}`
|
.setTitle(`${r.online ? '🟢' : '🔴'} ${r.name}`)
|
||||||
: `🔴 **${r.name}** — offline`
|
.addFields({ name: 'Status', value: r.online ? '**Online**' : '**Offline**', inline: true })
|
||||||
);
|
.setFooter({ text: `${brandFooter('STATUS')} • alle 2 min` })
|
||||||
return new EmbedBuilder()
|
|
||||||
.setColor(brandColor())
|
|
||||||
.setTitle('🎮 Server-Status')
|
|
||||||
.setDescription(lines.join('\n'))
|
|
||||||
.setFooter({ text: `${brandFooter('STATUS')} • aktualisiert alle 2 min` })
|
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (r.online && r.players != null && r.max) {
|
||||||
|
const pct = Math.min(1, r.players / r.max);
|
||||||
|
const bar = '█'.repeat(Math.round(pct * 10)).padEnd(10, '░');
|
||||||
|
embed.addFields({ name: 'Spieler', value: `\`${bar}\` ${r.players}/${r.max}`, inline: true });
|
||||||
|
}
|
||||||
|
if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true });
|
||||||
|
if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` });
|
||||||
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
|
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
|
||||||
export async function monitorTick(client) {
|
export async function monitorTick(client) {
|
||||||
const servers = serverList();
|
const servers = listGameservers();
|
||||||
const channelId = getSetting('status_channel_id');
|
|
||||||
if (servers.length === 0) return;
|
if (servers.length === 0) return;
|
||||||
|
|
||||||
const results = await Promise.all(servers.map(queryServer));
|
const results = await Promise.all(servers.map(queryServer));
|
||||||
|
|
||||||
// Bot-Presence: Gesamtspielerzahl — nur wenn kein eigener Status (Brand-Tab) gesetzt ist
|
// Bot-Presence: Gesamtspielerzahl — nur wenn kein eigener Status (Brand-Tab) gesetzt ist
|
||||||
const online = results.filter((r) => r.online);
|
const withPlayers = results.filter((r) => r.online && r.players != null);
|
||||||
const totalPlayers = online.reduce((sum, r) => sum + r.players, 0);
|
const totalPlayers = withPlayers.reduce((sum, r) => sum + r.players, 0);
|
||||||
if (!botStatusText()) {
|
if (!botStatusText()) {
|
||||||
try {
|
try {
|
||||||
client.user?.setActivity?.(
|
client.user?.setActivity?.(
|
||||||
online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline',
|
results.some((r) => r.online) ? `${totalPlayers} Spieler online` : 'Server offline',
|
||||||
{ type: ActivityType.Watching }
|
{ type: ActivityType.Watching }
|
||||||
);
|
);
|
||||||
} catch { /* Presence ist nice-to-have */ }
|
} catch { /* Presence ist nice-to-have */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren)
|
// Pro Server ein Embed pflegen
|
||||||
|
const channelId = getSetting('status_channel_id');
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||||
if (!channel?.isTextBased()) return;
|
if (!channel?.isTextBased()) return;
|
||||||
|
|
||||||
const embed = buildEmbed(results);
|
for (const r of results) {
|
||||||
const messageId = getSetting('status_message_id');
|
const payload = { embeds: [buildServerEmbed(r)] };
|
||||||
if (messageId) {
|
try {
|
||||||
const existing = await channel.messages.fetch(messageId).catch(() => null);
|
if (r.message_id) {
|
||||||
|
const existing = await channel.messages.fetch(r.message_id).catch(() => null);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await existing.edit({ embeds: [embed] });
|
await existing.edit(payload);
|
||||||
return;
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = await channel.send(payload);
|
||||||
|
setGameserverMessage(r.id, message.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[monitor] Embed für ${r.name} fehlgeschlagen:`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const message = await channel.send({ embeds: [embed] });
|
|
||||||
setSetting('status_message_id', message.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startServerMonitor(client) {
|
export function startServerMonitor(client) {
|
||||||
|
|||||||
@@ -404,6 +404,72 @@ export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_
|
|||||||
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?');
|
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?');
|
||||||
export const isTempVoice = (id) => Boolean(isTempVoiceStmt.get(id));
|
export const isTempVoice = (id) => Boolean(isTempVoiceStmt.get(id));
|
||||||
|
|
||||||
|
// Team: Web-Zugriff mit Bereichs-Rechten (Owner = ADMIN_DISCORD_ID darf immer alles)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS web_admins (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT,
|
||||||
|
scopes TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const upsertWebAdmin = db.prepare(`
|
||||||
|
INSERT INTO web_admins (user_id, username, scopes) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, scopes = excluded.scopes
|
||||||
|
`);
|
||||||
|
const webAdminStmt = db.prepare('SELECT * FROM web_admins WHERE user_id = ?');
|
||||||
|
const listWebAdminsStmt = db.prepare('SELECT * FROM web_admins ORDER BY username');
|
||||||
|
const deleteWebAdminStmt = db.prepare('DELETE FROM web_admins WHERE user_id = ?');
|
||||||
|
export const saveWebAdmin = (userId, username, scopes) =>
|
||||||
|
upsertWebAdmin.run(userId, username, scopes.join(','));
|
||||||
|
export const webAdminScopes = (userId) =>
|
||||||
|
(webAdminStmt.get(userId)?.scopes ?? '').split(',').filter(Boolean);
|
||||||
|
export const listWebAdmins = () => listWebAdminsStmt.all();
|
||||||
|
export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0;
|
||||||
|
|
||||||
|
// Game-Server (DiscordGSM-Stil): strukturierte Server statt CSV-Setting
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS gameservers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'fivem',
|
||||||
|
query_url TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
message_id TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
// Einmalige Migration aus dem alten "gameservers"-CSV-Setting ("Name=URL, …")
|
||||||
|
{
|
||||||
|
// settings-Tabelle kann hier noch fehlen (wird sonst erst weiter unten angelegt)
|
||||||
|
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||||
|
const hasServers = db.prepare('SELECT count(*) AS n FROM gameservers').get().n > 0;
|
||||||
|
const legacy = db.prepare(`SELECT value FROM settings WHERE key = 'gameservers'`).get()?.value;
|
||||||
|
if (!hasServers && legacy) {
|
||||||
|
const ins = db.prepare(`INSERT INTO gameservers (name, type, query_url) VALUES (?, 'fivem', ?)`);
|
||||||
|
for (const entry of legacy.split(',')) {
|
||||||
|
const [name, ...rest] = entry.split('=');
|
||||||
|
const url = rest.join('=').trim().replace(/\/$/, '');
|
||||||
|
if (name?.trim() && /^https?:\/\//.test(url)) ins.run(name.trim(), url);
|
||||||
|
}
|
||||||
|
db.prepare(`DELETE FROM settings WHERE key = 'gameservers'`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const insertGameserver = db.prepare(`
|
||||||
|
INSERT INTO gameservers (name, type, query_url, address) VALUES (@name, @type, @query_url, @address)
|
||||||
|
`);
|
||||||
|
const updateGameserverStmt = db.prepare(`
|
||||||
|
UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address WHERE id = @id
|
||||||
|
`);
|
||||||
|
const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?');
|
||||||
|
const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id');
|
||||||
|
const getGameserverStmt = db.prepare('SELECT * FROM gameservers WHERE id = ?');
|
||||||
|
const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?');
|
||||||
|
export const createGameserver = (s) => insertGameserver.run(s).lastInsertRowid;
|
||||||
|
export const updateGameserver = (s) => updateGameserverStmt.run(s).changes > 0;
|
||||||
|
export const setGameserverMessage = (id, messageId) => setGameserverMessageStmt.run(messageId, id);
|
||||||
|
export const listGameservers = () => listGameserversStmt.all();
|
||||||
|
export const getGameserver = (id) => getGameserverStmt.get(id) ?? null;
|
||||||
|
export const deleteGameserver = (id) => deleteGameserverStmt.run(id).changes > 0;
|
||||||
|
|
||||||
// Moderation: Verwarnungen + Sticky-Roles
|
// Moderation: Verwarnungen + Sticky-Roles
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS warns (
|
CREATE TABLE IF NOT EXISTS warns (
|
||||||
|
|||||||
+173
-33
@@ -10,6 +10,8 @@ import {
|
|||||||
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
|
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
|
||||||
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm,
|
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm,
|
||||||
saveTrigger, listTriggers, deleteTrigger, activityRange,
|
saveTrigger, listTriggers, deleteTrigger, activityRange,
|
||||||
|
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
|
||||||
|
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||||||
@@ -28,7 +30,7 @@ function paging(request) {
|
|||||||
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
|
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin-Guard: null = okay, sonst wurde bereits eine Fehler-Antwort gesendet */
|
/** Owner-Guard (ADMIN_DISCORD_ID): null = okay, sonst wurde bereits geantwortet */
|
||||||
function requireAdmin(request, reply) {
|
function requireAdmin(request, reply) {
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
if (!user) return reply.code(401).send({ error: 'login required' });
|
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||||
@@ -36,11 +38,36 @@ function requireAdmin(request, reply) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Web-Scopes des eingeloggten Users: Owner = ['*'], Team = aus web_admins */
|
||||||
|
function scopesOf(user) {
|
||||||
|
if (!user) return [];
|
||||||
|
if (isAdmin(user)) return ['*'];
|
||||||
|
return webAdminScopes(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bereichs-Guard: Owner oder Team-Mitglied mit passendem Scope */
|
||||||
|
function requireScope(request, reply, scope) {
|
||||||
|
const user = getSessionUser(request);
|
||||||
|
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||||
|
const scopes = scopesOf(user);
|
||||||
|
if (scopes.includes('*') || scopes.includes(scope)) return null;
|
||||||
|
return reply.code(403).send({ error: `scope '${scope}' required` });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mindestens irgendein Team-Zugang (für gemeinsame Daten wie Kanal-Listen) */
|
||||||
|
function requireAnyScope(request, reply) {
|
||||||
|
const user = getSessionUser(request);
|
||||||
|
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||||
|
if (scopesOf(user).length === 0) return reply.code(403).send({ error: 'no access' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerApiRoutes(app, client) {
|
export function registerApiRoutes(app, client) {
|
||||||
// Wer bin ich? (fürs Frontend: Login-Status + Admin-Flag)
|
// Wer bin ich? (fürs Frontend: Login-Status, Owner-Flag + Team-Scopes)
|
||||||
app.get('/api/me', async (request) => {
|
app.get('/api/me', async (request) => {
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
return user ? { user, admin: isAdmin(user) } : { user: null, admin: false };
|
if (!user) return { user: null, admin: false, scopes: [] };
|
||||||
|
return { user, admin: isAdmin(user), scopes: scopesOf(user) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Devlog-Archiv — öffentlich (wie der Discord-Kanal); ?q= für Volltextsuche
|
// Devlog-Archiv — öffentlich (wie der Discord-Kanal); ?q= für Volltextsuche
|
||||||
@@ -76,7 +103,7 @@ export function registerApiRoutes(app, client) {
|
|||||||
|
|
||||||
// Playtester-Liste — Admin
|
// Playtester-Liste — Admin
|
||||||
app.get('/api/playtesters', async (request, reply) => {
|
app.get('/api/playtesters', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'community')) return;
|
||||||
return { playtesters: listPlaytesters() };
|
return { playtesters: listPlaytesters() };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +208,7 @@ ${rssItems}
|
|||||||
|
|
||||||
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
||||||
app.delete('/api/devlogs/:id', async (request, reply) => {
|
app.delete('/api/devlogs/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'devlogs')) return;
|
||||||
|
|
||||||
const deleted = await removeDevlog(request.params.id);
|
const deleted = await removeDevlog(request.params.id);
|
||||||
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
|
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
|
||||||
@@ -253,7 +280,6 @@ ${rssItems}
|
|||||||
status_channel_id: getSetting('status_channel_id') ?? '',
|
status_channel_id: getSetting('status_channel_id') ?? '',
|
||||||
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
||||||
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
|
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
|
||||||
gameservers: getSetting('gameservers') ?? '',
|
|
||||||
autorole_id: getSetting('autorole_id') ?? '',
|
autorole_id: getSetting('autorole_id') ?? '',
|
||||||
brand_name: getSetting('brand_name') ?? 'D4RKST3R',
|
brand_name: getSetting('brand_name') ?? 'D4RKST3R',
|
||||||
brand_color: getSetting('brand_color') ?? '#f5c518',
|
brand_color: getSetting('brand_color') ?? '#f5c518',
|
||||||
@@ -277,8 +303,10 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/settings', async (request, reply) => {
|
app.get('/api/settings', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireAnyScope(request, reply)) return;
|
||||||
|
|
||||||
|
// Application-Daten (Beschreibung) nachladen — best effort
|
||||||
|
await client.application?.fetch?.().catch(() => {});
|
||||||
const stats = archiveStats();
|
const stats = archiveStats();
|
||||||
return {
|
return {
|
||||||
channels: listChannels(),
|
channels: listChannels(),
|
||||||
@@ -288,6 +316,7 @@ ${rssItems}
|
|||||||
botTag: client.user?.tag ?? null,
|
botTag: client.user?.tag ?? null,
|
||||||
botName: client.user?.username ?? null,
|
botName: client.user?.username ?? null,
|
||||||
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
|
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
|
||||||
|
botDescription: client.application?.description ?? '',
|
||||||
uptimeSeconds: Math.floor(process.uptime()),
|
uptimeSeconds: Math.floor(process.uptime()),
|
||||||
guilds: client.guilds.cache.size,
|
guilds: client.guilds.cache.size,
|
||||||
giteaTokenConfigured: Boolean(giteaApiToken()),
|
giteaTokenConfigured: Boolean(giteaApiToken()),
|
||||||
@@ -298,10 +327,18 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/settings', async (request, reply) => {
|
app.put('/api/settings', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'settings')) return;
|
||||||
|
|
||||||
const body = request.body ?? {};
|
const body = request.body ?? {};
|
||||||
|
|
||||||
|
// Sensible Felder sind Owner-only — für Team-Mitglieder still ignorieren
|
||||||
|
if (!isAdmin(getSessionUser(request))) {
|
||||||
|
for (const key of [
|
||||||
|
'gitea_api_token', 'twitch_client_id', 'twitch_client_secret',
|
||||||
|
'discord_guild_id', 'bot_name', 'bot_description', 'public_url', 'gitea_url',
|
||||||
|
]) delete body[key];
|
||||||
|
}
|
||||||
|
|
||||||
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
|
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
|
||||||
const OPTIONAL_CHANNELS = [
|
const OPTIONAL_CHANNELS = [
|
||||||
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
||||||
@@ -409,6 +446,15 @@ ${rssItems}
|
|||||||
return reply.code(502).send({ error: `Bot-Name: Discord lehnt ab (${error.message?.slice(0, 80)})` });
|
return reply.code(502).send({ error: `Bot-Name: Discord lehnt ab (${error.message?.slice(0, 80)})` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Bot-Profil-Beschreibung („Über mich" der App)
|
||||||
|
if (body.bot_description !== undefined &&
|
||||||
|
String(body.bot_description) !== (client.application?.description ?? '')) {
|
||||||
|
try {
|
||||||
|
await client.application.edit({ description: String(body.bot_description).slice(0, 400) });
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(502).send({ error: `Beschreibung: Discord lehnt ab (${error.message?.slice(0, 80)})` });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (statusChanged) {
|
if (statusChanged) {
|
||||||
const { applyBotStatus } = await import('../bot/presence.js');
|
const { applyBotStatus } = await import('../bot/presence.js');
|
||||||
applyBotStatus(client);
|
applyBotStatus(client);
|
||||||
@@ -429,7 +475,7 @@ ${rssItems}
|
|||||||
setSetting(key, String(body[key]).trim());
|
setSetting(key, String(body[key]).trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
|
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo']) {
|
||||||
if (body[key] !== undefined) {
|
if (body[key] !== undefined) {
|
||||||
setSetting(key, String(body[key]).trim());
|
setSetting(key, String(body[key]).trim());
|
||||||
}
|
}
|
||||||
@@ -501,12 +547,12 @@ ${rssItems}
|
|||||||
const menuToJson = (m) => ({ ...m, exclusive: Boolean(m.exclusive), entries: JSON.parse(m.entries || '[]') });
|
const menuToJson = (m) => ({ ...m, exclusive: Boolean(m.exclusive), entries: JSON.parse(m.entries || '[]') });
|
||||||
|
|
||||||
app.get('/api/rolemenus', async (request, reply) => {
|
app.get('/api/rolemenus', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'rollen')) return;
|
||||||
return { menus: listRoleMenus().map(menuToJson) };
|
return { menus: listRoleMenus().map(menuToJson) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/rolemenus', async (request, reply) => {
|
app.post('/api/rolemenus', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'rollen')) return;
|
||||||
const menu = parseMenuBody(request, reply);
|
const menu = parseMenuBody(request, reply);
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const id = createRoleMenu(menu);
|
const id = createRoleMenu(menu);
|
||||||
@@ -515,7 +561,7 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/rolemenus/:id', async (request, reply) => {
|
app.put('/api/rolemenus/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'rollen')) return;
|
||||||
const id = Number(request.params.id);
|
const id = Number(request.params.id);
|
||||||
if (!getRoleMenu(id)) return reply.code(404).send({ error: 'Menü nicht gefunden' });
|
if (!getRoleMenu(id)) return reply.code(404).send({ error: 'Menü nicht gefunden' });
|
||||||
const menu = parseMenuBody(request, reply);
|
const menu = parseMenuBody(request, reply);
|
||||||
@@ -535,7 +581,7 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/rolemenus/:id/publish', async (request, reply) => {
|
app.post('/api/rolemenus/:id/publish', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'rollen')) return;
|
||||||
try {
|
try {
|
||||||
const messageId = await publishRoleMenu(client, Number(request.params.id));
|
const messageId = await publishRoleMenu(client, Number(request.params.id));
|
||||||
request.log.info(`Rollen-Menü ${request.params.id} publiziert (${messageId})`);
|
request.log.info(`Rollen-Menü ${request.params.id} publiziert (${messageId})`);
|
||||||
@@ -546,7 +592,7 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/rolemenus/:id', async (request, reply) => {
|
app.delete('/api/rolemenus/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'rollen')) return;
|
||||||
const menu = getRoleMenu(Number(request.params.id));
|
const menu = getRoleMenu(Number(request.params.id));
|
||||||
if (!menu) return { deleted: false };
|
if (!menu) return { deleted: false };
|
||||||
await unpublishRoleMenu(client, menu);
|
await unpublishRoleMenu(client, menu);
|
||||||
@@ -564,12 +610,12 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/alphakeys', async (request, reply) => {
|
app.get('/api/alphakeys', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'community')) return;
|
||||||
return keyStatus();
|
return keyStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/alphakeys', async (request, reply) => {
|
app.post('/api/alphakeys', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'community')) return;
|
||||||
const keys = String(request.body?.keys ?? '')
|
const keys = String(request.body?.keys ?? '')
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((k) => k.trim())
|
.map((k) => k.trim())
|
||||||
@@ -582,7 +628,7 @@ ${rssItems}
|
|||||||
|
|
||||||
// Verteilen: jeder Playtester ohne Key bekommt einen per DM (Key bleibt frei bei DM-Fehler)
|
// Verteilen: jeder Playtester ohne Key bekommt einen per DM (Key bleibt frei bei DM-Fehler)
|
||||||
app.post('/api/alphakeys/distribute', async (request, reply) => {
|
app.post('/api/alphakeys/distribute', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'community')) return;
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
const failed = [];
|
const failed = [];
|
||||||
for (const p of keyStatus().playtestersWithout) {
|
for (const p of keyStatus().playtestersWithout) {
|
||||||
@@ -641,12 +687,12 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/appforms', async (request, reply) => {
|
app.get('/api/appforms', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||||
return { forms: listAppForms().map(formToJson) };
|
return { forms: listAppForms().map(formToJson) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/appforms', async (request, reply) => {
|
app.post('/api/appforms', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||||
const form = parseFormBody(request, reply);
|
const form = parseFormBody(request, reply);
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const id = createAppForm(form);
|
const id = createAppForm(form);
|
||||||
@@ -654,7 +700,7 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/appforms/:id', async (request, reply) => {
|
app.put('/api/appforms/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||||
const id = Number(request.params.id);
|
const id = Number(request.params.id);
|
||||||
if (!getAppForm(id)) return reply.code(404).send({ error: 'Formular nicht gefunden' });
|
if (!getAppForm(id)) return reply.code(404).send({ error: 'Formular nicht gefunden' });
|
||||||
const form = parseFormBody(request, reply);
|
const form = parseFormBody(request, reply);
|
||||||
@@ -669,7 +715,7 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/appforms/:id/publish', async (request, reply) => {
|
app.post('/api/appforms/:id/publish', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||||
try {
|
try {
|
||||||
const messageId = await publishAppForm(client, Number(request.params.id));
|
const messageId = await publishAppForm(client, Number(request.params.id));
|
||||||
return { ok: true, message_id: messageId };
|
return { ok: true, message_id: messageId };
|
||||||
@@ -679,7 +725,7 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/appforms/:id', async (request, reply) => {
|
app.delete('/api/appforms/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'bewerbungen')) return;
|
||||||
const form = getAppForm(Number(request.params.id));
|
const form = getAppForm(Number(request.params.id));
|
||||||
if (!form) return { deleted: false };
|
if (!form) return { deleted: false };
|
||||||
await unpublishAppForm(client, form);
|
await unpublishAppForm(client, form);
|
||||||
@@ -687,15 +733,72 @@ ${rssItems}
|
|||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Game-Server (Admin) — DiscordGSM-Stil ---
|
||||||
|
|
||||||
|
function parseServerBody(request, reply) {
|
||||||
|
const body = request.body ?? {};
|
||||||
|
const name = String(body.name ?? '').trim();
|
||||||
|
const queryUrl = String(body.query_url ?? '').trim().replace(/\/$/, '');
|
||||||
|
const type = ['fivem', 'http'].includes(body.type) ? body.type : 'fivem';
|
||||||
|
if (!name || !/^https?:\/\/.+/.test(queryUrl)) {
|
||||||
|
reply.code(400).send({ error: 'name und query_url (http/https) nötig' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: name.slice(0, 60),
|
||||||
|
type,
|
||||||
|
query_url: queryUrl,
|
||||||
|
address: String(body.address ?? '').trim().slice(0, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/gameservers', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'server')) return;
|
||||||
|
return { servers: listGameservers() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/gameservers', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'server')) return;
|
||||||
|
const server = parseServerBody(request, reply);
|
||||||
|
if (!server) return;
|
||||||
|
const id = createGameserver(server);
|
||||||
|
return { ok: true, server: getGameserver(id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/gameservers/:id', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'server')) return;
|
||||||
|
const id = Number(request.params.id);
|
||||||
|
if (!getGameserver(id)) return reply.code(404).send({ error: 'Server nicht gefunden' });
|
||||||
|
const server = parseServerBody(request, reply);
|
||||||
|
if (!server) return;
|
||||||
|
updateGameserver({ ...server, id });
|
||||||
|
return { ok: true, server: getGameserver(id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/gameservers/:id', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'server')) return;
|
||||||
|
const server = getGameserver(Number(request.params.id));
|
||||||
|
if (!server) return { deleted: false };
|
||||||
|
// Status-Embed mit aufräumen
|
||||||
|
if (server.message_id) {
|
||||||
|
const channelId = getSetting('status_channel_id');
|
||||||
|
const channel = channelId ? await client.channels.fetch(channelId).catch(() => null) : null;
|
||||||
|
const message = await channel?.messages?.fetch(server.message_id).catch(() => null);
|
||||||
|
await message?.delete().catch(() => {});
|
||||||
|
}
|
||||||
|
deleteGameserver(server.id);
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
// --- Triggers (Admin) ---
|
// --- Triggers (Admin) ---
|
||||||
|
|
||||||
app.get('/api/triggers', async (request, reply) => {
|
app.get('/api/triggers', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
return { triggers: listTriggers() };
|
return { triggers: listTriggers() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/triggers', async (request, reply) => {
|
app.put('/api/triggers', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
|
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
|
||||||
const replyText = String(request.body?.reply ?? '').trim();
|
const replyText = String(request.body?.reply ?? '').trim();
|
||||||
if (keyword.length < 3 || !replyText) {
|
if (keyword.length < 3 || !replyText) {
|
||||||
@@ -706,19 +809,19 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/triggers/:keyword', async (request, reply) => {
|
app.delete('/api/triggers/:keyword', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
|
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
|
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
|
||||||
|
|
||||||
app.get('/api/tags', async (request, reply) => {
|
app.get('/api/tags', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
return { tags: listTags() };
|
return { tags: listTags() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/tags/:name', async (request, reply) => {
|
app.put('/api/tags/:name', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
const name = String(request.params.name).trim().toLowerCase();
|
const name = String(request.params.name).trim().toLowerCase();
|
||||||
const content = String(request.body?.content ?? '').trim();
|
const content = String(request.body?.content ?? '').trim();
|
||||||
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
|
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
|
||||||
@@ -730,19 +833,19 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/tags/:name', async (request, reply) => {
|
app.delete('/api/tags/:name', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
|
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Geplante Posts (Admin) ---
|
// --- Geplante Posts (Admin) ---
|
||||||
|
|
||||||
app.get('/api/scheduled', async (request, reply) => {
|
app.get('/api/scheduled', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
return { posts: listScheduledPosts() };
|
return { posts: listScheduledPosts() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/scheduled', async (request, reply) => {
|
app.post('/api/scheduled', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
const body = request.body ?? {};
|
const body = request.body ?? {};
|
||||||
const channelId = String(body.channel_id ?? '');
|
const channelId = String(body.channel_id ?? '');
|
||||||
const content = String(body.content ?? '').trim();
|
const content = String(body.content ?? '').trim();
|
||||||
@@ -774,13 +877,13 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/scheduled/:id', async (request, reply) => {
|
app.delete('/api/scheduled/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
return { deleted: deleteScheduledPost(Number(request.params.id)) };
|
return { deleted: deleteScheduledPost(Number(request.params.id)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
|
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
|
||||||
app.post('/api/compose', async (request, reply) => {
|
app.post('/api/compose', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireScope(request, reply, 'content')) return;
|
||||||
|
|
||||||
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle } = request.body ?? {};
|
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle } = request.body ?? {};
|
||||||
const text = String(content ?? '').trim();
|
const text = String(content ?? '').trim();
|
||||||
@@ -851,6 +954,43 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Team-Verwaltung (nur Owner) ---
|
||||||
|
|
||||||
|
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
|
||||||
|
|
||||||
|
app.get('/api/webadmins', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
return { admins: listWebAdmins(), scopes: TEAM_SCOPES };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/webadmins', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
const userId = String(request.body?.user_id ?? '').trim();
|
||||||
|
const scopes = (Array.isArray(request.body?.scopes) ? request.body.scopes : [])
|
||||||
|
.filter((s) => TEAM_SCOPES.includes(s));
|
||||||
|
if (!/^\d{15,21}$/.test(userId)) {
|
||||||
|
return reply.code(400).send({ error: 'user_id: Discord-User-ID erwartet' });
|
||||||
|
}
|
||||||
|
if (userId === config.adminDiscordId) {
|
||||||
|
return reply.code(400).send({ error: 'Der Owner hat immer alle Rechte' });
|
||||||
|
}
|
||||||
|
if (scopes.length === 0) return reply.code(400).send({ error: 'mindestens ein Bereich' });
|
||||||
|
// Username für die Anzeige holen (best effort)
|
||||||
|
let username = String(request.body?.username ?? '').trim();
|
||||||
|
if (!username) {
|
||||||
|
const user = await client.users.fetch(userId).catch(() => null);
|
||||||
|
username = user?.username ?? userId;
|
||||||
|
}
|
||||||
|
saveWebAdmin(userId, username, scopes);
|
||||||
|
request.log.info(`Team-Mitglied ${username} (${userId}): ${scopes.join(',')}`);
|
||||||
|
return { ok: true, admins: listWebAdmins() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/webadmins/:id', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
return { deleted: deleteWebAdmin(String(request.params.id)), admins: listWebAdmins() };
|
||||||
|
});
|
||||||
|
|
||||||
// --- API-Keys (Admin) — für /api/v1/* ---
|
// --- API-Keys (Admin) — für /api/v1/* ---
|
||||||
|
|
||||||
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
||||||
@@ -883,7 +1023,7 @@ ${rssItems}
|
|||||||
|
|
||||||
// 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 (requireScope(request, reply, 'settings')) return;
|
||||||
|
|
||||||
const target = request.params.target;
|
const target = request.params.target;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user