Neun Endpunkte unter /api/radio: Stand, Steuern, Warteschlange (einreihen, entfernen, leeren, sortieren), Suche und der yt-dlp-Block. Alles geht durch dieselben Funktionen wie die Discord-Tafeln. Eine zweite Abspiellogik neben radio.js waere eine zweite Gelegenheit, sich anders zu verhalten als die erste. Start, Stopp, Skip, Sender und Lautstaerke liegen als Aktionen auf einem Endpunkt statt auf fuenf: alle brauchen dieselbe Server-Aufloesung, dieselbe Rechtepruefung und antworten mit demselben Stand. Fuenf Geruest- Kopien waeren fuenf Gelegenheiten, eine davon zu vergessen. Ein Suchbegriff wird auch hier nicht geraten -- die Treffer gehen zurueck, gewaehlt wird im Panel. Genau wie in Discord. Die cookies.txt bekommt einen eigenen 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 zurueck kommt nie der Inhalt, nur Groesse und Datum. Nur der Owner darf ihn setzen, nicht das Team. listVoiceChannels() prueft die Rechte mit: ein Kanal, den das Panel anbietet und in den der Bot dann nicht darf, sieht aus wie ein kaputter Knopf. Beim Schreiben aufgefallen und behoben: radioState() wurde benutzt, aber nicht importiert -- das haette beim ersten Senderwechsel aus dem Panel geknallt. Der Syntaxcheck sieht so etwas nicht, das Laden des Moduls schon. Geprueft mit echtem Fastify, signiertem Sitzungs-Cookie und gefaelschtem Discord-Client: 24 Faelle, davon acht Gegenproben, die fehlschlagen muessen (ohne Anmeldung 401, unbekannte Aktion, Sender ohne ID, Skip ohne Kanal, leere Eingabe, kaputte Adresse, Sortieren ohne Angabe, Cookies ohne Netscape-Kennzeile). Alle 24 wie erwartet.
2946 lines
136 KiB
JavaScript
2946 lines
136 KiB
JavaScript
// 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 ` <item>
|
||
<title>${esc(title)}</title>
|
||
<link>${publicUrl()}/devlogs</link>
|
||
<guid isPermaLink="false">${esc(d.message_id)}</guid>
|
||
<pubDate>${new Date(d.posted_at).toUTCString()}</pubDate>
|
||
<description>${esc(d.content)}</description>
|
||
</item>`;
|
||
})
|
||
.join('\n');
|
||
|
||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<rss version="2.0">
|
||
<channel>
|
||
<title>${brandFooter('DEVLOG')}</title>
|
||
<link>${publicUrl()}/devlogs</link>
|
||
<description>Entwicklungs-Updates, automatisch archiviert.</description>
|
||
<language>de</language>
|
||
${rssItems}
|
||
</channel>
|
||
</rss>`;
|
||
return reply.type('application/rss+xml; charset=utf-8').send(xml);
|
||
});
|
||
|
||
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
||
app.delete('/api/devlogs/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'devlogs')) return;
|
||
|
||
const deleted = await removeDevlog(request.params.id);
|
||
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
|
||
return { deleted };
|
||
});
|
||
|
||
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
|
||
app.get('/api/commits', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
|
||
const { limit, offset, page } = paging(request);
|
||
const { items, total } = listCommits(limit, offset);
|
||
return { items, total, page, pageSize: PAGE_SIZE };
|
||
});
|
||
|
||
// --- Settings (Admin) ---
|
||
|
||
/** Alle Textkanäle, die der Bot sehen kann (für die Dropdowns) */
|
||
// Ein Forum ist nicht „textBased" — es hat keine Nachrichten, nur Beitraege.
|
||
// Fuers Voting ist es trotzdem ein gueltiges Ziel, also muss es hier
|
||
// auftauchen; sonst steht es schlicht nicht zur Auswahl.
|
||
const FORUM_TYPEN = new Set([ChannelType.GuildForum, ChannelType.GuildMedia]);
|
||
const istWaehlbar = (ch) => ch.isTextBased?.() || FORUM_TYPEN.has(ch.type);
|
||
|
||
function listChannels() {
|
||
const channels = [];
|
||
for (const guild of client.guilds.cache.values()) {
|
||
for (const ch of guild.channels.cache.values()) {
|
||
if (istWaehlbar(ch) && ch.viewable !== false) {
|
||
channels.push({
|
||
id: ch.id,
|
||
name: ch.name,
|
||
guild: guild.name,
|
||
forum: FORUM_TYPEN.has(ch.type),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return channels.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
/**
|
||
* 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' });
|
||
}
|
||
});
|
||
}
|