Files
d4rkbot/src/web/api.js
T
D4rkst3randClaude Opus 5 eec170ff41
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Connect-Links im Discord anklickbar, und Mods bekommen einen eigenen Knopf
Im Kanal stand woertlich das da:

    [https://cfx.re/join/4odv68](https://cfx.re/join/4odv68)

Der Link war sein eigener Linktext. Discord macht aus der nackten URL zwischen
den eckigen Klammern zuerst selbst einen Link und kommt mit der
Masked-Link-Schreibweise dann nicht mehr durch — heraus kommen die Klammern im
Klartext. Der Kommentar an der Stelle behauptete ausserdem, so seien steam://
und Konsorten klickbar. Waren sie nie.

Jetzt sind es Link-Knoepfe unter dem Embed. Die Frage nach der Schreibweise
stellt sich damit gar nicht mehr, und ein Knopf sieht auch nach einem aus.
Discord nimmt dort nur http(s) an; fivem:// und steam:// stehen deshalb weiter
im Embed, aber als Codeblock zum Kopieren statt als toter Link.

Dazu ein eigenes Feld fuer den Mod-Download. Vorher landete der beim
Connect-Link, weil es kein anderes gab — und dann fuehrte "Verbinden" zu einer
ZIP-Datei. Farming Simulator liefert die Modliste ohnehin mit, also steht die
Anzahl jetzt auch im Embed und als Chip auf der Seite.

Beim Testen aufgefallen: eine neue Spalte hat jeden Aufrufer umgebracht, der
sie noch nicht kannte — better-sqlite3 wirft bei einem fehlenden benannten
Parameter. createGameserver und updateGameserver fuellen die Felder jetzt
einmal auf, statt das an jeder Aufrufstelle nachzuziehen.

Geprueft: was in einen Knopf darf und was nicht (https, http, fivem://,
steam://, leer, null, Leerzeichen, Satz mit Link drin), beide Knoepfe in einer
Reihe, nur einer, gar keiner — eine leere Reihe weist Discord ab —, https
landet im Knopf und nicht doppelt auch im Embed, fivem:// umgekehrt, Mods bei
47/0/null, und der Aufruf ohne die neue Spalte. Seite im laufenden Frontend
angesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:52:08 +02:00

2409 lines
111 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
import { ChannelType, EmbedBuilder } from 'discord.js';
import {
listDevlogs, listDevlogsByProject, devlogHeads,
searchDevlogs, getDevlog, listCommits, listReleases, archiveStats,
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
listGallery, listPlaytesters, topWishes, commitHeatmap,
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
topLevels, saveTag, listTags, deleteTag,
createScheduledPost, listScheduledPosts, deleteScheduledPost,
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm, clearOtherPlaytesterForms,
saveTrigger, listTriggers, deleteTrigger, activityRange,
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver, lastOnlineAt,
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
wishComments, wishCommentImages,
listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
heartbeatBuckets, heartbeatFirst,
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
listTicketCategories, createTicketCategory, updateTicketCategory, deleteTicketCategory,
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage,
} from '../db.js';
import crypto from 'node:crypto';
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS, spielName, spielListe } from '../bot/server-monitor.js';
import { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js';
import {
wunschAnlegen, wunschTagsAktualisieren, forumTagsAnlegen, wunschBilderDir,
STATUS_LABEL, STATUS_EMOJI,
} from '../bot/wishes.js';
import { bilderLoeschen } from '../bot/bilder.js';
import { heartbeatSerie } from '../bot/heartbeat.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard, playtesterRoleId } from '../runtime-settings.js';
import { xpForLevel } from '../bot/levels.js';
import { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
const PAGE_SIZE = 20;
/** Query-Parameter ?page=1.. in LIMIT/OFFSET übersetzen */
function paging(request) {
const page = Math.max(1, Number(request.query.page) || 1);
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
}
/** Owner-Guard (ADMIN_DISCORD_ID): null = okay, sonst wurde bereits geantwortet */
function requireAdmin(request, reply) {
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'login required' });
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
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) {
// Wer bin ich? (fürs Frontend: Login-Status, Owner-Flag + Team-Scopes)
app.get('/api/me', async (request) => {
const user = getSessionUser(request);
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
// Projekte im Archiv (D4RKBOT, ECOGAME …) samt Anzahl — für die Filter-Chips.
// Das Projekt steht in der ersten Zeile, dieselbe Regel wie im Frontend.
app.get('/api/devlog-projects', async () => {
const counts = new Map();
for (const head of devlogHeads()) {
const name = head.match(/^Devlog\s*[—–-]\s*(.+)$/i)?.[1]?.trim();
if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
}
return {
projects: [...counts].map(([name, n]) => ({ name, n })).sort((a, b) => b.n - a.n),
};
});
app.get('/api/devlogs', async (request) => {
const { limit, offset, page } = paging(request);
const q = (request.query.q ?? '').trim();
const project = (request.query.project ?? '').trim();
// Suche schlägt Filter — beides gleichzeitig würde die FTS-Abfrage verkomplizieren,
// ohne dass jemand danach fragt.
const { items, total } = q
? searchDevlogs(q, limit, offset)
: (project ? listDevlogsByProject(project, limit, offset) : listDevlogs(limit, offset));
// images: JSON-Spalte → fertige URLs fürs Frontend
const mapped = items.map(({ images, ...rest }) => ({
...rest,
images: JSON.parse(images || '[]').map((f) => `/devlog-assets/${f}`),
}));
return { items: mapped, total, page, pageSize: PAGE_SIZE };
});
// Einzelnes Devlog (Permalink-Seite /devlogs/:id)
app.get('/api/devlogs/:id', async (request, reply) => {
const item = getDevlog(String(request.params.id));
if (!item) return reply.code(404).send({ error: 'Devlog nicht gefunden' });
return {
item: {
...item,
images: JSON.parse(item.images || '[]').map((f) => `/devlog-assets/${f}`),
},
};
});
// Changelog — öffentlich (Releases aller Repos)
app.get('/api/releases', async (request) => {
const { limit, offset, page } = paging(request);
const { items, total } = listReleases(limit, offset);
return { items, total, page, pageSize: PAGE_SIZE };
});
// Screenshot-Galerie — öffentlich
app.get('/api/gallery', async (request) => {
const { limit, offset, page } = paging(request);
const { items, total } = listGallery(limit, offset);
const mapped = items.map(({ images, ...rest }) => ({
...rest,
images: JSON.parse(images || '[]').map((f) => `/gallery-assets/${f}`),
}));
return { items: mapped, total, page, pageSize: PAGE_SIZE };
});
// Playtester-Liste — Admin
app.get('/api/playtesters', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
return { playtesters: listPlaytesters() };
});
// Playtester entfernen. Die Rolle geht mit — nur aus der Liste zu streichen
// und die Rolle stehen zu lassen wäre eine Halbwahrheit, die spätestens
// beim nächsten Verteilen auffällt.
app.delete('/api/playtesters/:id', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const userId = String(request.params.id);
const weg = removePlaytester(userId);
let rolleWeg = false;
const roleId = playtesterRoleId();
if (roleId) {
try {
const gid = discordGuildId();
const guild = gid ? client.guilds.cache.get(gid) : client.guilds.cache.first();
const member = await guild?.members.fetch(userId);
await member?.roles.remove(roleId);
rolleWeg = true;
} catch { /* nicht mehr auf dem Server, oder Rolle steht über dem Bot */ }
}
logAudit(getSessionUser(request), 'playtester entfernt', userId);
return { ok: weg, rolleWeg, playtesters: listPlaytesters() };
});
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich
app.get('/api/wishes', async (request) => {
const user = getSessionUser(request);
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
const sortierung = String(request.query?.sort ?? 'top');
// Den Link zum Thread bauen wir hier — die Guild-ID gehört nicht auf
// die öffentliche Seite, nur der fertige Verweis
const gid = discordGuildId();
return {
wishes: topWishes(50, sortierung).map((w) => ({
...w,
voted: mine.has(w.id),
thread_url: gid && w.thread_id
? `https://discord.com/channels/${gid}/${w.thread_id}`
: null,
})),
bereiche: listWishCategories(),
canVote: Boolean(user),
};
});
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
// Öffentliche Server-Status-Seite: letzte Query-Ergebnisse + 24h-Verlauf
// (bewusst ohne query_url/host/port — nur was Besucher sehen dürfen)
app.get('/api/servers', async () => ({
servers: listGameservers().map((s) => {
const r = lastResults.get(s.id);
return {
id: s.id,
name: s.name,
type: s.type,
spiel: spielName(s.type),
icon: GAME_ICONS[s.type] ?? '🎮',
image_url: s.image_url || null,
address: s.address || null,
connect_url: s.connect_url || null,
mods_url: s.mods_url || null,
online: r?.online ?? null, // null = noch nie gecheckt
players: r?.players ?? null,
max: r?.max ?? null,
map: r?.map ?? null,
version: r?.version ?? null,
mods: r?.mods ?? null,
ping: r?.ping ?? null,
// Namen im Spiel, so wie sie im Discord-Embed auch stehen
spielerNamen: r?.online ? (r.playerNames ?? []) : [],
checkedAt: r?.checkedAt ?? null,
// Für offline Server: seit wann. Ohne Verlauf bleibt es null.
zuletztOnline: r?.online === false ? lastOnlineAt(s.id) : null,
history: playerHistory(s.id, 24),
// Erreichbarkeit der letzten 7 Tage, stundenweise. Länger geht
// nicht: prunePlayerHistory räumt alles Ältere weg.
uptime: uptimeBuckets(s.id, 168),
};
}),
}));
/** Einen überwachten Dienst in die Form bringen, die beide Seiten nutzen */
function dienstStand(dienst, { tage = false } = {}) {
const s = watchdogState().get(dienst.url) ?? {};
return {
id: dienst.id,
name: dienst.name,
gruppe: dienst.gruppe,
oeffentlich: dienst.oeffentlich,
// undefined = seit dem Start noch nicht geprüft
online: s.geprueft ? !s.down : null,
fails: s.fails ?? 0,
seit: s.since ?? null,
ms: s.ms ?? null,
status: s.status ?? null,
geprueft: s.geprueft ?? null,
uptime: tage ? watchdogDays(dienst.url, 90) : watchdogBuckets(dienst.url, 168),
};
}
// Watchdog-Übersicht fürs Panel: alle Dienste mit voller Adresse.
// Nicht öffentlich — in der Liste können interne Dienste stehen.
app.get('/api/watchdog', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return {
aktiv: moduleEnabled('watchdog'),
intervall: tuning('watchdog_interval'),
adressen: watchedServices().map((d) => ({ ...dienstStand(d), url: d.url })),
stoerungen: listIncidents(90, 50),
};
});
// Öffentliche Statusseite: nur freigegebene Dienste, und bewusst OHNE
// Adresse — der Name reicht, die URL geht niemanden etwas an.
app.get('/api/status', async () => {
const dienste = watchedServices().filter((d) => d.oeffentlich);
const urls = new Set(dienste.map((d) => d.url));
return {
dienste: dienste.map((d) => dienstStand(d, { tage: true })),
server: listGameservers().map((s) => {
const r = lastResults.get(s.id);
return {
id: s.id,
name: s.name,
icon: GAME_ICONS[s.type] ?? '🎮',
online: r?.online ?? null,
players: r?.players ?? null,
max: r?.max ?? null,
};
}),
// Nur Störungen freigegebener Dienste — sonst verrät die Historie,
// was es sonst noch gibt
stoerungen: listIncidents(90, 20)
.filter((v) => urls.has(v.url))
.map(({ url, ...rest }) => ({
...rest,
name: dienste.find((d) => d.url === url)?.name ?? 'Dienst',
})),
};
});
// --- Überwachte Dienste verwalten (Panel) ---
/** Eingaben prüfen; antwortet bei Fehlern selbst */
function dienstBody(request, reply) {
const b = request.body ?? {};
const name = String(b.name ?? '').trim().slice(0, 60);
const url = String(b.url ?? '').trim();
if (!name) { reply.code(400).send({ error: 'Name fehlt' }); return null; }
if (!/^https?:\/\/.+/.test(url)) { reply.code(400).send({ error: 'URL mit http(s) erwartet' }); return null; }
return {
name, url,
gruppe: String(b.gruppe ?? 'Dienste').trim().slice(0, 40) || 'Dienste',
oeffentlich: Boolean(b.oeffentlich),
sort: Number(b.sort) || 0,
};
}
app.post('/api/monitored', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const d = dienstBody(request, reply);
if (!d) return;
createMonitored(d);
logAudit(getSessionUser(request), 'dienst überwacht', `${d.name} (${d.url})`);
return { ok: true, dienste: listMonitored() };
});
app.put('/api/monitored/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const d = dienstBody(request, reply);
if (!d) return;
updateMonitored({ ...d, id: Number(request.params.id) });
logAudit(getSessionUser(request), 'dienst geändert', d.name);
return { ok: true, dienste: listMonitored() };
});
app.delete('/api/monitored/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
logAudit(getSessionUser(request), 'dienst entfernt', String(request.params.id));
return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() };
});
// Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist
// eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server.
app.get('/api/ticket-categories', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return { kategorien: listTicketCategories() };
});
/** Eingaben pruefen — gibt null zurueck und hat dann schon geantwortet */
function anliegenBody(request, reply) {
const b = request.body ?? {};
const name = String(b.name ?? '').trim();
if (!name) {
reply.code(400).send({ error: 'Name fehlt.' });
return null;
}
return {
name: name.slice(0, 80),
// Discord lehnt die ganze Nachricht ab, wenn ein Emoji nicht passt —
// geprueft wird beim Bauen des Menues, hier nur gekuerzt
emoji: String(b.emoji ?? '').trim().slice(0, 32) || '🎫',
beschreibung: String(b.beschreibung ?? '').trim().slice(0, 100),
ping_role_id: String(b.ping_role_id ?? '').trim() || null,
intro: String(b.intro ?? '').trim().slice(0, 1024),
sort: Number(b.sort) || 0,
};
}
app.post('/api/ticket-categories', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const k = anliegenBody(request, reply);
if (!k) return;
createTicketCategory(k);
logAudit(getSessionUser(request), 'ticket-anliegen angelegt', k.name);
return { ok: true, kategorien: listTicketCategories() };
});
app.put('/api/ticket-categories/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const k = anliegenBody(request, reply);
if (!k) return;
updateTicketCategory({ ...k, id: Number(request.params.id) });
logAudit(getSessionUser(request), 'ticket-anliegen geaendert', k.name);
return { ok: true, kategorien: listTicketCategories() };
});
app.delete('/api/ticket-categories/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
logAudit(getSessionUser(request), 'ticket-anliegen entfernt', String(request.params.id));
return {
deleted: deleteTicketCategory(Number(request.params.id)),
kategorien: listTicketCategories(),
};
});
// AutoMod: Discords eigene Regeln lesen und schalten. Die Wortlisten
// bleiben in Discord — dort ist das Bearbeiten besser geloest.
app.get('/api/automod', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
const res = await listRules(client, discordGuildId());
return { aktiv: moduleEnabled('automod'), ...res };
});
app.put('/api/automod/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const user = getSessionUser(request);
try {
const regel = await setRuleEnabled(
client, discordGuildId(), String(request.params.id),
Boolean(request.body?.aktiv), user?.username ?? 'Panel'
);
logAudit(user, `automod-regel ${regel.aktiv ? 'an' : 'aus'}`, regel.name);
return { ok: true, regel };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
// --- Module: zentrale An/Aus-Schalter aller Funktionen ---
app.get('/api/modules', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return {
modules: moduleStates(),
groups: MODULE_GROUPS,
favorites: moduleFavorites(),
maxFavorites: MAX_FAVORITES,
};
});
// Welche Funktionen in der Seitenleiste stehen
app.put('/api/module-favorites', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const res = setModuleFavorites(request.body?.ids ?? []);
if (!res.ok) return reply.code(400).send({ error: res.error });
logAudit(getSessionUser(request), 'seitenleiste geändert', res.favorites.join(', ') || 'leer');
return { ok: true, favorites: res.favorites };
});
// Alles zu einer Funktion an einem Ort: ihre Kanäle und Felder, ihre Texte,
// ihre Werte. Genau das zeigt die Modul-Seite.
app.get('/api/modules/:id', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
const detail = moduleDetail(String(request.params.id));
if (!detail) return reply.code(404).send({ error: 'Modul unbekannt' });
return {
...detail,
texte: templateStates().filter((t) => t.module === detail.id),
werte: tuningStates().filter((w) => w.modules.includes(detail.id)),
};
});
app.put('/api/modules/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const id = String(request.params.id);
const on = Boolean(request.body?.enabled);
if (!setModuleEnabled(id, on)) {
return reply.code(404).send({ error: 'Modul unbekannt' });
}
logAudit(getSessionUser(request), on ? 'modul aktiviert' : 'modul deaktiviert', id);
request.log.info(`Modul ${id}${on ? 'an' : 'aus'}`);
return { ok: true, modules: moduleStates() };
});
// Fehlenden Kanal / fehlende Rolle direkt anlegen (Status → Noch einzurichten)
app.post('/api/setup/create', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const setting = String(request.body?.setting ?? '');
try {
const { createForSetting } = await import('../bot/server-setup.js');
const res = await createForSetting(client, setting);
logAudit(
getSessionUser(request),
res.wiederverwendet ? 'vorhandenen kanal verknüpft' : `${res.art} angelegt`,
`${setting}${res.name}`
);
request.log.info(`${res.art} ${res.name} für ${setting} (${res.wiederverwendet ? 'verknüpft' : 'neu'})`);
return { ok: true, ...res, modules: moduleStates(), settings: currentSettings() };
} catch (error) {
request.log.error({ err: error }, `Anlegen für ${setting} fehlgeschlagen`);
// Discord-Meldungen sind für Menschen unbrauchbar — eigene durchreichen
return reply.code(400).send({ error: error.message || 'Anlegen fehlgeschlagen' });
}
});
// Grundgerüst: Vorschau, was der Assistent anlegen würde
app.get('/api/setup/plan', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const { setupPlan } = await import('../bot/server-setup.js');
return { schritte: setupPlan(client), kategorie: brandName() };
});
// … und ausführen
app.post('/api/setup/run', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const kategorie = String(request.body?.kategorie ?? '').trim() || undefined;
try {
const { runSetup } = await import('../bot/server-setup.js');
const ergebnisse = await runSetup(client, { kategorie });
const neu = ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length;
logAudit(getSessionUser(request), 'grundgerüst angelegt', `${neu} neu, ${ergebnisse.length} gesamt`);
request.log.info(`Grundgerüst: ${neu} neu von ${ergebnisse.length} Schritten`);
return { ok: true, ergebnisse, modules: moduleStates(), settings: currentSettings() };
} catch (error) {
request.log.error({ err: error }, 'Grundgerüst fehlgeschlagen');
return reply.code(400).send({ error: error.message || 'Fehlgeschlagen' });
}
});
// --- Stellwerte: Zahlen, die vorher fest im Code standen ---
app.get('/api/tuning', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return { values: tuningStates(), groups: TUNING_GROUPS };
});
app.put('/api/tuning', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const body = request.body ?? {};
// Erst alles prüfen, dann schreiben — sonst bleibt bei einem Fehler
// die Hälfte gesetzt und die andere nicht.
const pending = [];
for (const [id, value] of Object.entries(body)) {
const check = setTuning(id, value, { dryRun: true });
if (!check.ok) return reply.code(400).send({ error: check.error });
pending.push([id, value]);
}
for (const [id, value] of pending) setTuning(id, value);
if (pending.length > 0) {
logAudit(getSessionUser(request), 'werte geändert', pending.map(([id]) => id).join(', '));
}
return { ok: true, values: tuningStates() };
});
// Vorschau der Willkommens-Karte. Nimmt das Aussehen als Query entgegen,
// damit man im Panel sieht, was man einstellt, bevor gespeichert wird.
app.get('/api/welcome-preview.png', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const q = request.query ?? {};
const hex = (v) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '')) ? String(v).toLowerCase() : undefined);
// Nur bekannte Felder übernehmen — der Rest kommt aus den Einstellungen
const override = {};
for (const key of ['background', 'accent', 'accent2', 'text']) {
const value = hex(q[key]);
if (value) override[key] = value;
}
if (q.kicker !== undefined) override.kicker = String(q.kicker).slice(0, 24);
if (q.sub !== undefined) override.sub = String(q.sub).slice(0, 60);
if (q.image !== undefined) override.image = String(q.image).trim();
if (q.watermark !== undefined) override.watermark = q.watermark !== '0';
try {
const { buildWelcomeCard } = await import('../bot/welcome-card.js');
const png = await buildWelcomeCard({
username: String(q.name ?? '').trim() || client.user?.username || 'Neues Mitglied',
avatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }),
memberNumber: Number(q.count) || 42,
}, override);
reply.header('Cache-Control', 'no-store');
return reply.type('image/png').send(png);
} catch (error) {
request.log.error({ err: error }, 'Willkommens-Vorschau fehlgeschlagen');
return reply.code(500).send({ error: 'Vorschau fehlgeschlagen' });
}
});
// Funktionsliste für die Produktseite — öffentlich und ohne Zustand:
// was der Bot kann, nicht was hier gerade eingeschaltet ist. Quelle ist
// dasselbe Register wie die Module, damit die Seite nicht veraltet.
app.get('/api/features', async () => ({
groups: MODULE_GROUPS,
features: MODULES.map((m) => ({ id: m.id, name: m.name, desc: m.desc, group: m.group })),
}));
// --- Text-Vorlagen: was der Bot nach außen schreibt ---
// (nicht /api/templates — das sind die gespeicherten Composer-Nachrichten)
app.get('/api/texts', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return { templates: templateStates(), groups: TEMPLATE_GROUPS };
});
app.put('/api/texts/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const id = String(request.params.id);
const text = String(request.body?.text ?? '');
if (text.length > 4000) {
return reply.code(400).send({ error: 'Text zu lang (max. 4000 Zeichen)' });
}
if (!setTemplate(id, text)) {
return reply.code(404).send({ error: 'Vorlage unbekannt' });
}
const reset = !text.trim();
logAudit(getSessionUser(request), reset ? 'vorlage zurückgesetzt' : 'vorlage geändert', id);
return { ok: true, templates: templateStates() };
});
// --- Frei angelegte Seiten (Regeln, Über uns, …) ---
// Adressen, die schon vom Frontend oder Server belegt sind
const RESERVED_SLUGS = new Set([
'devlogs', 'roadmap', 'server', 'galerie', 'level', 'events', 'changelog',
'profil', 'commits', 'settings', 'dashboard', 'impressum', 'datenschutz',
'api', 'auth', 'webhooks', 'sso', 'health', 'assets', 'features', 'commands', 'docs',
]);
// Menü-Einträge + veröffentlichte Seiten — öffentlich
app.get('/api/pages', async () => ({ pages: listPublishedPages() }));
app.get('/api/pages/:slug', async (request, reply) => {
const page = getPage(String(request.params.slug));
if (!page || !page.published) {
// Entwürfe darf nur sehen, wer sie auch bearbeiten dürfte
const scopes = scopesOf(getSessionUser(request));
if (!page || !(scopes.includes('*') || scopes.includes('content'))) {
return reply.code(404).send({ error: 'Seite nicht gefunden' });
}
}
return { page: { ...page, published: Boolean(page.published), in_menu: Boolean(page.in_menu) } };
});
// Verwaltung (Scope content) — inklusive Entwürfen
app.get('/api/pages/all/list', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return {
pages: listPages().map((p) => ({
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
})),
};
});
app.put('/api/pages/:slug', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const slug = String(request.params.slug).trim().toLowerCase()
.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
const title = String(request.body?.title ?? '').trim();
if (!slug || !title) return reply.code(400).send({ error: 'Kürzel und Titel nötig' });
if (RESERVED_SLUGS.has(slug)) {
return reply.code(400).send({ error: `„${slug}" ist bereits vergeben — bitte anderes Kürzel` });
}
savePage({
slug,
title: title.slice(0, 80),
content: String(request.body?.content ?? '').slice(0, 50_000),
published: request.body?.published ? 1 : 0,
in_menu: request.body?.in_menu === false ? 0 : 1,
sort: Number(request.body?.sort) || 0,
});
logAudit(getSessionUser(request), 'seite gespeichert', `${title} (/${slug})`);
return { ok: true, slug, pages: listPages() };
});
app.delete('/api/pages/:slug', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
logAudit(getSessionUser(request), 'seite gelöscht', String(request.params.slug));
return { deleted: deletePage(String(request.params.slug)), pages: listPages() };
});
// Dienste-Kacheln fürs Portal — öffentlich
app.get('/api/services', async () => ({ services: listServices() }));
/** Dienst-Body prüfen; antwortet bei Fehlern selbst */
function parseServiceBody(request, reply) {
const body = request.body ?? {};
const name = String(body.name ?? '').trim();
const url = String(body.url ?? '').trim();
if (!name || !/^https?:\/\/.+/.test(url)) {
reply.code(400).send({ error: 'name und url (http/https) nötig' });
return null;
}
return {
name: name.slice(0, 60),
url: url.slice(0, 300),
icon: (String(body.icon ?? '').trim() || '🔗').slice(0, 8),
description: String(body.description ?? '').trim().slice(0, 160),
sort: Number(body.sort) || 0,
};
}
app.post('/api/services', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const service = parseServiceBody(request, reply);
if (!service) return;
const id = createService(service);
logAudit(getSessionUser(request), 'dienst hinzugefügt', service.name);
return { ok: true, services: listServices(), id };
});
app.put('/api/services/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const service = parseServiceBody(request, reply);
if (!service) return;
updateService({ ...service, id: Number(request.params.id) });
logAudit(getSessionUser(request), 'dienst geändert', service.name);
return { ok: true, services: listServices() };
});
app.delete('/api/services/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
logAudit(getSessionUser(request), 'dienst gelöscht', String(request.params.id));
return { deleted: deleteService(Number(request.params.id)), services: listServices() };
});
// Level-Bestenliste + Server-Aktivität — öffentlich.
// Avatare kommen aus dem Member-Cache: der ist durch den GuildMembers-Intent
// ohnehin gefüllt, kostet also keinen API-Aufruf. Wer nicht drin ist,
// bekommt kein Bild — die Liste soll nicht 50 Abrufe an Discord auslösen.
app.get('/api/levels', async () => {
const members = new Map();
for (const guild of client.guilds.cache.values()) {
for (const [id, member] of guild.members?.cache ?? []) {
if (!members.has(id)) members.set(id, member);
}
}
return {
levels: topLevels(50).map((row) => ({
...row,
avatar: members.get(row.user_id)?.displayAvatarURL?.({ size: 64 }) ?? null,
display_name: members.get(row.user_id)?.displayName ?? row.username,
})),
};
});
app.get('/api/serverstats', async () => ({
days: activityRange(30),
members: [...client.guilds.cache.values()].reduce((sum, g) => sum + (g.memberCount ?? 0), 0),
}));
// Discord-Events — öffentlich (5-min-Cache)
let eventsCache = { at: 0, data: null };
app.get('/api/events', async () => {
if (eventsCache.data && Date.now() - eventsCache.at < 5 * 60_000) return eventsCache.data;
const events = [];
for (const guild of client.guilds.cache.values()) {
const fetched = await guild.scheduledEvents?.fetch?.().catch(() => null);
for (const e of fetched?.values?.() ?? []) {
events.push({
name: e.name,
description: e.description ?? '',
start: e.scheduledStartAt?.toISOString() ?? null,
end: e.scheduledEndAt?.toISOString() ?? null,
url: e.url,
cover: e.coverImageURL?.({ size: 1024 }) ?? null,
interested: e.userCount ?? null,
});
}
}
events.sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
eventsCache = { at: Date.now(), data: { events } };
return eventsCache.data;
});
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
let roadmapCache = { at: 0, repo: null, data: null };
app.get('/api/roadmap', async (request, reply) => {
const repo = roadmapRepo();
if (roadmapCache.data && roadmapCache.repo === repo && Date.now() - roadmapCache.at < 5 * 60_000) {
return roadmapCache.data;
}
try {
const milestones = await getMilestones(repo);
const data = {
repo,
milestones: milestones.map((m) => ({
title: m.title,
description: m.description ?? '',
state: m.state, // 'open' | 'closed'
open: m.open_issues ?? 0,
closed: m.closed_issues ?? 0,
due_on: m.due_on ?? null,
})),
};
roadmapCache = { at: Date.now(), repo, data };
return data;
} catch (error) {
request.log.error({ err: error }, 'Roadmap-Abruf fehlgeschlagen');
// Letzten guten Stand weiterreichen, falls vorhanden
if (roadmapCache.data && roadmapCache.repo === repo) return roadmapCache.data;
return reply.code(502).send({ error: 'Roadmap nicht abrufbar' });
}
});
// RSS-Feed der Devlogs — öffentlich abonnierbar
app.get('/feed.xml', async (request, reply) => {
const { items } = listDevlogs(20, 0);
const esc = (s) =>
String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
const rssItems = items
.map((d) => {
const project = d.content.match(/^Devlog\s*[—–-]\s*(.+)$/im)?.[1]?.trim();
const title = `Devlog ${dateFmt.format(new Date(d.posted_at))}${project ? ` — ${project}` : ''}`;
return ` <item>
<title>${esc(title)}</title>
<link>${publicUrl()}/devlogs</link>
<guid isPermaLink="false">${esc(d.message_id)}</guid>
<pubDate>${new Date(d.posted_at).toUTCString()}</pubDate>
<description>${esc(d.content)}</description>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${brandFooter('DEVLOG')}</title>
<link>${publicUrl()}/devlogs</link>
<description>Entwicklungs-Updates, automatisch archiviert.</description>
<language>de</language>
${rssItems}
</channel>
</rss>`;
return reply.type('application/rss+xml; charset=utf-8').send(xml);
});
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
app.delete('/api/devlogs/:id', async (request, reply) => {
if (requireScope(request, reply, 'devlogs')) return;
const deleted = await removeDevlog(request.params.id);
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
return { deleted };
});
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
app.get('/api/commits', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const { limit, offset, page } = paging(request);
const { items, total } = listCommits(limit, offset);
return { items, total, page, pageSize: PAGE_SIZE };
});
// --- Settings (Admin) ---
/** Alle Textkanäle, die der Bot sehen kann (für die Dropdowns) */
// Ein Forum ist nicht „textBased" — es hat keine Nachrichten, nur Beitraege.
// Fuers Voting ist es trotzdem ein gueltiges Ziel, also muss es hier
// auftauchen; sonst steht es schlicht nicht zur Auswahl.
const FORUM_TYPEN = new Set([ChannelType.GuildForum, ChannelType.GuildMedia]);
const istWaehlbar = (ch) => ch.isTextBased?.() || FORUM_TYPEN.has(ch.type);
function listChannels() {
const channels = [];
for (const guild of client.guilds.cache.values()) {
for (const ch of guild.channels.cache.values()) {
if (istWaehlbar(ch) && ch.viewable !== false) {
channels.push({
id: ch.id,
name: ch.name,
guild: guild.name,
forum: FORUM_TYPEN.has(ch.type),
});
}
}
}
return channels.sort((a, b) => a.name.localeCompare(b.name));
}
/** Rollen, die der Bot vergeben kann (unter seiner höchsten Rolle, nicht managed) */
function listRoles() {
const roles = [];
for (const guild of client.guilds.cache.values()) {
for (const role of guild.roles?.cache?.values() ?? []) {
if (role.id === guild.id) continue; // @everyone
if (role.managed) continue; // Bot-/Integrations-Rollen
roles.push({ id: role.id, name: role.name, guild: guild.name });
}
}
return roles.sort((a, b) => a.name.localeCompare(b.name));
}
function currentSettings() {
return {
commit_channel_id: commitChannelId(),
devlog_channel_id: devlogChannelId(),
release_channel_id: releaseChannelId() ?? '',
devlog_ping_role_id: devlogPingRoleId() ?? '',
commit_feed_enabled: getSetting('commit_feed_enabled') !== '0',
commit_branch_filter: getSetting('commit_branch_filter') ?? '',
ignored_repos: getSetting('ignored_repos') ?? '',
weekly_recap_enabled: getSetting('weekly_recap_enabled') !== '0',
devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0',
bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame',
roadmap_repo: getSetting('roadmap_repo') ?? 'D4rkst3r/EcoGame',
watchdog_urls: getSetting('watchdog_urls') ?? '',
public_url: publicUrl(),
hub_url: getSetting('hub_url') ?? '',
bot_url: getSetting('bot_url') ?? '',
cookie_domain: getSetting('cookie_domain') ?? '',
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
backup_enabled: getSetting('backup_enabled') !== '0',
repo_backup_enabled: getSetting('repo_backup_enabled') === '1',
backup_channel_id: getSetting('backup_channel_id') ?? '',
playtester_role_id: getSetting('playtester_role_id') ?? '',
starboard_channel_id: getSetting('starboard_channel_id') ?? '',
starboard_threshold: Number(getSetting('starboard_threshold')) || 3,
screenshot_channel_id: getSetting('screenshot_channel_id') ?? '',
modmail_channel_id: getSetting('modmail_channel_id') ?? '',
welcome_channel_id: getSetting('welcome_channel_id') ?? '',
...(() => {
const card = welcomeCard();
return {
welcome_card_enabled: card.enabled,
welcome_card_bg: card.background,
welcome_card_accent: card.accent,
welcome_card_accent2: card.accent2,
welcome_card_text: card.text,
welcome_card_kicker: card.kicker,
welcome_card_sub: card.sub,
welcome_card_image: card.image,
welcome_card_watermark: card.watermark,
};
})(),
modlog_channel_id: getSetting('modlog_channel_id') ?? '',
status_channel_id: getSetting('status_channel_id') ?? '',
voting_channel_id: getSetting('voting_channel_id') ?? '',
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
autorole_id: getSetting('autorole_id') ?? '',
brand_name: getSetting('brand_name') ?? 'D4RKST3R',
brand_color: getSetting('brand_color') ?? '#f5c518',
brand_color2: getSetting('brand_color2') ?? '#ff4d00',
bot_status_type: getSetting('bot_status_type') ?? 'custom',
bot_status_text: getSetting('bot_status_text') ?? '',
discord_guild_id: getSetting('discord_guild_id') ?? '',
gitea_api_token_set: Boolean(giteaApiToken()),
sticky_roles_enabled: getSetting('sticky_roles_enabled') === '1',
levels_enabled: getSetting('levels_enabled') === '1',
levels_announce: getSetting('levels_announce') !== '0',
level_rewards: getSetting('level_rewards') ?? '',
bot_presence_status: getSetting('bot_presence_status') ?? 'online',
events_announce_channel_id: getSetting('events_announce_channel_id') ?? '',
social_announce_channel_id: getSetting('social_announce_channel_id') ?? '',
youtube_channel_id: getSetting('youtube_channel_id') ?? '',
twitch_channel: getSetting('twitch_channel') ?? '',
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
member_gate_enabled: getSetting('member_gate_enabled') !== '0',
discord_invite_url: getSetting('discord_invite_url') ?? '',
birthday_channel_id: getSetting('birthday_channel_id') ?? '',
birthday_role_id: getSetting('birthday_role_id') ?? '',
legal_name: getSetting('legal_name') ?? '',
legal_address: getSetting('legal_address') ?? '',
legal_email: getSetting('legal_email') ?? '',
legal_extra: getSetting('legal_extra') ?? '',
};
}
app.get('/api/settings', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
// Application-Daten (Beschreibung) nachladen — best effort
await client.application?.fetch?.().catch(() => {});
const stats = archiveStats();
return {
channels: listChannels(),
roles: listRoles(),
settings: currentSettings(),
status: {
botTag: client.user?.tag ?? null,
botName: client.user?.username ?? null,
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
botDescription: client.application?.description ?? '',
uptimeSeconds: Math.floor(process.uptime()),
// Laufzeit seit dem letzten Start sagt nur, wie lange es diesmal
// gutgegangen ist. Der Herzschlag zeigt die Woche davor mit.
uptime: heartbeatSerie(heartbeatBuckets(168), heartbeatFirst()),
ping: client.ws?.ping >= 0 ? Math.round(client.ws.ping) : null,
guilds: client.guilds.cache.size,
giteaTokenConfigured: Boolean(giteaApiToken()),
lastBackup: getSetting('last_backup'),
...stats,
},
};
});
app.put('/api/settings', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const body = request.body ?? {};
// Stand vor der Änderung — und zwar genau so, wie ihn GET /api/settings
// ausliefert (Einstellung ODER Env-Fallback). Nur daran lässt sich
// ablesen, was das Formular wirklich geändert hat.
const before = currentSettings();
const unchanged = (key, value) => value === String(before[key] ?? '');
// 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',
'legal_name', 'legal_address', 'legal_email', 'legal_extra',
'hub_url', 'bot_url', 'cookie_domain',
]) delete body[key];
}
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
const OPTIONAL_CHANNELS = [
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
'birthday_channel_id',
];
// Auch Devlog- und Commit-Kanal dürfen leer sein: leer heißt „noch nicht
// eingerichtet", und der Bot kommt damit klar. Vorher scheiterte das
// Speichern JEDER Einstellung, solange einer der beiden nicht gesetzt war
// — auf einer frischen Installation also immer.
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue;
// `?? ''` ist wichtig: nicht gesetzte Kanäle kommen als null aus der
// API zurück, und String(null) wäre "null" — also ein Kanal, den es
// nie gibt.
const value = String(body[key] ?? '');
// Nur geprüft, was sich tatsächlich ändert. Sonst blockiert ein
// inzwischen gelöschter Kanal das Speichern *aller* Einstellungen,
// weil das Formular ihn unverändert mitschickt.
if (unchanged(key, value)) continue;
if (value === '') {
setSetting(key, '');
continue;
}
const ch = client.channels.cache.get(value);
if (!ch || !istWaehlbar(ch)) {
return reply.code(400).send({ error: `${key}: Kanal nicht gefunden` });
}
setSetting(key, value);
}
// Rollen: müssen existieren ('' = Feature aus)
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id']) {
if (body[key] === undefined) continue;
const value = String(body[key] ?? ''); // null = keine Rolle, siehe oben
if (unchanged(key, value)) continue; // unverändert → nicht prüfen
if (value !== '') {
const exists = [...client.guilds.cache.values()].some((g) =>
g.roles?.cache?.has?.(value)
);
if (!exists) {
return reply.code(400).send({ error: `${key}: Rolle nicht gefunden` });
}
}
setSetting(key, value);
}
// Starboard-Schwellwert: 150
if (body.starboard_threshold !== undefined) {
const n = Number(body.starboard_threshold);
if (!Number.isInteger(n) || n < 1 || n > 50) {
return reply.code(400).send({ error: 'starboard_threshold: 150' });
}
setSetting('starboard_threshold', String(n));
}
if (body.commit_feed_enabled !== undefined) {
setSetting('commit_feed_enabled', body.commit_feed_enabled ? '1' : '0');
}
if (body.weekly_recap_enabled !== undefined) {
setSetting('weekly_recap_enabled', body.weekly_recap_enabled ? '1' : '0');
}
if (body.devlog_threads_enabled !== undefined) {
setSetting('devlog_threads_enabled', body.devlog_threads_enabled ? '1' : '0');
}
if (body.backup_enabled !== undefined) {
setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
}
if (body.repo_backup_enabled !== undefined) {
setSetting('repo_backup_enabled', body.repo_backup_enabled ? '1' : '0');
}
if (body.sticky_roles_enabled !== undefined) {
setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0');
}
if (body.member_gate_enabled !== undefined) {
setSetting('member_gate_enabled', body.member_gate_enabled ? '1' : '0');
}
if (body.discord_invite_url !== undefined) {
const value = String(body.discord_invite_url).trim();
if (value && !/^https:\/\//.test(value)) {
return reply.code(400).send({ error: 'discord_invite_url: https-Link erwartet' });
}
setSetting('discord_invite_url', value);
}
if (body.levels_enabled !== undefined) {
setSetting('levels_enabled', body.levels_enabled ? '1' : '0');
}
if (body.levels_announce !== undefined) {
setSetting('levels_announce', body.levels_announce ? '1' : '0');
}
if (body.level_rewards !== undefined) {
setSetting('level_rewards', String(body.level_rewards).trim());
}
// Willkommens-Karte
if (body.welcome_card_enabled !== undefined) {
setSetting('welcome_card_enabled', body.welcome_card_enabled ? '1' : '0');
}
if (body.welcome_card_watermark !== undefined) {
setSetting('welcome_card_watermark', body.welcome_card_watermark ? '1' : '0');
}
for (const key of ['welcome_card_bg', 'welcome_card_accent', 'welcome_card_accent2', 'welcome_card_text']) {
if (body[key] === undefined) continue;
const value = String(body[key]).trim();
if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
return reply.code(400).send({ error: `${key}: Hex-Farbe wie #f5c518 erwartet` });
}
setSetting(key, value.toLowerCase());
}
if (body.welcome_card_kicker !== undefined) {
setSetting('welcome_card_kicker', String(body.welcome_card_kicker).slice(0, 24));
}
if (body.welcome_card_sub !== undefined) {
setSetting('welcome_card_sub', String(body.welcome_card_sub).slice(0, 60));
}
if (body.welcome_card_image !== undefined) {
const value = String(body.welcome_card_image).trim();
if (value && !/^https?:\/\//i.test(value)) {
return reply.code(400).send({ error: 'welcome_card_image: Bild-Adresse mit http(s) erwartet' });
}
setSetting('welcome_card_image', value);
}
// Branding
if (body.brand_name !== undefined) {
setSetting('brand_name', String(body.brand_name).trim().slice(0, 40) || 'D4RKST3R');
}
for (const key of ['brand_color', 'brand_color2']) {
if (body[key] === undefined) continue;
const value = String(body[key]).trim();
if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
return reply.code(400).send({ error: `${key}: Hex-Farbe wie #f5c518 erwartet` });
}
setSetting(key, value.toLowerCase());
}
// Bot-Status (Presence) — wird sofort angewendet
let statusChanged = false;
if (body.bot_status_type !== undefined) {
if (!['playing', 'watching', 'listening', 'custom'].includes(body.bot_status_type)) {
return reply.code(400).send({ error: 'bot_status_type ungültig' });
}
setSetting('bot_status_type', body.bot_status_type);
statusChanged = true;
}
if (body.bot_status_text !== undefined) {
setSetting('bot_status_text', String(body.bot_status_text).trim().slice(0, 120));
statusChanged = true;
}
if (body.bot_presence_status !== undefined) {
if (!['online', 'idle', 'dnd'].includes(body.bot_presence_status)) {
return reply.code(400).send({ error: 'bot_presence_status ungültig' });
}
setSetting('bot_presence_status', body.bot_presence_status);
statusChanged = true;
}
// Bot-Name (Discord rate-limitet Umbenennen stark)
if (body.bot_name !== undefined && String(body.bot_name).trim() &&
String(body.bot_name).trim() !== client.user?.username) {
try {
await client.user.setUsername(String(body.bot_name).trim().slice(0, 32));
} catch (error) {
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) {
const { applyBotStatus } = await import('../bot/presence.js');
applyBotStatus(client);
}
// Aus der Env verlagerbar: Guild-ID (greift beim nächsten Start) + Gitea-Token (write-only)
if (body.discord_guild_id !== undefined) {
setSetting('discord_guild_id', String(body.discord_guild_id).trim());
}
if (body.gitea_api_token !== undefined && String(body.gitea_api_token).trim() !== '') {
setSetting('gitea_api_token', String(body.gitea_api_token).trim());
}
// Social + Temp-Voice
for (const key of ['youtube_channel_id', 'twitch_channel', 'tempvoice_channel_id']) {
if (body[key] !== undefined) setSetting(key, String(body[key]).trim());
}
for (const key of ['twitch_client_id', 'twitch_client_secret']) {
if (body[key] !== undefined && 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']) {
if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim());
}
}
// Impressums-Angaben (Owner-only, oben bereits gefiltert)
for (const key of ['legal_name', 'legal_address', 'legal_email', 'legal_extra']) {
if (body[key] !== undefined) setSetting(key, String(body[key]).trim().slice(0, 500));
}
// Getrennte Adressen für Hub und Produktseite (leer = beides gleich)
for (const key of ['hub_url', 'bot_url']) {
if (body[key] === undefined) continue;
const value = String(body[key]).trim().replace(/\/$/, '');
if (value && !/^https?:\/\/.+/.test(value)) {
return reply.code(400).send({ error: `${key}: muss mit http(s):// beginnen` });
}
setSetting(key, value);
}
if (body.cookie_domain !== undefined) {
const value = String(body.cookie_domain).trim();
if (value && !/^\.?[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) {
return reply.code(400).send({ error: 'cookie_domain: z. B. .d4rkst3r.de' });
}
setSetting('cookie_domain', value);
}
// 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');
logAudit(getSessionUser(request), 'settings', Object.keys(body).join(', ').slice(0, 300));
return { ok: true, settings: currentSettings() };
});
// --- Rollen-Menüs (Admin) — Carl-Bot-Ersatz ---
/** Menü-Body validieren + normalisieren; sendet bei Fehler selbst die Antwort */
function parseMenuBody(request, reply) {
const body = request.body ?? {};
const title = String(body.title ?? '').trim();
if (!title) {
reply.code(400).send({ error: 'title nötig' });
return null;
}
const entries = (Array.isArray(body.entries) ? body.entries : [])
.map((e) => ({
emoji: String(e.emoji ?? '').trim(),
label: String(e.label ?? '').trim(),
role_id: String(e.role_id ?? '').trim(),
style: ['primary', 'secondary', 'success', 'danger'].includes(e.style) ? e.style : 'secondary',
}))
.filter((e) => e.label && e.role_id);
if (entries.length > MAX_ENTRIES) {
reply.code(400).send({ error: `maximal ${MAX_ENTRIES} Rollen pro Menü` });
return null;
}
// Rollen müssen existieren, keine Duplikate
const seen = new Set();
for (const e of entries) {
if (seen.has(e.role_id)) {
reply.code(400).send({ error: 'doppelte Rolle im Menü' });
return null;
}
seen.add(e.role_id);
const exists = [...client.guilds.cache.values()].some((g) => g.roles?.cache?.has?.(e.role_id));
if (!exists) {
reply.code(400).send({ error: `Rolle ${e.role_id} nicht gefunden` });
return null;
}
}
const channelId = String(body.channel_id ?? '').trim();
if (channelId && !client.channels.cache.get(channelId)?.isTextBased?.()) {
reply.code(400).send({ error: 'Kanal nicht gefunden' });
return null;
}
return {
title: title.slice(0, 200),
description: String(body.description ?? '').trim().slice(0, 2000),
channel_id: channelId || null,
exclusive: body.exclusive ? 1 : 0,
entries: JSON.stringify(entries),
};
}
const menuToJson = (m) => ({ ...m, exclusive: Boolean(m.exclusive), entries: JSON.parse(m.entries || '[]') });
app.get('/api/rolemenus', async (request, reply) => {
if (requireScope(request, reply, 'rollen')) return;
return { menus: listRoleMenus().map(menuToJson) };
});
app.post('/api/rolemenus', async (request, reply) => {
if (requireScope(request, reply, 'rollen')) return;
const menu = parseMenuBody(request, reply);
if (!menu) return;
const id = createRoleMenu(menu);
request.log.info(`Rollen-Menü ${id} erstellt`);
logAudit(getSessionUser(request), 'rollen-menü erstellt', menu.title);
return { ok: true, menu: menuToJson(getRoleMenu(id)) };
});
app.put('/api/rolemenus/:id', async (request, reply) => {
if (requireScope(request, reply, 'rollen')) return;
const id = Number(request.params.id);
if (!getRoleMenu(id)) return reply.code(404).send({ error: 'Menü nicht gefunden' });
const menu = parseMenuBody(request, reply);
if (!menu) return;
updateRoleMenu({ ...menu, id });
// Bereits gepostet? Dann den Discord-Post direkt mitziehen
let published = false;
if (getRoleMenu(id).message_id) {
try {
await publishRoleMenu(client, id);
published = true;
} catch (error) {
request.log.warn(`Menü ${id}: Discord-Update fehlgeschlagen: ${error.message}`);
}
}
logAudit(getSessionUser(request), 'rollen-menü geändert', menu.title);
return { ok: true, published, menu: menuToJson(getRoleMenu(id)) };
});
app.post('/api/rolemenus/:id/publish', async (request, reply) => {
if (requireScope(request, reply, 'rollen')) return;
try {
const messageId = await publishRoleMenu(client, Number(request.params.id));
request.log.info(`Rollen-Menü ${request.params.id} publiziert (${messageId})`);
return { ok: true, message_id: messageId };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
app.delete('/api/rolemenus/:id', async (request, reply) => {
if (requireScope(request, reply, 'rollen')) return;
const menu = getRoleMenu(Number(request.params.id));
if (!menu) return { deleted: false };
await unpublishRoleMenu(client, menu);
deleteRoleMenu(menu.id);
request.log.info(`Rollen-Menü ${menu.id} gelöscht`);
logAudit(getSessionUser(request), 'rollen-menü gelöscht', menu.title);
return { deleted: true };
});
// --- Alpha-Keys (Admin) ---
/** Wortlaut der Schlüssel-DM — an zwei Stellen gebraucht */
const keyText = (key) =>
`🎟️ **Dein EcoGame-Alpha-Key:**\n\`\`\`\n${key}\n\`\`\`\nViel Spaß beim Testen — Feedback gern per \`/bug\` oder \`/wunsch\`! 💛`;
const keyStatus = () => {
const belegt = new Map(assignedAlphaKeys().map((k) => [k.assigned_to, k]));
return {
free: freeAlphaKeyCount(),
// Der freie Vorrat selbst, nicht nur seine Größe: einen vertippten
// Schlüssel findet man sonst nie wieder
freieKeys: freeAlphaKeys(),
assigned: assignedAlphaKeys(),
// Jeder Playtester mit seinem Schlüssel daneben — die Liste ist die
// eine Stelle, an der man beides zusammen sieht
playtesters: listPlaytesters().map((t) => ({
...t,
key: belegt.get(t.user_id)?.key ?? null,
keySeit: belegt.get(t.user_id)?.assigned_at ?? null,
})),
playtestersWithout: listPlaytesters().filter((t) => !alphaKeyOf(t.user_id)),
};
};
/** Einen Schlüssel per DM zustellen. Klappt es nicht, bleibt er frei. */
async function keySenden(userId) {
const key = reserveAlphaKey(userId);
if (!key) return { ok: false, grund: 'Vorrat leer' };
try {
const user = await client.users.fetch(userId);
await user.send(keyText(key));
return { ok: true, key };
} catch {
unreserveAlphaKey(key);
return { ok: false, grund: 'Direktnachricht nicht zustellbar' };
}
}
app.get('/api/alphakeys', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
return keyStatus();
});
app.post('/api/alphakeys', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const keys = String(request.body?.keys ?? '')
.split(/\r?\n/)
.map((k) => k.trim())
.filter(Boolean);
if (keys.length === 0) return reply.code(400).send({ error: 'keys: eine pro Zeile' });
const added = addAlphaKeys(keys);
request.log.info(`${added} Alpha-Keys hinzugefügt`);
return { ok: true, added, ...keyStatus() };
});
// Verteilen: jeder Playtester ohne Key bekommt einen per DM (Key bleibt frei bei DM-Fehler)
app.post('/api/alphakeys/distribute', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
let sent = 0;
const failed = [];
for (const p of keyStatus().playtestersWithout) {
const res = await keySenden(p.user_id);
if (!res.ok && res.grund === 'Vorrat leer') break;
if (res.ok) sent++;
else failed.push(p.username ?? p.user_id);
}
request.log.info(`Alpha-Keys verteilt: ${sent} gesendet, ${failed.length} fehlgeschlagen`);
logAudit(getSessionUser(request), 'alpha-keys verteilt', `${sent} verschickt`);
return { ok: true, sent, failed, ...keyStatus() };
});
// Gezielt an eine Person — häufiger gebraucht als „alle auf einmal"
app.post('/api/alphakeys/assign', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const userId = String(request.body?.user_id ?? '').trim();
if (!userId) return reply.code(400).send({ error: 'user_id fehlt.' });
if (alphaKeyOf(userId)) return reply.code(400).send({ error: 'Hat schon einen Schlüssel.' });
const res = await keySenden(userId);
if (!res.ok) return reply.code(400).send({ error: res.grund });
logAudit(getSessionUser(request), 'alpha-key vergeben', userId);
return { ok: true, ...keyStatus() };
});
// Zurückziehen: der Schlüssel landet wieder im Vorrat. Die Person hat ihn
// per DM natürlich weiterhin — das ist eine Buchhaltung, keine Sperre.
app.post('/api/alphakeys/revoke', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const key = String(request.body?.key ?? '').trim();
if (!key) return reply.code(400).send({ error: 'key fehlt.' });
unreserveAlphaKey(key);
logAudit(getSessionUser(request), 'alpha-key zurückgezogen', key);
return { ok: true, ...keyStatus() };
});
app.delete('/api/alphakeys/:key', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const key = decodeURIComponent(String(request.params.key));
if (!deleteFreeAlphaKey(key)) {
return reply.code(400).send({ error: 'Nur freie Schlüssel lassen sich löschen.' });
}
logAudit(getSessionUser(request), 'alpha-key gelöscht', key);
return { ok: true, ...keyStatus() };
});
// --- Bewerbungs-Formulare (Admin) ---
const formToJson = (f) => ({ ...f, questions: JSON.parse(f.questions || '[]') });
function parseFormBody(request, reply) {
const body = request.body ?? {};
const title = String(body.title ?? '').trim();
if (!title) {
reply.code(400).send({ error: 'title nötig' });
return null;
}
const questions = (Array.isArray(body.questions) ? body.questions : [])
.map((q) => String(q).trim())
.filter(Boolean)
.slice(0, MAX_QUESTIONS);
for (const key of ['review_channel_id', 'post_channel_id']) {
const v = String(body[key] ?? '');
if (v && !client.channels.cache.get(v)?.isTextBased?.()) {
reply.code(400).send({ error: `${key}: Kanal nicht gefunden` });
return null;
}
}
const roleId = String(body.approve_role_id ?? '');
if (roleId && ![...client.guilds.cache.values()].some((g) => g.roles?.cache?.has?.(roleId))) {
reply.code(400).send({ error: 'approve_role_id: Rolle nicht gefunden' });
return null;
}
return {
title: title.slice(0, 100),
description: String(body.description ?? '').trim().slice(0, 1000),
review_channel_id: String(body.review_channel_id ?? '') || null,
approve_role_id: roleId || null,
post_channel_id: String(body.post_channel_id ?? '') || null,
questions: JSON.stringify(questions),
playtester: body.playtester ? 1 : 0,
};
}
/** Nur ein Formular kann das Playtester-Formular sein */
function playtesterExklusiv(form, id) {
if (form.playtester) clearOtherPlaytesterForms(id);
}
app.get('/api/appforms', async (request, reply) => {
if (requireScope(request, reply, 'bewerbungen')) return;
return { forms: listAppForms().map(formToJson) };
});
app.post('/api/appforms', async (request, reply) => {
if (requireScope(request, reply, 'bewerbungen')) return;
const form = parseFormBody(request, reply);
if (!form) return;
const id = createAppForm(form);
playtesterExklusiv(form, id);
return { ok: true, form: formToJson(getAppForm(id)), forms: listAppForms().map(formToJson) };
});
app.put('/api/appforms/:id', async (request, reply) => {
if (requireScope(request, reply, 'bewerbungen')) return;
const id = Number(request.params.id);
if (!getAppForm(id)) return reply.code(404).send({ error: 'Formular nicht gefunden' });
const form = parseFormBody(request, reply);
if (!form) return;
updateAppForm({ ...form, id });
playtesterExklusiv(form, id);
if (getAppForm(id).message_id) {
await publishAppForm(client, id).catch((e) =>
request.log.warn(`Formular ${id}: Discord-Update fehlgeschlagen: ${e.message}`)
);
}
return { ok: true, form: formToJson(getAppForm(id)), forms: listAppForms().map(formToJson) };
});
app.post('/api/appforms/:id/publish', async (request, reply) => {
if (requireScope(request, reply, 'bewerbungen')) return;
try {
const messageId = await publishAppForm(client, Number(request.params.id));
return { ok: true, message_id: messageId };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
app.delete('/api/appforms/:id', async (request, reply) => {
if (requireScope(request, reply, 'bewerbungen')) return;
const form = getAppForm(Number(request.params.id));
if (!form) return { deleted: false };
await unpublishAppForm(client, form);
deleteAppForm(form.id);
return { deleted: true };
});
// --- Game-Server (Admin) — DiscordGSM-Stil ---
function parseServerBody(request, reply) {
const body = request.body ?? {};
const name = String(body.name ?? '').trim();
const type = String(body.type ?? 'fivem').trim().toLowerCase().replace(/[^a-z0-9]/g, '');
const queryUrl = String(body.query_url ?? '').trim().replace(/\/$/, '');
const host = String(body.host ?? '').trim();
const port = Number(body.port) || null;
if (!name || !type) {
reply.code(400).send({ error: 'name und type nötig' });
return null;
}
if (['fivem', 'http'].includes(type)) {
if (!/^https?:\/\/.+/.test(queryUrl)) {
reply.code(400).send({ error: 'query_url (http/https) nötig' });
return null;
}
} else if (!host) {
// gamedig-Typ: Host (+ optionaler Port) statt URL
reply.code(400).send({ error: 'host nötig (gamedig-Query)' });
return null;
}
return {
name: name.slice(0, 60),
type,
query_url: queryUrl,
address: String(body.address ?? '').trim().slice(0, 120),
host: host.slice(0, 120),
port,
connect_url: String(body.connect_url ?? '').trim().slice(0, 200),
image_url: /^https:\/\/.+/.test(String(body.image_url ?? '').trim())
? String(body.image_url).trim().slice(0, 300)
: '',
token: String(body.token ?? '').trim().slice(0, 200),
// Nur http(s) — daraus wird ein Link-Knopf, und Discord weist
// alles andere ab. Unbrauchbares lieber hier verwerfen.
mods_url: /^https?:\/\/.+/i.test(String(body.mods_url ?? '').trim())
? String(body.mods_url).trim().slice(0, 300)
: '',
};
}
app.get('/api/gameservers', async (request, reply) => {
if (requireScope(request, reply, 'server')) return;
return { servers: listGameservers() };
});
// Alle Spiele, die gamedig abfragen kann — damit die Auswahl im Panel nicht
// an einer von Hand gepflegten Liste hängt und jedes Update automatisch
// neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port
// gilt und ob ein Zugangs-Code nötig ist.
app.get('/api/gamedig-games', async (request, reply) => {
if (requireScope(request, reply, 'server')) return;
return { games: spielListe() };
});
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);
logAudit(getSessionUser(request), 'server hinzugefügt', server.name);
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 });
logAudit(getSessionUser(request), 'server geändert', server.name);
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);
logAudit(getSessionUser(request), 'server gelöscht', server.name);
return { deleted: true };
});
// --- Triggers (Admin) ---
app.get('/api/triggers', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { triggers: listTriggers() };
});
app.put('/api/triggers', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
const replyText = String(request.body?.reply ?? '').trim();
if (keyword.length < 3 || !replyText) {
return reply.code(400).send({ error: 'keyword (min 3 Zeichen) und reply nötig' });
}
saveTrigger(keyword.slice(0, 60), replyText.slice(0, 2000));
return { ok: true };
});
app.delete('/api/triggers/:keyword', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
});
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
app.get('/api/tags', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { tags: listTags() };
});
app.put('/api/tags/:name', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const name = String(request.params.name).trim().toLowerCase();
const content = String(request.body?.content ?? '').trim();
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
return reply.code(400).send({ error: 'Name: nur Kleinbuchstaben, Zahlen, - und _ (max 40)' });
}
if (!content) return reply.code(400).send({ error: 'content nötig' });
saveTag(name, content.slice(0, 2000));
return { ok: true, name };
});
app.delete('/api/tags/:name', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
});
// --- Geplante Posts (Admin) ---
app.get('/api/scheduled', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { posts: listScheduledPosts() };
});
app.post('/api/scheduled', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const body = request.body ?? {};
const channelId = String(body.channel_id ?? '');
const content = String(body.content ?? '').trim();
if (!client.channels.cache.get(channelId)?.isTextBased?.()) {
return reply.code(400).send({ error: 'Kanal nicht gefunden' });
}
if (!content) return reply.code(400).send({ error: 'content nötig' });
let nextRunAt;
try {
nextRunAt = computeNextRun({
type: body.type, date: body.date, time: body.time, weekday: body.weekday,
});
} catch (error) {
return reply.code(400).send({ error: error.message });
}
const id = createScheduledPost({
channel_id: channelId,
content: content.slice(0, 4000),
embed_title: String(body.embed_title ?? '').trim() || null,
type: body.type,
date: body.date ?? null,
time: body.time,
weekday: body.weekday ?? null,
next_run_at: nextRunAt,
});
request.log.info(`Geplanter Post ${id} (${body.type}) → ${nextRunAt} UTC`);
return { ok: true, id, next_run_at: nextRunAt };
});
app.delete('/api/scheduled/:id', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteScheduledPost(Number(request.params.id)) };
});
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
app.post('/api/compose', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle, embed } = request.body ?? {};
const text = String(content ?? '').trim();
const cleanEmbed = embed ? sanitizeEmbed(embed) : null;
if (!channelId || (!text && !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' });
}
// Drei Modi: volles Embed (Composer v2) > Embed-Titel-Kurzform > Plaintext
let payload;
if (cleanEmbed) {
payload = { content: text ? text.slice(0, 2000) : null, embeds: [cleanEmbed] };
} else if (embedTitle?.trim()) {
payload = {
content: null,
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setTitle(String(embedTitle).slice(0, 200))
.setDescription(text.slice(0, 4000))
.setFooter({ text: brandName() })
.setTimestamp(),
],
};
} else {
payload = { content: text.slice(0, 2000), embeds: [] };
}
if (messageId) {
const message = await channel.messages.fetch(String(messageId)).catch(() => null);
if (!message) return reply.code(404).send({ error: 'Nachricht nicht gefunden' });
if (message.author?.id !== client.user?.id) {
return reply.code(403).send({ error: 'Nur eigene Bot-Nachrichten sind editierbar' });
}
await message.edit(payload);
request.log.info(`Composer: Nachricht ${messageId} bearbeitet`);
logAudit(getSessionUser(request), 'composer bearbeitet', `#${channel.name ?? channelId}`);
return { ok: true, edited: true, message_id: String(messageId) };
}
const message = await channel.send(payload);
const { maybeCrosspost } = await import('../embeds.js');
await maybeCrosspost(message);
request.log.info(`Composer: Nachricht ${message.id} gesendet`);
logAudit(getSessionUser(request), 'composer gesendet', `#${channel.name ?? channelId}`);
return { ok: true, edited: false, message_id: message.id };
});
// Bot-Avatar/-Banner setzen (Admin) — Bild als Base64-Data-URL im Body
app.post('/api/branding/:target', { bodyLimit: 12 * 1024 * 1024 }, async (request, reply) => {
if (requireAdmin(request, reply)) return;
const target = request.params.target;
if (!['avatar', 'banner'].includes(target)) {
return reply.code(400).send({ error: 'target: avatar oder banner' });
}
const dataUrl = String(request.body?.data ?? '');
const match = dataUrl.match(/^data:image\/(png|jpe?g|gif|webp);base64,(.+)$/s);
if (!match) return reply.code(400).send({ error: 'data: Base64-Data-URL eines Bildes erwartet' });
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length > 8 * 1024 * 1024) {
return reply.code(400).send({ error: 'Bild zu groß (max 8 MB)' });
}
try {
if (target === 'avatar') await client.user.setAvatar(buffer);
else await client.user.setBanner(buffer);
request.log.info(`Bot-${target} aktualisiert (${(buffer.length / 1024).toFixed(0)} KB)`);
logAudit(getSessionUser(request), 'branding', `bot-${target} geändert`);
return { ok: true };
} catch (error) {
request.log.error({ err: error }, `Bot-${target} fehlgeschlagen`);
// Discord rate-limitet Avatar-Änderungen hart (~2 pro 10 min)
return reply.code(502).send({ error: `Discord lehnt ab: ${error.message?.slice(0, 120)}` });
}
});
// --- Composer-Vorlagen (Scope: content) ---
app.get('/api/templates', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { templates: listTemplates() };
});
app.put('/api/templates/:name', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
const name = String(request.params.name).trim().slice(0, 60);
if (!name) return reply.code(400).send({ error: 'name nötig' });
const payload = {
content: String(request.body?.content ?? '').slice(0, 2000),
embed: request.body?.embed ? sanitizeEmbed(request.body.embed) : null,
};
if (!payload.content && !payload.embed) {
return reply.code(400).send({ error: 'Vorlage ist leer' });
}
saveTemplate(name, payload);
logAudit(getSessionUser(request), 'vorlage gespeichert', name);
return { ok: true, templates: listTemplates() };
});
app.delete('/api/templates/:name', async (request, reply) => {
if (requireScope(request, reply, 'content')) return;
return { deleted: deleteTemplate(String(request.params.name)), templates: listTemplates() };
});
// --- Member-Bereich: Profil, Rollen-Selfservice, Wunsch-Voting ---
// Membership kurz cachen, damit nicht jeder Klick die Discord-API trifft
const memberCheckCache = new Map(); // userId → { ok, at }
async function requireMember(request, reply) {
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'login required' });
const cached = memberCheckCache.get(user.id);
if (cached && Date.now() - cached.at < 5 * 60_000) {
return cached.ok ? null : reply.code(403).send({ error: 'not a member' });
}
const ok = await isGuildMember(client, user.id);
memberCheckCache.set(user.id, { ok, at: Date.now() });
return ok ? null : reply.code(403).send({ error: 'not a member' });
}
/** Guild-Member des eingeloggten Users holen (null wenn nicht auffindbar) */
async function fetchSelfMember(request) {
const user = getSessionUser(request);
const guildId = discordGuildId() ?? [...client.guilds.cache.keys()][0];
if (!user || !guildId) return null;
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
return guild ? await guild.members.fetch(user.id).catch(() => null) : null;
}
app.get('/api/invite', async () => ({ invite: discordInviteUrl() }));
// Betreiber-Angaben für Impressum/Datenschutz — öffentlich (müssen es sein)
app.get('/api/legal', async () => ({
legal: legalInfo(),
brand: brandName(),
publicUrl: publicUrl(),
// Für Querverweise zwischen den beiden Seiten
hubUrl: hubUrl(),
botUrl: botUrl(),
split: hubUrl() !== botUrl(),
guildConfigured: Boolean(discordGuildId()),
}));
// DSGVO Art. 17: eigene Daten löschen
app.delete('/api/profile', async (request, reply) => {
if (await requireMember(request, reply)) return;
const user = getSessionUser(request);
const deleted = deleteUserData(user.id);
request.log.info(`Datenlöschung für ${user.id}: ${JSON.stringify(deleted)}`);
logAudit(user, 'daten gelöscht (dsgvo)', user.username);
return { ok: true, deleted };
});
app.get('/api/profile', async (request, reply) => {
if (await requireMember(request, reply)) return;
const user = getSessionUser(request);
const member = await fetchSelfMember(request);
const roles = member
? [...member.roles.cache.values()]
.filter((r) => r.id !== member.guild.id)
.sort((a, b) => b.position - a.position)
.map((r) => ({ id: r.id, name: r.name, color: r.hexColor === '#000000' ? null : r.hexColor }))
: [];
const row = getLevelRow(user.id);
let level = null;
if (row) {
const rank = topLevels(1000).findIndex((l) => l.user_id === user.id) + 1;
const base = xpForLevel(row.level);
const next = xpForLevel(row.level + 1);
level = {
level: row.level, xp: row.xp, rank: rank || null,
progress: row.xp - base, needed: next - base,
};
}
return {
user: {
...user,
displayName: member?.displayName ?? user.username,
avatar: member?.displayAvatarURL?.({ size: 128 }) ?? user.avatar,
joinedAt: member?.joinedAt?.toISOString?.() ?? null,
},
roles,
level,
playtester: isPlaytester(user.id),
alphaKey: alphaKeyOf(user.id),
};
});
// Veröffentlichte Rollen-Menüs + eigener Rollen-Stand
app.get('/api/myroles', async (request, reply) => {
if (await requireMember(request, reply)) return;
const member = await fetchSelfMember(request);
if (!member) return reply.code(404).send({ error: 'Member nicht gefunden' });
const menus = listRoleMenus()
.filter((m) => m.message_id) // nur veröffentlichte
.map((m) => ({
id: m.id, title: m.title, description: m.description, exclusive: Boolean(m.exclusive),
entries: JSON.parse(m.entries || '[]').map((e) => ({
role_id: e.role_id, label: e.label, emoji: e.emoji ?? '', style: e.style ?? 'secondary',
has: member.roles.cache.has(e.role_id),
})),
}));
return { menus };
});
// Rolle togglen — nur Rollen aus veröffentlichten Menüs, Exklusiv-Logik wie im Button
app.post('/api/myroles/toggle', async (request, reply) => {
if (await requireMember(request, reply)) return;
const menuId = Number(request.body?.menu_id);
const roleId = String(request.body?.role_id ?? '');
const menu = getRoleMenu(menuId);
const entries = menu ? JSON.parse(menu.entries || '[]') : [];
const entry = entries.find((e) => e.role_id === roleId);
if (!menu?.message_id || !entry) {
return reply.code(400).send({ error: 'Rolle gehört zu keinem aktiven Menü' });
}
const member = await fetchSelfMember(request);
if (!member) return reply.code(404).send({ error: 'Member nicht gefunden' });
try {
if (member.roles.cache.has(roleId)) {
await member.roles.remove(roleId);
return { has: false, label: entry.label };
}
if (menu.exclusive) {
for (const other of entries) {
if (other.role_id !== roleId && member.roles.cache.has(other.role_id)) {
await member.roles.remove(other.role_id).catch(() => {});
}
}
}
await member.roles.add(roleId);
return { has: true, label: entry.label };
} catch {
return reply.code(502).send({ error: 'Rolle konnte nicht geändert werden — Bot-Rechte prüfen' });
}
});
// Wunsch einreichen — läuft durch denselben Weg wie /wunsch, damit ein
// Wunsch von hier genauso aussieht: Embed, 👍 und Thread zum Reden
app.post('/api/wishes', async (request, reply) => {
if (await requireMember(request, reply)) return;
const user = getSessionUser(request);
const idea = String(request.body?.idea ?? '').trim().slice(0, 500);
if (idea.length < 5) return reply.code(400).send({ error: 'Idee zu kurz' });
const categoryId = Number(request.body?.category_id) || null;
try {
const { id } = await wunschAnlegen(client, {
idee: idea,
autor: user.username,
autorId: user.id,
avatar: user.avatar ?? null,
categoryId,
});
return { ok: true, id };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
// Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist
// dieselbe Zeile in wish_votes
app.post('/api/wishes/:id/vote', async (request, reply) => {
if (await requireMember(request, reply)) return;
const user = getSessionUser(request);
const id = Number(request.params.id);
const wunsch = getWish(id);
if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
return { voted: toggleWishVote(id, user.id) };
});
// Ein einzelner Wunsch mit allem, was im Thread darunter steht.
// Muss vor /api/wishes/:id/... stehen? Nein — andere Tiefe, kein Konflikt.
app.get('/api/wishes/:id', async (request, reply) => {
const id = Number(request.params.id);
const wunsch = getWish(id);
if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
const user = getSessionUser(request);
const gid = discordGuildId();
return {
wunsch: {
...wunsch,
voted: user ? myWishVotes(user.id).includes(id) : false,
thread_url: gid && wunsch.thread_id
? `https://discord.com/channels/${gid}/${wunsch.thread_id}`
: null,
},
kommentare: wishComments(id),
canVote: Boolean(user),
};
});
// Suche vor dem Einreichen — Doppler gar nicht erst entstehen lassen
app.get('/api/wishes/suche', async (request) => {
const q = String(request.query?.q ?? '').trim();
return { treffer: q.length >= 3 ? searchWishes(q) : [] };
});
// Bereiche, in die ein Wunsch faellt. Ohne Eintrag bleibt die Liste eine
// Liste — Schubladen lohnen sich erst ab einer gewissen Zahl.
app.get('/api/wish-categories', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return { bereiche: listWishCategories() };
});
function bereichBody(request, reply) {
const name = String(request.body?.name ?? '').trim();
if (!name) {
reply.code(400).send({ error: 'Name fehlt.' });
return null;
}
return {
name: name.slice(0, 40),
emoji: String(request.body?.emoji ?? '').trim().slice(0, 32),
sort: Number(request.body?.sort) || 0,
};
}
app.post('/api/wish-categories', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const b = bereichBody(request, reply);
if (!b) return;
createWishCategory(b);
logAudit(getSessionUser(request), 'wunsch-bereich angelegt', b.name);
return { ok: true, bereiche: listWishCategories() };
});
app.put('/api/wish-categories/:id', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const b = bereichBody(request, reply);
if (!b) return;
updateWishCategory({ ...b, id: Number(request.params.id) });
logAudit(getSessionUser(request), 'wunsch-bereich geaendert', b.name);
return { ok: true, bereiche: listWishCategories() };
});
// Wuensche im geloeschten Bereich bleiben stehen, nur ohne Schild
app.delete('/api/wish-categories/:id', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const weg = deleteWishCategory(Number(request.params.id));
logAudit(getSessionUser(request), 'wunsch-bereich entfernt', String(request.params.id));
return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) };
});
// Fehlende Tags im Forum anlegen — spart das Abtippen der Bereichsnamen
// und der fuenf Staende, und Tippfehler waeren hier stumme Fehlschlaege
app.post('/api/wish-categories/tags', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
try {
const res = await forumTagsAnlegen(client);
logAudit(getSessionUser(request), 'forum-tags angelegt', res.angelegt.join(', ') || 'keine');
return { ok: true, ...res };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
// --- Wünsche verwalten (Team) ---
const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [
id, { label, emoji: STATUS_EMOJI[id] },
]));
/**
* Den Wunsch-Post in Discord auf den neuen Stand bringen. Dort haben die
* Leute abgestimmt, dort gehört die Antwort hin — die Webseite allein
* erreicht die meisten nie.
*/
async function wunschPostAktualisieren(wunsch) {
if (!wunsch.message_id) return;
const channelId = votingChannelId();
if (!channelId) return;
// Im Forum liegt die Startnachricht im Beitrag selbst, nicht im Kanal —
// deshalb zuerst dort suchen, wo der Wunsch seinen Thread hat
const wo = wunsch.thread_id
? await client.channels.fetch(wunsch.thread_id).catch(() => null)
: null;
const channel = wo ?? await client.channels.fetch(channelId).catch(() => null);
let message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null);
if (!message && wo) {
// Textkanal mit Thread darunter: die Nachricht steht eine Ebene höher
const eltern = await client.channels.fetch(channelId).catch(() => null);
message = await eltern?.messages?.fetch(wunsch.message_id).catch(() => null);
}
if (!message?.embeds?.[0]) return;
const s = STATUS[wunsch.status] ?? STATUS.offen;
const alt = message.embeds[0];
// Den Kopf nur um den Stand ergänzen, nicht den Bereich überschreiben —
// dort steht „🚗 Fahrzeuge", das soll bleiben
const basis = String(alt.title ?? 'Feature-Wunsch').split(' — ')[0];
const embed = EmbedBuilder.from(alt).setTitle(`${basis}${s.emoji} ${s.label}`);
embed.setFields(wunsch.status_grund
? [{ name: 'Vom Team', value: String(wunsch.status_grund).slice(0, 1000) }]
: []);
await message.edit({ embeds: [embed] }).catch(() => {});
}
app.put('/api/wishes/:id/status', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const id = Number(request.params.id);
const status = String(request.body?.status ?? '');
if (!STATUS[status]) return reply.code(400).send({ error: 'Unbekannter Status.' });
const vorher = getWish(id);
if (!vorher) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
setWishStatus(id, status, String(request.body?.grund ?? '').trim().slice(0, 500));
const wunsch = getWish(id);
await wunschPostAktualisieren(wunsch);
await wunschTagsAktualisieren(client, wunsch, STATUS[status].label);
// Kreis schliessen: wer dafür gestimmt hat, erfährt es — aber nur beim
// Sprung auf umgesetzt, und nur einmal (vorher war ein anderer Status)
let benachrichtigt = 0;
if (status === 'umgesetzt' && vorher.status !== 'umgesetzt') {
const empfaenger = new Set(wishVoters(id));
if (wunsch.author_id) empfaenger.add(wunsch.author_id);
for (const userId of empfaenger) {
const user = await client.users.fetch(userId).catch(() => null);
if (!user) continue;
const ok = await user.send(
`✅ **Umgesetzt!** Der Wunsch, für den du gestimmt hast, ist jetzt drin:\n`
+ `> ${String(wunsch.idea).slice(0, 300)}`
+ (wunsch.status_grund ? `\n\n${wunsch.status_grund}` : '')
).then(() => true).catch(() => false);
if (ok) benachrichtigt++;
}
}
logAudit(getSessionUser(request), `wunsch ${status}`, String(wunsch.idea).slice(0, 60));
return { ok: true, benachrichtigt, wishes: topWishes(50) };
});
app.put('/api/wishes/:id/category', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const id = Number(request.params.id);
if (!getWish(id)) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
setWishCategory(id, Number(request.body?.category_id) || null);
return { ok: true, wishes: topWishes(50) };
});
// Doppler zusammenführen: Stimmen wandern zum Original, wer für beide
// gestimmt hat, zählt dort weiterhin einmal
app.post('/api/wishes/:id/merge', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const doppler = Number(request.params.id);
const original = Number(request.body?.original);
if (!getWish(doppler) || !getWish(original)) {
return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
}
if (doppler === original) {
return reply.code(400).send({ error: 'Ein Wunsch kann nicht sein eigener Doppler sein.' });
}
mergeWish(doppler, original);
logAudit(getSessionUser(request), 'wünsche zusammengeführt', `#${doppler} → #${original}`);
return { ok: true, wishes: topWishes(50) };
});
app.delete('/api/wishes/:id', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const wunsch = getWish(Number(request.params.id));
if (!wunsch) return { deleted: false, wishes: topWishes(50) };
// Erst die Dateinamen holen, dann loeschen — danach sind sie weg
const bilder = wishCommentImages(wunsch.id);
deleteWish(wunsch.id);
await bilderLoeschen(wunschBilderDir, bilder);
logAudit(getSessionUser(request), 'wunsch gelöscht', String(wunsch.idea).slice(0, 60));
return { deleted: true, wishes: topWishes(50) };
});
// --- 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;
// Avatare aus dem Member-Cache — kostet keinen Discord-Aufruf,
// dieselbe Quelle wie bei der Bestenliste
const bild = (id) => {
for (const guild of client.guilds.cache.values()) {
const m = guild.members?.cache?.get(id);
if (m) return { avatar: m.displayAvatarURL?.({ size: 64 }) ?? null, name: m.displayName };
}
return { avatar: null, name: null };
};
const owner = bild(config.adminDiscordId);
return {
admins: listWebAdmins().map((a) => ({ ...a, ...bild(a.user_id) })),
scopes: TEAM_SCOPES,
owner: { user_id: config.adminDiscordId, ...owner },
};
});
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(',')}`);
logAudit(getSessionUser(request), 'team geändert', `${username}: ${scopes.join(',')}`);
return { ok: true, admins: listWebAdmins() };
});
app.delete('/api/webadmins/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
logAudit(getSessionUser(request), 'team entfernt', String(request.params.id));
return { deleted: deleteWebAdmin(String(request.params.id)), admins: listWebAdmins() };
});
// Audit-Log: die letzten Team-/Owner-Aktionen im Webinterface (nur Owner)
app.get('/api/auditlog', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const bild = (id) => {
for (const guild of client.guilds.cache.values()) {
const m = guild.members?.cache?.get(id);
if (m) return m.displayAvatarURL?.({ size: 48 }) ?? null;
}
return null;
};
return { entries: listAudit(100).map((e) => ({ ...e, avatar: bild(e.user_id) })) };
});
// --- SSO-Apps (nur Owner): Fremd-Dienste, die den Discord-Login mitnutzen ---
app.get('/api/ssoapps', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { apps: listSsoApps() };
});
app.post('/api/ssoapps', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const slug = String(request.body?.slug ?? '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
const name = String(request.body?.name ?? '').trim();
const prefix = String(request.body?.redirect_prefix ?? '').trim();
if (!slug || !name) return reply.code(400).send({ error: 'slug und name nötig' });
if (!prefix.split(/[\s,]+/).filter(Boolean).every((p) => /^https?:\/\/.+/.test(p))) {
return reply.code(400).send({ error: 'redirect_prefix: http(s)-URL(s) erwartet' });
}
if (getSsoApp(slug)) return reply.code(409).send({ error: 'slug schon vergeben' });
const secret = `sso_${crypto.randomBytes(24).toString('hex')}`;
saveSsoApp({ slug, name, redirect_prefix: prefix, secret });
logAudit(getSessionUser(request), 'sso-app angelegt', `${name} (${slug})`);
// Secret nur in dieser einen Antwort!
return { slug, name, redirect_prefix: prefix, secret };
});
app.delete('/api/ssoapps/:slug', async (request, reply) => {
if (requireAdmin(request, reply)) return;
logAudit(getSessionUser(request), 'sso-app gelöscht', String(request.params.slug));
return { deleted: deleteSsoApp(String(request.params.slug)), apps: listSsoApps() };
});
// --- 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(',')})`);
logAudit(getSessionUser(request), 'api-key erstellt', name);
// Klartext-Key nur in dieser einen Antwort!
return { id, key, name, scopes };
});
app.delete('/api/apikeys/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const deleted = deleteApiKey(Number(request.params.id));
request.log.info(`API-Key ${request.params.id} widerrufen: ${deleted}`);
return { deleted };
});
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
app.post('/api/settings/test/:target', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const target = request.params.target;
// Sonderfall: echten Wochen-Rückblick sofort posten
if (target === 'recap') {
try {
const { postWeeklyRecap } = await import('../bot/weekly-recap.js');
await postWeeklyRecap(client);
return { ok: true };
} catch (error) {
request.log.error({ err: error }, 'Wochen-Rückblick-Test fehlgeschlagen');
return reply.code(502).send({ error: 'Senden fehlgeschlagen' });
}
}
// Sonderfall: Backup sofort erstellen
if (target === 'backup') {
try {
const { runBackup } = await import('../backup.js');
const result = await runBackup(client);
return { ok: true, sizeBytes: result.sizeBytes };
} catch (error) {
request.log.error({ err: error }, 'Backup-Test fehlgeschlagen');
return reply.code(502).send({ error: 'Backup fehlgeschlagen' });
}
}
// Alle Gitea-Repos als Bundle sichern (kann je nach Größe dauern)
if (target === 'repobackup') {
try {
const { runRepoBackup } = await import('../repo-backup.js');
const result = await runRepoBackup();
logAudit(getSessionUser(request), 'repo-backup gestartet', `${result.ok}/${result.repos} Repos`);
return { ok: true, ...result };
} catch (error) {
request.log.error({ err: error }, 'Repo-Backup fehlgeschlagen');
return reply.code(502).send({ error: `Repo-Backup: ${error.message?.slice(0, 120)}` });
}
}
const channelId =
target === 'commit' ? commitChannelId()
: target === 'devlog' ? devlogChannelId()
: target === 'release' ? releaseChannelId()
: null;
if (!channelId) {
return reply.code(400).send({ error: 'Kein Kanal konfiguriert' });
}
try {
const channel = await client.channels.fetch(channelId);
await channel.send({
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setTitle('🔧 Test')
.setDescription(
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.`
)
.setFooter({ text: brandFooter('SETUP'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
],
});
return { ok: true };
} catch (error) {
request.log.error({ err: error }, 'Test-Nachricht fehlgeschlagen');
return reply.code(502).send({ error: 'Senden fehlgeschlagen — Rechte im Kanal prüfen' });
}
});
}