// 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, lsState, lsModSizes, setLsModSize, listStations, createStation, updateStation, deleteStation, queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand, setRadioState, clearRadioState, radioState, } from '../db.js'; import crypto from 'node:crypto'; import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, 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, zugangStand } from '../bot/server-monitor.js'; import { istLsServer, modBasis } from '../bot/ls-farm.js'; import { titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle, lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, } from '../bot/radio.js'; import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js'; import { ytdlpVorhanden, ytdlpFassung, cookiesStand, cookiesSchreiben, selbstAktualisieren, letzterFehler, argumenteVorschau, aufloesen, TREFFER, } from '../bot/youtube.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 { radioLautstaerke, LAUT_MIN, LAUT_MAX, LAUT_SCHRITT, youtubeAn, } from '../runtime-settings.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, // Wie viele Mods der Bot von diesem Server kennt — nur dann // lohnt der Weg auf die Mod-Seite. null heisst „gibt es nicht". mod_liste: lsState(s.id)?.mods?.length || null, links: s.links ?? [], zugang: zugangStand(s, r ?? {}), // Wie der Server sich im Spiel nennt, falls anders als hier spielname: r?.spielname && r.spielname.trim() !== s.name.trim() ? r.spielname.trim() : 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() }; }); // --- Radio-Sender --- // // Frei eintragbar statt fest verdrahtet: es gibt keinen Grund, warum // jemand nur die Sender hören dürfen sollte, die im Code stehen. /** Eingaben prüfen; antwortet bei Fehlern selbst */ function senderBody(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; } // Kein http:// — der Strom liefe sonst unverschlüsselt durch den Server if (!/^https?:\/\/.+/.test(url)) { reply.code(400).send({ error: 'URL mit http(s) erwartet' }); return null; } return { name, url, emoji: String(b.emoji ?? '').trim().slice(0, 8) || null, sort: Number(b.sort) || 0, }; } app.get('/api/radio', async (request, reply) => { if (requireAnyScope(request, reply)) return; return { sender: listStations() }; }); app.post('/api/radio', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const s = senderBody(request, reply); if (!s) return; createStation(s); logAudit(getSessionUser(request), 'radiosender angelegt', `${s.name} (${s.url})`); return { ok: true, sender: listStations() }; }); app.put('/api/radio/:id', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const s = senderBody(request, reply); if (!s) return; updateStation({ ...s, id: Number(request.params.id) }); logAudit(getSessionUser(request), 'radiosender geändert', s.name); return { ok: true, sender: listStations() }; }); app.delete('/api/radio/:id', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; logAudit(getSessionUser(request), 'radiosender entfernt', String(request.params.id)); return { deleted: deleteStation(Number(request.params.id)), sender: listStations() }; }); // Was der Sender gerade spielt — zum Prüfen beim Eintragen. Der Titel // kommt aus dem Datenstrom selbst, es braucht also keine Sender-API. app.post('/api/radio/pruefen', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const url = String(request.body?.url ?? '').trim(); if (!/^https?:\/\/.+/.test(url)) return reply.code(400).send({ error: 'URL mit http(s) erwartet' }); const { titel, sender } = await titelHolen(url); return { erreichbar: sender != null || titel != null, sender, titel }; }); // --- Radio-Fernbedienung --- // // Was die Discord-Tafeln koennen, kann das Panel auch — und zwar ueber // dieselben Funktionen. Eine zweite Abspiellogik neben `radio.js` waere // eine zweite Gelegenheit, sich anders zu verhalten als die erste. /** * Welcher Server ist gemeint? * * Der Bot kann in mehreren stehen; das Panel hat aber nur eine Ansicht. * Reihenfolge: ausdruecklich mitgegeben, sonst der eingestellte, sonst — * wenn es nur einen gibt — eben der. Bei mehreren ohne Einstellung wird * *nicht* geraten, sondern gesagt, dass die Angabe fehlt. */ function radioGuild(request) { const gewuenscht = String(request.body?.guildId ?? request.query?.guildId ?? '').trim(); if (gewuenscht) return client.guilds.cache.get(gewuenscht) ?? null; const eingestellt = discordGuildId(); if (eingestellt) return client.guilds.cache.get(eingestellt) ?? null; return client.guilds.cache.size === 1 ? [...client.guilds.cache.values()][0] : null; } /** Der laufende Stand, so wie ihn das Panel braucht */ function radioStand(guild) { const jetzt = guild ? laeuft(guild.id) : null; if (!jetzt) return null; const kanal = guild.channels.cache.get(jetzt.channelId); const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; const t = jetzt.quelle?.art === 'youtube' ? jetzt.quelle.titel : null; return { art: jetzt.quelle?.art ?? null, titel: t ? t.titel : (jetzt.titel ?? null), sender: jetzt.quelle?.art === 'radio' ? jetzt.quelle.station.name : null, url: t?.url ?? null, thumb: t?.thumb ?? null, kanal: t?.kanal ?? null, // Sekunden statt eines Zeitstempels: der Browser muesste sonst // gegen die Uhr des Servers rechnen, und die beiden gehen selten // gleich. verstrichen: Math.floor((Date.now() - jetzt.seit) / 1000), dauer: t ? t.dauer : null, kanalId: jetzt.channelId, kanalName: kanal?.name ?? null, tonLaeuft: jetzt.tonLaeuft, heimStation: heim ? { id: heim.id, name: heim.name } : null, }; } app.get('/api/radio/status', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const guild = radioGuild(request); const guildId = guild?.id ?? null; return { guildId, guildName: guild?.name ?? null, // Mehrere Server und keine Einstellung: dann fehlt die Angabe, // und das steht hier statt eines stillen leeren Standes. mehrdeutig: !guild && client.guilds.cache.size > 1, laeuft: radioStand(guild), lautstaerke: radioLautstaerke(), grenzen: { min: LAUT_MIN, max: LAUT_MAX, schritt: LAUT_SCHRITT }, warteschlange: guildId ? queueList(guildId) : [], stand: guildId ? queueStand(guildId) : { n: 0, dauer: 0 }, sprachkanaele: listVoiceChannels(), werkzeuge: { ffmpeg: ffmpegVorhanden(), ytdlp: ytdlpVorhanden(), ytdlpFassung: ytdlpFassung(), youtubeAn: youtubeAn(), cookies: cookiesStand(), argumente: argumenteVorschau(), letzterFehler: letzterFehler(), }, }; }); /** * Start, Stopp, Ueberspringen, Sender, Lautstaerke. * * Ein Endpunkt mit einer Aktion statt fuenf Endpunkten: alle brauchen * dieselbe Server-Aufloesung, dieselbe Rechtepruefung und antworten mit * demselben Stand. Fuenfmal dasselbe Gerüst waere fuenfmal die Gelegenheit, * eines davon zu vergessen. */ app.post('/api/radio/steuern', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const guild = radioGuild(request); if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); const guildId = guild.id; const b = request.body ?? {}; const aktion = String(b.aktion ?? ''); /** Der Zielkanal: mitgegeben, sonst der laufende */ const zielId = () => String(b.kanalId ?? '') || laeuft(guildId)?.channelId || null; try { if (aktion === 'stopp') { stoppen(guildId); clearRadioState(guildId); } else if (aktion === 'laut') { const wert = lautstaerkeSetzen(guildId, Number(b.wert)); // Frueh zurueck, und zwar an der Protokollierung vorbei: am // Schieber entstehen viele Werte hintereinander, und ein // Protokoll, das zu neunzig Prozent aus Lautstaerke besteht, // ist keines mehr. Zurueck kommt der tatsaechlich gesetzte // Wert, nicht der gewuenschte — wer am Anschlag dreht, soll // das am Ergebnis sehen. await tafelnMelden(client, guildId); return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) }; } else if (aktion === 'skip') { const kanalId = zielId(); if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' }); await weiter(client, guildId, { erzwungen: true, kanalId }); } else if (aktion === 'sender' || aktion === 'heim') { const stationId = aktion === 'heim' ? laeuft(guildId)?.heimStationId : Number(b.stationId); const station = stationId ? getStation(Number(stationId)) : null; if (!station) return reply.code(400).send({ error: 'Sender nicht gefunden.' }); const kanalId = zielId(); if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal gewaehlt.' }); const kanal = await client.channels.fetch(kanalId).catch(() => null); if (!kanal?.isVoiceBased()) return reply.code(400).send({ error: 'Das ist kein Sprachkanal.' }); await spielen(kanal, radioQuelle(station)); // Ohne diesen Merker kaeme der Bot nach einem Neustart nicht // zurueck — und die Radio-Tafel wuesste nicht, was laeuft. const vorhanden = radioState(guildId); setRadioState({ guildId, channelId: kanalId, stationId: station.id, panelChannelId: vorhanden?.panel_channel_id, panelMessageId: vorhanden?.panel_message_id, }); // Den Titel gleich mitholen: `titelMerken` schreibt ihn auch // in die Statuszeile am Sprachkanal, sonst steht dort bis zum // naechsten Abgleich der Titel des vorigen Senders. const { titel } = await titelHolen(station.url); titelMerken(client, guildId, titel); } else { return reply.code(400).send({ error: `Unbekannte Aktion: ${aktion}` }); } } catch (error) { // Ein gescheiterter Beitritt bringt die Diagnose aus `radio.js` // mit — die ist hier deutlich mehr wert als ein 500er. return reply.code(400).send({ error: error.message }); } logAudit(getSessionUser(request), 'radio gesteuert', aktion); await tafelnMelden(client, guildId); return { ok: true, laeuft: radioStand(guild), warteschlange: queueList(guildId) }; }); // --- Warteschlange --- app.post('/api/radio/warteschlange', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const guild = radioGuild(request); if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); const guildId = guild.id; const b = request.body ?? {}; const wunschVon = getSessionUser(request)?.username ?? null; // Zwei Wege hier hinein: eine Eingabe (Adresse oder Suchbegriff) oder // ein fertiger Treffer, den das Panel vorher ausgewaehlt hat. let eingereiht; if (b.treffer) { eingereiht = eintraegeAufnehmen(guildId, [{ video_id: String(b.treffer.video_id ?? ''), url: String(b.treffer.url ?? ''), titel: String(b.treffer.titel ?? ''), dauer: b.treffer.dauer ?? null, kanal: b.treffer.kanal ?? null, thumb: b.treffer.thumb ?? null, }], wunschVon); } else { const r = await einreihen(guildId, String(b.eingabe ?? ''), { wunschVon }); if (r.fehler) return reply.code(400).send({ error: r.fehler }); // Ein Suchbegriff wird nicht geraten: die Treffer gehen zurueck, // gewaehlt wird im Panel. Genau wie in Discord. if (r.treffer) return { treffer: r.treffer, suche: true }; eingereiht = r.eingereiht; } const angeworfen = await ggfAnwerfen(client, guildId, String(b.kanalId ?? '') || null); logAudit(getSessionUser(request), 'radio eingereiht', eingereiht.map((e) => e.titel).join(', ').slice(0, 200)); await tafelnMelden(client, guildId); return { ok: true, eingereiht, angeworfen, warteschlange: queueList(guildId), laeuft: radioStand(guild), }; }); app.post('/api/radio/suche', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const text = String(request.body?.text ?? '').trim(); if (!text) return reply.code(400).send({ error: 'Nichts eingegeben.' }); const r = await aufloesen(text, { max: TREFFER }); if (r.fehler) return reply.code(400).send({ error: r.fehler }); return { treffer: r.treffer, suche: r.suche }; }); app.delete('/api/radio/warteschlange/:id', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const guild = radioGuild(request); if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); const weg = queueRemove(Number(request.params.id)); await tafelnMelden(client, guild.id); return { deleted: weg, warteschlange: queueList(guild.id) }; }); app.delete('/api/radio/warteschlange', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const guild = radioGuild(request); if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); const n = queueClear(guild.id); logAudit(getSessionUser(request), 'warteschlange geleert', `${n} Titel`); await tafelnMelden(client, guild.id); return { geleert: n, warteschlange: [] }; }); app.post('/api/radio/warteschlange/sortieren', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; const guild = radioGuild(request); if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); const b = request.body ?? {}; // Zwei Formen, weil das Panel umsortiert und Discord nur „nach vorn" // kennt — beide landen in derselben Pruefung in der Datenbank, die // fremde IDs nicht anfasst. if (b.vor) queueVor(guild.id, Number(b.vor)); else if (Array.isArray(b.ids)) queueReorder(guild.id, b.ids); else return reply.code(400).send({ error: 'Weder `ids` noch `vor` mitgegeben.' }); await tafelnMelden(client, guild.id); return { ok: true, warteschlange: queueList(guild.id) }; }); // --- yt-dlp --- app.get('/api/radio/youtube', async (request, reply) => { if (requireScope(request, reply, 'settings')) return; return { ytdlp: ytdlpVorhanden(), fassung: ytdlpFassung(), an: youtubeAn(), cookies: cookiesStand(), argumente: argumenteVorschau(), letzterFehler: letzterFehler(), }; }); /** * Die `cookies.txt` setzen oder loeschen. * * Eigener Endpunkt statt eines Feldes in den Einstellungen: der Inhalt ist * mehrzeilig und enthaelt Sitzungsschluessel eines echten Kontos. Er * gehoert in eine Datei mit 0600 neben die Datenbank und nicht in eine * Tabelle, die das Panel andernorts auch anzeigt. * * Zurueck kommt nie der Inhalt, nur Groesse und Datum. */ app.put('/api/radio/youtube/cookies', async (request, reply) => { if (requireAdmin(request, reply)) return; try { const r = await cookiesSchreiben(request.body?.inhalt ?? ''); logAudit(getSessionUser(request), 'yt-cookies', r.gesetzt ? 'gesetzt' : 'geloescht'); return { ok: true, cookies: cookiesStand() }; } catch (error) { return reply.code(400).send({ error: error.message }); } }); app.post('/api/radio/youtube/update', async (request, reply) => { if (requireAdmin(request, reply)) return; try { const r = await selbstAktualisieren(); logAudit(getSessionUser(request), 'yt-dlp aktualisiert', r.fassung ?? '?'); return { ok: true, ...r }; } catch (error) { return reply.code(400).send({ error: error.message }); } }); // 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('&', '&').replaceAll('<', '<').replaceAll('>', '>'); 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 ` ${esc(title)} ${publicUrl()}/devlogs ${esc(d.message_id)} ${new Date(d.posted_at).toUTCString()} ${esc(d.content)} `; }) .join('\n'); const xml = ` ${brandFooter('DEVLOG')} ${publicUrl()}/devlogs Entwicklungs-Updates, automatisch archiviert. de ${rssItems} `; 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)); } /** * Sprachkanäle, in die der Bot auch wirklich hineinkommt. * * Bewusst mit Rechteprüfung und nicht bloss nach Kanaltyp: ein Kanal, den * das Panel anbietet und in den der Bot dann nicht darf, sieht aus wie ein * kaputter Knopf. Was hier fehlt, fehlt aus einem Grund — deshalb steht * der auch dabei. */ function listVoiceChannels() { const channels = []; for (const guild of client.guilds.cache.values()) { for (const ch of guild.channels.cache.values()) { if (!ch.isVoiceBased?.() || ch.viewable === false) continue; const rechte = guild.members.me ? ch.permissionsFor(guild.members.me) : null; channels.push({ id: ch.id, name: ch.name, guild: guild.name, guildId: guild.id, besetzt: ch.members?.filter?.((m) => !m.user.bot).size ?? 0, darfRein: Boolean(rechte?.has('Connect') && rechte?.has('Speak')), }); } } 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 { // Alles, was die Modulliste kennt, zuerst — die Einträge darunter // überschreiben es, wo es Standardwerte oder Env-Rückfälle gibt. // // Ohne das gilt für jedes neue Modul: gespeichert wird, angezeigt // nicht. Das Feld steht leer da, obwohl der Wert in der Datenbank // liegt, und der Haken „einsatzbereit" widerspricht dem Formular. ...Object.fromEntries( Object.entries(moduleSettingKeys()) .filter(([art]) => art !== 'schalter') // Schalter sind Ja/Nein, siehe direkt darunter .flatMap(([, keys]) => keys.map((k) => [k, getSetting(k) ?? ''])) ), // Schalter ebenfalls aus dem Register. Vorher stand jeder einzeln // weiter unten in dieser Datei — und fünf fehlten dort schlicht // (radio, automod, anti_raid, linked_roles, ls_farm). Die liessen // sich dann nur über den Modul-Reiter umlegen, nirgends sonst. // // Der Vorgabewert steckt im Register, nicht hier: manche Module // sind ab Werk an, andere aus. `moduleEnabled` weiss das. ...Object.fromEntries(moduleSettingKeys().schalter.map((k) => { const mod = MODULES.find((m) => m.setting === k); return [k, mod ? moduleEnabled(mod.id) : getSetting(k) === '1']; })), 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]; } // Was ein Modul an Feldern mitbringt, ist im Register beschrieben — // von dort geholt statt hier nochmal aufgezählt. Ein Feld, das im // Panel steht, aber nicht in dieser Datei, verfiel sonst stumm. const ausRegister = moduleSettingKeys(); // Schalter aus dem Register — jeder neue Modul-Schalter ist damit // automatisch speicherbar. Die handgeschriebenen Zweige weiter unten // machen dasselbe noch einmal; das schadet nicht und bleibt stehen, // solange sie zusätzliche Nebenwirkungen haben. for (const key of ausRegister.schalter) { if (body[key] !== undefined) setSetting(key, body[key] ? '1' : '0'); } // Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus) const OPTIONAL_CHANNELS = [...new Set([ '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', ...ausRegister.kanal, ])]; // 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 [...new Set([ 'devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id', ...ausRegister.rolle, ])]) { 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: 1–50 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: 1–50' }); } 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()); } } // Freitext-Felder. Die Modul-Felder kommen aus dem Register dazu, sonst // muss jedes neue Modul auch hier eingetragen werden — und wer das // vergisst, merkt es erst, wenn das Feld beim Speichern verschwindet. for (const key of [...new Set([ 'commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', ...ausRegister.text, ])]) { 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) : '', links: freieLinks(body.links), // Nur bekannte Werte — sonst stuende irgendwann "asdf" in der // Spalte und zugangStand faellt still auf 'auto' zurueck. zugang: ['auto', 'offen', 'passwort', 'whitelist'].includes(String(body.zugang ?? '')) ? String(body.zugang) : 'auto', }; } /** * Freie Links säubern. * * Zeilen ohne Beschriftung oder ohne Adresse fliegen raus statt als leerer * Knopf im Discord zu landen. Zehn ist die Grenze, weil Discord fünf * Knöpfe je Reihe nimmt und zwei Reihen unter einem Embed genug sind. */ function freieLinks(roh) { if (!Array.isArray(roh)) return []; return roh .map((l) => ({ label: String(l?.label ?? '').trim().slice(0, 80), url: String(l?.url ?? '').trim().slice(0, 400), })) .filter((l) => l.label && l.url) .slice(0, 10); } app.get('/api/gameservers', async (request, reply) => { if (requireScope(request, reply, 'server')) return; return { servers: listGameservers() }; }); // --- Modliste eines LS-Servers (öffentlich) --- // // Die Liste steht in der Statusabfrage, die der LS-Tick ohnehin holt; hier // kommt sie aus der Datenbank statt bei jedem Seitenaufruf vom Spielserver. app.get('/api/servers/:id/mods', async (request, reply) => { const server = getGameserver(Number(request.params.id)); if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' }); const stand = lsState(server.id); const groessen = lsModSizes(server.id); return { server: { id: server.id, name: server.name, spiel: spielName(server.type) }, geprueft: stand?.geprueft ?? null, mods: (stand?.mods ?? []).map((m) => ({ // Der Hash ist der Fingerabdruck des Spiels und hat auf einer // öffentlichen Seite nichts verloren — er sagt nichts, was ein // Besucher braucht, und lädt zum Fehlschluss ein, er sei eine // Prüfsumme der Datei. name: m.name, titel: m.titel, autor: m.autor, version: m.version, bytes: groessen[m.name] ?? null, })), }; }); // Download durch den Bot hindurch. Der Zugangs-Code des Spielservers bleibt // damit hier und steht nicht in einem Link, den jemand weitergibt — mit dem // Code liesse sich sonst auch der ganze Spielstand lesen. app.get('/api/servers/:id/mods/:datei', async (request, reply) => { const server = getGameserver(Number(request.params.id)); if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' }); // Nur Namen, die wirklich in der Modliste stehen. Das ist keine // Formatprüfung, sondern eine Positivliste: ohne sie liesse sich über // den Dateinamen jede beliebige Adresse des Spielservers abrufen. const name = String(request.params.datei ?? '').replace(/\.zip$/i, ''); const stand = lsState(server.id); const mod = (stand?.mods ?? []).find((m) => m.name === name); if (!mod) return reply.code(404).send({ error: 'Mod nicht in der Liste' }); const ziel = `${modBasis(server)}/mods/${encodeURIComponent(name)}.zip` + `?code=${encodeURIComponent(server.token)}`; let quelle; try { quelle = await fetch(ziel, { signal: AbortSignal.timeout(30_000) }); } catch { return reply.code(502).send({ error: 'Spielserver antwortet nicht' }); } if (!quelle.ok || !quelle.body) { // 403 heisst in aller Regel: gekauftes DLC, das der Server nicht // weitergeben darf. Das ist kein Fehler, sondern eine Auskunft. const code = quelle.status === 403 ? 403 : 502; return reply.code(code).send({ error: code === 403 ? 'Gekaufter Inhalt — über Steam installieren' : 'Download fehlgeschlagen', }); } // Grösse merken: der Spielserver beantwortet kein HEAD, also ist das // die einzige Gelegenheit, sie ohne zusätzlichen Verkehr zu erfahren. const laenge = Number(quelle.headers.get('content-length')); if (Number.isFinite(laenge) && laenge > 0) { setLsModSize(server.id, name, laenge); reply.header('content-length', String(laenge)); } reply.header('content-type', 'application/zip'); reply.header('content-disposition', `attachment; filename="${name}.zip"`); return reply.send(quelle.body); }); // 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; // 64 und nicht 48: Discord nimmt nur Zweierpotenzen von 16 bis 4096. // Mit 48 warf die Bibliothek, und weil der Wurf durch das map lief, // riss ein einziges Avatar das ganze Protokoll mit — 500 statt Liste. const bild = (id) => { try { for (const guild of client.guilds.cache.values()) { const m = guild.members?.cache?.get(id); if (m) return m.displayAvatarURL?.({ size: 64 }) ?? null; } } catch { /* ein fehlendes Bild ist kein Grund, die Liste zu verlieren */ } 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' }); } }); }