Zwei Befunde aus dem Betrieb. 1. Der Bot ging aus dem leeren Kanal und kam sofort wieder rein. `stoppen()` hielt erst den Spieler an und loeschte dann aus `laufend`. `player.stop()` meldet den Leerlauf aber SYNCHRON -- gemessen mit @discordjs/voice 0.19: der Idle-Melder laeuft mitten in stop(), noch vor der naechsten Zeile. Er fand den Eintrag also noch, hielt das Ende fuer "Titel vorbei" und schaltete weiter: naechster Titel oder zurueck auf den Heimsender. Von aussen genau das gemeldete Bild. Jetzt wird erst geloescht, dann angehalten. Nachgestellt mit einem echten AudioPlayer und beiden Reihenfolgen -- die Kontrolle ist dabei die alte Reihenfolge: sie schaltet nachweislich weiter (1x), die neue nicht (0x). Ohne diese Gegenprobe koennte der Test etwas ganz anderes messen. 2. Im YouTube-Modus fehlte die Pause. `pausieren()` gibt es jetzt, ausdruecklich nur fuer YouTube. Ein Sender laesst sich nicht sinnvoll anhalten: er laeuft beim Betreiber weiter, waehrend hier die Leitung volllaeuft, und beim Fortsetzen kaeme eine Konserve, die immer weiter hinterherhinkt. Fuer einen Sender heisst Pause Stopp -- dafuer gibt es den Stopp-Knopf, und der Pause-Knopf graut aus. Die verstrichene Zeit rechnet Pausen heraus, sonst liefe der Fortschrittsbalken nach dem Fortsetzen um die Pausenlaenge voraus. `verstrichen()` steht an EINER Stelle, die sich Discord-Tafel und Panel teilen -- zwei Rechnungen waeren zwei Gelegenheiten, die Pause zu vergessen. Die Musik-Tafel bekommt eine zweite Knopfreihe: mit der Pause waeren es sechs Knoepfe, Discord laesst fuenf je Reihe zu. Platz ist reichlich (vier von fuenf Reihen), und die Trennung ist ohnehin sinnvoller -- oben, was gerade klingt, unten, was mit der Liste passiert. Nachgezaehlt: 2 Reihen ohne Liste, 4 mit. Beim Quellenwechsel wird ausdruecklich fortgesetzt: der Spieler ueberlebt den Wechsel, und stand er auf Pause, waere der neue Titel stumm gestartet waehrend der Eintrag "laeuft" sagt. Nebenbei: mein Feld-Abgleich hielt eine zweite Kopie der Feldliste von `radioStand()` und driftete beim ersten neuen Feld prompt ab -- er meldete `pausiert` als fehlend, obwohl die API es liefert. Er liest die Form jetzt aus der Quelle, statt sie doppelt zu pflegen.
2959 lines
137 KiB
JavaScript
2959 lines
137 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, getStation, 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, pausieren, verstrichen,
|
||
} 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()` zieht Pausen ab -- dieselbe Rechnung
|
||
// wie in der Discord-Tafel, nicht eine zweite daneben.
|
||
verstrichen: verstrichen(jetzt),
|
||
pausiert: Boolean(jetzt.pausiertSeit),
|
||
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 === 'pause') {
|
||
// Gibt null zurueck, wenn nichts oder ein Sender laeuft --
|
||
// dann ist Pause nicht vorgesehen, und das wird gesagt statt
|
||
// still nichts zu tun.
|
||
const neu = pausieren(guildId, b.wert === undefined ? null : Boolean(b.wert));
|
||
if (neu === null) {
|
||
return reply.code(400).send({
|
||
error: 'Pause gibt es nur bei YouTube -- ein Sender laesst sich nicht anhalten, '
|
||
+ 'nur stoppen.',
|
||
});
|
||
}
|
||
} 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' });
|
||
}
|
||
});
|
||
}
|