// 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' });
}
});
}