Die Kanalauswahl blieb leer, obwohl "einsatzbereit" gruen daneben stand — ein Widerspruch, der die Ursache schon verraet: der Wert lag in der Datenbank, das Formular bekam ihn nur nie zu sehen. Ich hatte beim letzten Mal das Speichern repariert und uebersehen, dass currentSettings() dieselbe von Hand gepflegte Liste fuehrt. Jetzt holt auch sie die Schluessel aus dem Modul-Register. Geheimes bleibt aussen vor: gitea_api_token steht unter requires, faellt aber durch die Namenspruefung und wird weiterhin nur als "ist gesetzt" gemeldet. Dazu die Uebersetzung. Im Embed standen wheat, clover_windrow und seeds, weil der Feed nun mal Englisch spricht. Jetzt Weizen, Rotklee (Schwad) und Saatgut, mit lesbarem Rueckfall fuer alles, was nicht in der Liste steht — 371 Sorten uebersetzt niemand von Hand. Und der Monat. Die zwoelf Perioden sind schlicht Monate, EARLY_SPRING ist Maerz. Also nimmt das Feld jetzt "September", "sept" oder "9" statt EARLY_AUTUMN; Mehrdeutiges wie "Ju" wird abgelehnt statt geraten. Weiterzaehlen kann der Bot selbst, seit klar ist, dass ein Monat ueber einen Tag laeuft. Nicht ueber playTime — die steht im Savegame und wird nur beim Autosave geschrieben: ueber zwei Stunden gemessen stieg sie um 50, waehrend die Spieluhr 18,7 Stunden weiterlief. dayTime in der Statusabfrage ist dagegen live, und jeder Ruecksprung um Mitternacht ist ein Monatswechsel. Steht der Server leer, steht die Spielzeit — und der Monat bleibt richtigerweise stehen. Geprueft: alle zwoelf Monate hin und zurueck, Abkuerzungen, Monatszahlen, Mehrdeutiges, acht Sorten Unsinn, der Jahreswechsel von Februar auf Maerz, der Ruecksprung um Mitternacht samt Erstlauf, und dass jede Sorte der Standardauswahl einen deutschen Namen hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2435 lines
112 KiB
JavaScript
2435 lines
112 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,
|
||
} 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 } from '../bot/server-monitor.js';
|
||
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
||
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
||
import {
|
||
wunschAnlegen, wunschTagsAktualisieren, forumTagsAnlegen, wunschBilderDir,
|
||
STATUS_LABEL, STATUS_EMOJI,
|
||
} from '../bot/wishes.js';
|
||
import { bilderLoeschen } from '../bot/bilder.js';
|
||
import { heartbeatSerie } from '../bot/heartbeat.js';
|
||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||
import { sanitizeEmbed } from './api-v1.js';
|
||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||
import { computeNextRun } from './scheduled-posts.js';
|
||
import { config } from '../config.js';
|
||
import { removeDevlog } from '../bot/devlog-archive.js';
|
||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard, playtesterRoleId } from '../runtime-settings.js';
|
||
import { xpForLevel } from '../bot/levels.js';
|
||
import { getMilestones } from '../gitea-api.js';
|
||
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
/** Query-Parameter ?page=1.. in LIMIT/OFFSET übersetzen */
|
||
function paging(request) {
|
||
const page = Math.max(1, Number(request.query.page) || 1);
|
||
return { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, page };
|
||
}
|
||
|
||
/** Owner-Guard (ADMIN_DISCORD_ID): null = okay, sonst wurde bereits geantwortet */
|
||
function requireAdmin(request, reply) {
|
||
const user = getSessionUser(request);
|
||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
|
||
return null;
|
||
}
|
||
|
||
/** Web-Scopes des eingeloggten Users: Owner = ['*'], Team = aus web_admins */
|
||
function scopesOf(user) {
|
||
if (!user) return [];
|
||
if (isAdmin(user)) return ['*'];
|
||
return webAdminScopes(user.id);
|
||
}
|
||
|
||
/** Bereichs-Guard: Owner oder Team-Mitglied mit passendem Scope */
|
||
function requireScope(request, reply, scope) {
|
||
const user = getSessionUser(request);
|
||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||
const scopes = scopesOf(user);
|
||
if (scopes.includes('*') || scopes.includes(scope)) return null;
|
||
return reply.code(403).send({ error: `scope '${scope}' required` });
|
||
}
|
||
|
||
/** Mindestens irgendein Team-Zugang (für gemeinsame Daten wie Kanal-Listen) */
|
||
function requireAnyScope(request, reply) {
|
||
const user = getSessionUser(request);
|
||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||
if (scopesOf(user).length === 0) return reply.code(403).send({ error: 'no access' });
|
||
return null;
|
||
}
|
||
|
||
export function registerApiRoutes(app, client) {
|
||
// Wer bin ich? (fürs Frontend: Login-Status, Owner-Flag + Team-Scopes)
|
||
app.get('/api/me', async (request) => {
|
||
const user = getSessionUser(request);
|
||
if (!user) return { user: null, admin: false, scopes: [] };
|
||
return { user, admin: isAdmin(user), scopes: scopesOf(user) };
|
||
});
|
||
|
||
// Devlog-Archiv — öffentlich (wie der Discord-Kanal); ?q= für Volltextsuche
|
||
// Projekte im Archiv (D4RKBOT, ECOGAME …) samt Anzahl — für die Filter-Chips.
|
||
// Das Projekt steht in der ersten Zeile, dieselbe Regel wie im Frontend.
|
||
app.get('/api/devlog-projects', async () => {
|
||
const counts = new Map();
|
||
for (const head of devlogHeads()) {
|
||
const name = head.match(/^Devlog\s*[—–-]\s*(.+)$/i)?.[1]?.trim();
|
||
if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||
}
|
||
return {
|
||
projects: [...counts].map(([name, n]) => ({ name, n })).sort((a, b) => b.n - a.n),
|
||
};
|
||
});
|
||
|
||
app.get('/api/devlogs', async (request) => {
|
||
const { limit, offset, page } = paging(request);
|
||
const q = (request.query.q ?? '').trim();
|
||
const project = (request.query.project ?? '').trim();
|
||
// Suche schlägt Filter — beides gleichzeitig würde die FTS-Abfrage verkomplizieren,
|
||
// ohne dass jemand danach fragt.
|
||
const { items, total } = q
|
||
? searchDevlogs(q, limit, offset)
|
||
: (project ? listDevlogsByProject(project, limit, offset) : listDevlogs(limit, offset));
|
||
// images: JSON-Spalte → fertige URLs fürs Frontend
|
||
const mapped = items.map(({ images, ...rest }) => ({
|
||
...rest,
|
||
images: JSON.parse(images || '[]').map((f) => `/devlog-assets/${f}`),
|
||
}));
|
||
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
||
});
|
||
|
||
// Einzelnes Devlog (Permalink-Seite /devlogs/:id)
|
||
app.get('/api/devlogs/:id', async (request, reply) => {
|
||
const item = getDevlog(String(request.params.id));
|
||
if (!item) return reply.code(404).send({ error: 'Devlog nicht gefunden' });
|
||
return {
|
||
item: {
|
||
...item,
|
||
images: JSON.parse(item.images || '[]').map((f) => `/devlog-assets/${f}`),
|
||
},
|
||
};
|
||
});
|
||
|
||
// Changelog — öffentlich (Releases aller Repos)
|
||
app.get('/api/releases', async (request) => {
|
||
const { limit, offset, page } = paging(request);
|
||
const { items, total } = listReleases(limit, offset);
|
||
return { items, total, page, pageSize: PAGE_SIZE };
|
||
});
|
||
|
||
// Screenshot-Galerie — öffentlich
|
||
app.get('/api/gallery', async (request) => {
|
||
const { limit, offset, page } = paging(request);
|
||
const { items, total } = listGallery(limit, offset);
|
||
const mapped = items.map(({ images, ...rest }) => ({
|
||
...rest,
|
||
images: JSON.parse(images || '[]').map((f) => `/gallery-assets/${f}`),
|
||
}));
|
||
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
||
});
|
||
|
||
// Playtester-Liste — Admin
|
||
app.get('/api/playtesters', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
return { playtesters: listPlaytesters() };
|
||
});
|
||
|
||
// Playtester entfernen. Die Rolle geht mit — nur aus der Liste zu streichen
|
||
// und die Rolle stehen zu lassen wäre eine Halbwahrheit, die spätestens
|
||
// beim nächsten Verteilen auffällt.
|
||
app.delete('/api/playtesters/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const userId = String(request.params.id);
|
||
const weg = removePlaytester(userId);
|
||
let rolleWeg = false;
|
||
const roleId = playtesterRoleId();
|
||
if (roleId) {
|
||
try {
|
||
const gid = discordGuildId();
|
||
const guild = gid ? client.guilds.cache.get(gid) : client.guilds.cache.first();
|
||
const member = await guild?.members.fetch(userId);
|
||
await member?.roles.remove(roleId);
|
||
rolleWeg = true;
|
||
} catch { /* nicht mehr auf dem Server, oder Rolle steht über dem Bot */ }
|
||
}
|
||
logAudit(getSessionUser(request), 'playtester entfernt', userId);
|
||
return { ok: weg, rolleWeg, playtesters: listPlaytesters() };
|
||
});
|
||
|
||
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich
|
||
app.get('/api/wishes', async (request) => {
|
||
const user = getSessionUser(request);
|
||
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
|
||
const sortierung = String(request.query?.sort ?? 'top');
|
||
// Den Link zum Thread bauen wir hier — die Guild-ID gehört nicht auf
|
||
// die öffentliche Seite, nur der fertige Verweis
|
||
const gid = discordGuildId();
|
||
return {
|
||
wishes: topWishes(50, sortierung).map((w) => ({
|
||
...w,
|
||
voted: mine.has(w.id),
|
||
thread_url: gid && w.thread_id
|
||
? `https://discord.com/channels/${gid}/${w.thread_id}`
|
||
: null,
|
||
})),
|
||
bereiche: listWishCategories(),
|
||
canVote: Boolean(user),
|
||
};
|
||
});
|
||
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
|
||
|
||
// Öffentliche Server-Status-Seite: letzte Query-Ergebnisse + 24h-Verlauf
|
||
// (bewusst ohne query_url/host/port — nur was Besucher sehen dürfen)
|
||
app.get('/api/servers', async () => ({
|
||
servers: listGameservers().map((s) => {
|
||
const r = lastResults.get(s.id);
|
||
return {
|
||
id: s.id,
|
||
name: s.name,
|
||
type: s.type,
|
||
spiel: spielName(s.type),
|
||
icon: GAME_ICONS[s.type] ?? '🎮',
|
||
image_url: s.image_url || null,
|
||
address: s.address || null,
|
||
connect_url: s.connect_url || null,
|
||
mods_url: s.mods_url || null,
|
||
online: r?.online ?? null, // null = noch nie gecheckt
|
||
players: r?.players ?? null,
|
||
max: r?.max ?? null,
|
||
map: r?.map ?? null,
|
||
version: r?.version ?? null,
|
||
mods: r?.mods ?? null,
|
||
ping: r?.ping ?? null,
|
||
// Namen im Spiel, so wie sie im Discord-Embed auch stehen
|
||
spielerNamen: r?.online ? (r.playerNames ?? []) : [],
|
||
checkedAt: r?.checkedAt ?? null,
|
||
// Für offline Server: seit wann. Ohne Verlauf bleibt es null.
|
||
zuletztOnline: r?.online === false ? lastOnlineAt(s.id) : null,
|
||
history: playerHistory(s.id, 24),
|
||
// Erreichbarkeit der letzten 7 Tage, stundenweise. Länger geht
|
||
// nicht: prunePlayerHistory räumt alles Ältere weg.
|
||
uptime: uptimeBuckets(s.id, 168),
|
||
};
|
||
}),
|
||
}));
|
||
|
||
/** Einen überwachten Dienst in die Form bringen, die beide Seiten nutzen */
|
||
function dienstStand(dienst, { tage = false } = {}) {
|
||
const s = watchdogState().get(dienst.url) ?? {};
|
||
return {
|
||
id: dienst.id,
|
||
name: dienst.name,
|
||
gruppe: dienst.gruppe,
|
||
oeffentlich: dienst.oeffentlich,
|
||
// undefined = seit dem Start noch nicht geprüft
|
||
online: s.geprueft ? !s.down : null,
|
||
fails: s.fails ?? 0,
|
||
seit: s.since ?? null,
|
||
ms: s.ms ?? null,
|
||
status: s.status ?? null,
|
||
geprueft: s.geprueft ?? null,
|
||
uptime: tage ? watchdogDays(dienst.url, 90) : watchdogBuckets(dienst.url, 168),
|
||
};
|
||
}
|
||
|
||
// Watchdog-Übersicht fürs Panel: alle Dienste mit voller Adresse.
|
||
// Nicht öffentlich — in der Liste können interne Dienste stehen.
|
||
app.get('/api/watchdog', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
return {
|
||
aktiv: moduleEnabled('watchdog'),
|
||
intervall: tuning('watchdog_interval'),
|
||
adressen: watchedServices().map((d) => ({ ...dienstStand(d), url: d.url })),
|
||
stoerungen: listIncidents(90, 50),
|
||
};
|
||
});
|
||
|
||
// Öffentliche Statusseite: nur freigegebene Dienste, und bewusst OHNE
|
||
// Adresse — der Name reicht, die URL geht niemanden etwas an.
|
||
app.get('/api/status', async () => {
|
||
const dienste = watchedServices().filter((d) => d.oeffentlich);
|
||
const urls = new Set(dienste.map((d) => d.url));
|
||
return {
|
||
dienste: dienste.map((d) => dienstStand(d, { tage: true })),
|
||
server: listGameservers().map((s) => {
|
||
const r = lastResults.get(s.id);
|
||
return {
|
||
id: s.id,
|
||
name: s.name,
|
||
icon: GAME_ICONS[s.type] ?? '🎮',
|
||
online: r?.online ?? null,
|
||
players: r?.players ?? null,
|
||
max: r?.max ?? null,
|
||
};
|
||
}),
|
||
// Nur Störungen freigegebener Dienste — sonst verrät die Historie,
|
||
// was es sonst noch gibt
|
||
stoerungen: listIncidents(90, 20)
|
||
.filter((v) => urls.has(v.url))
|
||
.map(({ url, ...rest }) => ({
|
||
...rest,
|
||
name: dienste.find((d) => d.url === url)?.name ?? 'Dienst',
|
||
})),
|
||
};
|
||
});
|
||
|
||
// --- Überwachte Dienste verwalten (Panel) ---
|
||
|
||
/** Eingaben prüfen; antwortet bei Fehlern selbst */
|
||
function dienstBody(request, reply) {
|
||
const b = request.body ?? {};
|
||
const name = String(b.name ?? '').trim().slice(0, 60);
|
||
const url = String(b.url ?? '').trim();
|
||
if (!name) { reply.code(400).send({ error: 'Name fehlt' }); return null; }
|
||
if (!/^https?:\/\/.+/.test(url)) { reply.code(400).send({ error: 'URL mit http(s) erwartet' }); return null; }
|
||
return {
|
||
name, url,
|
||
gruppe: String(b.gruppe ?? 'Dienste').trim().slice(0, 40) || 'Dienste',
|
||
oeffentlich: Boolean(b.oeffentlich),
|
||
sort: Number(b.sort) || 0,
|
||
};
|
||
}
|
||
|
||
app.post('/api/monitored', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const d = dienstBody(request, reply);
|
||
if (!d) return;
|
||
createMonitored(d);
|
||
logAudit(getSessionUser(request), 'dienst überwacht', `${d.name} (${d.url})`);
|
||
return { ok: true, dienste: listMonitored() };
|
||
});
|
||
|
||
app.put('/api/monitored/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const d = dienstBody(request, reply);
|
||
if (!d) return;
|
||
updateMonitored({ ...d, id: Number(request.params.id) });
|
||
logAudit(getSessionUser(request), 'dienst geändert', d.name);
|
||
return { ok: true, dienste: listMonitored() };
|
||
});
|
||
|
||
app.delete('/api/monitored/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
logAudit(getSessionUser(request), 'dienst entfernt', String(request.params.id));
|
||
return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() };
|
||
});
|
||
|
||
// Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist
|
||
// eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server.
|
||
app.get('/api/ticket-categories', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
return { kategorien: listTicketCategories() };
|
||
});
|
||
|
||
/** Eingaben pruefen — gibt null zurueck und hat dann schon geantwortet */
|
||
function anliegenBody(request, reply) {
|
||
const b = request.body ?? {};
|
||
const name = String(b.name ?? '').trim();
|
||
if (!name) {
|
||
reply.code(400).send({ error: 'Name fehlt.' });
|
||
return null;
|
||
}
|
||
return {
|
||
name: name.slice(0, 80),
|
||
// Discord lehnt die ganze Nachricht ab, wenn ein Emoji nicht passt —
|
||
// geprueft wird beim Bauen des Menues, hier nur gekuerzt
|
||
emoji: String(b.emoji ?? '').trim().slice(0, 32) || '🎫',
|
||
beschreibung: String(b.beschreibung ?? '').trim().slice(0, 100),
|
||
ping_role_id: String(b.ping_role_id ?? '').trim() || null,
|
||
intro: String(b.intro ?? '').trim().slice(0, 1024),
|
||
sort: Number(b.sort) || 0,
|
||
};
|
||
}
|
||
|
||
app.post('/api/ticket-categories', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const k = anliegenBody(request, reply);
|
||
if (!k) return;
|
||
createTicketCategory(k);
|
||
logAudit(getSessionUser(request), 'ticket-anliegen angelegt', k.name);
|
||
return { ok: true, kategorien: listTicketCategories() };
|
||
});
|
||
|
||
app.put('/api/ticket-categories/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const k = anliegenBody(request, reply);
|
||
if (!k) return;
|
||
updateTicketCategory({ ...k, id: Number(request.params.id) });
|
||
logAudit(getSessionUser(request), 'ticket-anliegen geaendert', k.name);
|
||
return { ok: true, kategorien: listTicketCategories() };
|
||
});
|
||
|
||
app.delete('/api/ticket-categories/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
logAudit(getSessionUser(request), 'ticket-anliegen entfernt', String(request.params.id));
|
||
return {
|
||
deleted: deleteTicketCategory(Number(request.params.id)),
|
||
kategorien: listTicketCategories(),
|
||
};
|
||
});
|
||
|
||
// AutoMod: Discords eigene Regeln lesen und schalten. Die Wortlisten
|
||
// bleiben in Discord — dort ist das Bearbeiten besser geloest.
|
||
app.get('/api/automod', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
const res = await listRules(client, discordGuildId());
|
||
return { aktiv: moduleEnabled('automod'), ...res };
|
||
});
|
||
|
||
app.put('/api/automod/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const user = getSessionUser(request);
|
||
try {
|
||
const regel = await setRuleEnabled(
|
||
client, discordGuildId(), String(request.params.id),
|
||
Boolean(request.body?.aktiv), user?.username ?? 'Panel'
|
||
);
|
||
logAudit(user, `automod-regel ${regel.aktiv ? 'an' : 'aus'}`, regel.name);
|
||
return { ok: true, regel };
|
||
} catch (error) {
|
||
return reply.code(400).send({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// --- Module: zentrale An/Aus-Schalter aller Funktionen ---
|
||
|
||
app.get('/api/modules', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
return {
|
||
modules: moduleStates(),
|
||
groups: MODULE_GROUPS,
|
||
favorites: moduleFavorites(),
|
||
maxFavorites: MAX_FAVORITES,
|
||
};
|
||
});
|
||
|
||
// Welche Funktionen in der Seitenleiste stehen
|
||
app.put('/api/module-favorites', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const res = setModuleFavorites(request.body?.ids ?? []);
|
||
if (!res.ok) return reply.code(400).send({ error: res.error });
|
||
logAudit(getSessionUser(request), 'seitenleiste geändert', res.favorites.join(', ') || 'leer');
|
||
return { ok: true, favorites: res.favorites };
|
||
});
|
||
|
||
// Alles zu einer Funktion an einem Ort: ihre Kanäle und Felder, ihre Texte,
|
||
// ihre Werte. Genau das zeigt die Modul-Seite.
|
||
app.get('/api/modules/:id', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
const detail = moduleDetail(String(request.params.id));
|
||
if (!detail) return reply.code(404).send({ error: 'Modul unbekannt' });
|
||
return {
|
||
...detail,
|
||
texte: templateStates().filter((t) => t.module === detail.id),
|
||
werte: tuningStates().filter((w) => w.modules.includes(detail.id)),
|
||
};
|
||
});
|
||
|
||
app.put('/api/modules/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const id = String(request.params.id);
|
||
const on = Boolean(request.body?.enabled);
|
||
if (!setModuleEnabled(id, on)) {
|
||
return reply.code(404).send({ error: 'Modul unbekannt' });
|
||
}
|
||
logAudit(getSessionUser(request), on ? 'modul aktiviert' : 'modul deaktiviert', id);
|
||
request.log.info(`Modul ${id} → ${on ? 'an' : 'aus'}`);
|
||
return { ok: true, modules: moduleStates() };
|
||
});
|
||
|
||
// Fehlenden Kanal / fehlende Rolle direkt anlegen (Status → Noch einzurichten)
|
||
app.post('/api/setup/create', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const setting = String(request.body?.setting ?? '');
|
||
try {
|
||
const { createForSetting } = await import('../bot/server-setup.js');
|
||
const res = await createForSetting(client, setting);
|
||
logAudit(
|
||
getSessionUser(request),
|
||
res.wiederverwendet ? 'vorhandenen kanal verknüpft' : `${res.art} angelegt`,
|
||
`${setting} → ${res.name}`
|
||
);
|
||
request.log.info(`${res.art} ${res.name} für ${setting} (${res.wiederverwendet ? 'verknüpft' : 'neu'})`);
|
||
return { ok: true, ...res, modules: moduleStates(), settings: currentSettings() };
|
||
} catch (error) {
|
||
request.log.error({ err: error }, `Anlegen für ${setting} fehlgeschlagen`);
|
||
// Discord-Meldungen sind für Menschen unbrauchbar — eigene durchreichen
|
||
return reply.code(400).send({ error: error.message || 'Anlegen fehlgeschlagen' });
|
||
}
|
||
});
|
||
|
||
// Grundgerüst: Vorschau, was der Assistent anlegen würde
|
||
app.get('/api/setup/plan', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const { setupPlan } = await import('../bot/server-setup.js');
|
||
return { schritte: setupPlan(client), kategorie: brandName() };
|
||
});
|
||
|
||
// … und ausführen
|
||
app.post('/api/setup/run', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const kategorie = String(request.body?.kategorie ?? '').trim() || undefined;
|
||
try {
|
||
const { runSetup } = await import('../bot/server-setup.js');
|
||
const ergebnisse = await runSetup(client, { kategorie });
|
||
const neu = ergebnisse.filter((r) => r.ok && !r.wiederverwendet).length;
|
||
logAudit(getSessionUser(request), 'grundgerüst angelegt', `${neu} neu, ${ergebnisse.length} gesamt`);
|
||
request.log.info(`Grundgerüst: ${neu} neu von ${ergebnisse.length} Schritten`);
|
||
return { ok: true, ergebnisse, modules: moduleStates(), settings: currentSettings() };
|
||
} catch (error) {
|
||
request.log.error({ err: error }, 'Grundgerüst fehlgeschlagen');
|
||
return reply.code(400).send({ error: error.message || 'Fehlgeschlagen' });
|
||
}
|
||
});
|
||
|
||
// --- Stellwerte: Zahlen, die vorher fest im Code standen ---
|
||
|
||
app.get('/api/tuning', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
return { values: tuningStates(), groups: TUNING_GROUPS };
|
||
});
|
||
|
||
app.put('/api/tuning', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const body = request.body ?? {};
|
||
// Erst alles prüfen, dann schreiben — sonst bleibt bei einem Fehler
|
||
// die Hälfte gesetzt und die andere nicht.
|
||
const pending = [];
|
||
for (const [id, value] of Object.entries(body)) {
|
||
const check = setTuning(id, value, { dryRun: true });
|
||
if (!check.ok) return reply.code(400).send({ error: check.error });
|
||
pending.push([id, value]);
|
||
}
|
||
for (const [id, value] of pending) setTuning(id, value);
|
||
if (pending.length > 0) {
|
||
logAudit(getSessionUser(request), 'werte geändert', pending.map(([id]) => id).join(', '));
|
||
}
|
||
return { ok: true, values: tuningStates() };
|
||
});
|
||
|
||
// Vorschau der Willkommens-Karte. Nimmt das Aussehen als Query entgegen,
|
||
// damit man im Panel sieht, was man einstellt, bevor gespeichert wird.
|
||
app.get('/api/welcome-preview.png', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const q = request.query ?? {};
|
||
const hex = (v) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '')) ? String(v).toLowerCase() : undefined);
|
||
// Nur bekannte Felder übernehmen — der Rest kommt aus den Einstellungen
|
||
const override = {};
|
||
for (const key of ['background', 'accent', 'accent2', 'text']) {
|
||
const value = hex(q[key]);
|
||
if (value) override[key] = value;
|
||
}
|
||
if (q.kicker !== undefined) override.kicker = String(q.kicker).slice(0, 24);
|
||
if (q.sub !== undefined) override.sub = String(q.sub).slice(0, 60);
|
||
if (q.image !== undefined) override.image = String(q.image).trim();
|
||
if (q.watermark !== undefined) override.watermark = q.watermark !== '0';
|
||
|
||
try {
|
||
const { buildWelcomeCard } = await import('../bot/welcome-card.js');
|
||
const png = await buildWelcomeCard({
|
||
username: String(q.name ?? '').trim() || client.user?.username || 'Neues Mitglied',
|
||
avatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }),
|
||
memberNumber: Number(q.count) || 42,
|
||
}, override);
|
||
reply.header('Cache-Control', 'no-store');
|
||
return reply.type('image/png').send(png);
|
||
} catch (error) {
|
||
request.log.error({ err: error }, 'Willkommens-Vorschau fehlgeschlagen');
|
||
return reply.code(500).send({ error: 'Vorschau fehlgeschlagen' });
|
||
}
|
||
});
|
||
|
||
// Funktionsliste für die Produktseite — öffentlich und ohne Zustand:
|
||
// was der Bot kann, nicht was hier gerade eingeschaltet ist. Quelle ist
|
||
// dasselbe Register wie die Module, damit die Seite nicht veraltet.
|
||
app.get('/api/features', async () => ({
|
||
groups: MODULE_GROUPS,
|
||
features: MODULES.map((m) => ({ id: m.id, name: m.name, desc: m.desc, group: m.group })),
|
||
}));
|
||
|
||
// --- Text-Vorlagen: was der Bot nach außen schreibt ---
|
||
// (nicht /api/templates — das sind die gespeicherten Composer-Nachrichten)
|
||
|
||
app.get('/api/texts', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
return { templates: templateStates(), groups: TEMPLATE_GROUPS };
|
||
});
|
||
|
||
app.put('/api/texts/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const id = String(request.params.id);
|
||
const text = String(request.body?.text ?? '');
|
||
if (text.length > 4000) {
|
||
return reply.code(400).send({ error: 'Text zu lang (max. 4000 Zeichen)' });
|
||
}
|
||
if (!setTemplate(id, text)) {
|
||
return reply.code(404).send({ error: 'Vorlage unbekannt' });
|
||
}
|
||
const reset = !text.trim();
|
||
logAudit(getSessionUser(request), reset ? 'vorlage zurückgesetzt' : 'vorlage geändert', id);
|
||
return { ok: true, templates: templateStates() };
|
||
});
|
||
|
||
// --- Frei angelegte Seiten (Regeln, Über uns, …) ---
|
||
|
||
// Adressen, die schon vom Frontend oder Server belegt sind
|
||
const RESERVED_SLUGS = new Set([
|
||
'devlogs', 'roadmap', 'server', 'galerie', 'level', 'events', 'changelog',
|
||
'profil', 'commits', 'settings', 'dashboard', 'impressum', 'datenschutz',
|
||
'api', 'auth', 'webhooks', 'sso', 'health', 'assets', 'features', 'commands', 'docs',
|
||
]);
|
||
|
||
// Menü-Einträge + veröffentlichte Seiten — öffentlich
|
||
app.get('/api/pages', async () => ({ pages: listPublishedPages() }));
|
||
|
||
app.get('/api/pages/:slug', async (request, reply) => {
|
||
const page = getPage(String(request.params.slug));
|
||
if (!page || !page.published) {
|
||
// Entwürfe darf nur sehen, wer sie auch bearbeiten dürfte
|
||
const scopes = scopesOf(getSessionUser(request));
|
||
if (!page || !(scopes.includes('*') || scopes.includes('content'))) {
|
||
return reply.code(404).send({ error: 'Seite nicht gefunden' });
|
||
}
|
||
}
|
||
return { page: { ...page, published: Boolean(page.published), in_menu: Boolean(page.in_menu) } };
|
||
});
|
||
|
||
// Verwaltung (Scope content) — inklusive Entwürfen
|
||
app.get('/api/pages/all/list', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return {
|
||
pages: listPages().map((p) => ({
|
||
...p, published: Boolean(p.published), in_menu: Boolean(p.in_menu),
|
||
})),
|
||
};
|
||
});
|
||
|
||
app.put('/api/pages/:slug', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
const slug = String(request.params.slug).trim().toLowerCase()
|
||
.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||
const title = String(request.body?.title ?? '').trim();
|
||
|
||
if (!slug || !title) return reply.code(400).send({ error: 'Kürzel und Titel nötig' });
|
||
if (RESERVED_SLUGS.has(slug)) {
|
||
return reply.code(400).send({ error: `„${slug}" ist bereits vergeben — bitte anderes Kürzel` });
|
||
}
|
||
|
||
savePage({
|
||
slug,
|
||
title: title.slice(0, 80),
|
||
content: String(request.body?.content ?? '').slice(0, 50_000),
|
||
published: request.body?.published ? 1 : 0,
|
||
in_menu: request.body?.in_menu === false ? 0 : 1,
|
||
sort: Number(request.body?.sort) || 0,
|
||
});
|
||
logAudit(getSessionUser(request), 'seite gespeichert', `${title} (/${slug})`);
|
||
return { ok: true, slug, pages: listPages() };
|
||
});
|
||
|
||
app.delete('/api/pages/:slug', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
logAudit(getSessionUser(request), 'seite gelöscht', String(request.params.slug));
|
||
return { deleted: deletePage(String(request.params.slug)), pages: listPages() };
|
||
});
|
||
|
||
// Dienste-Kacheln fürs Portal — öffentlich
|
||
app.get('/api/services', async () => ({ services: listServices() }));
|
||
|
||
/** Dienst-Body prüfen; antwortet bei Fehlern selbst */
|
||
function parseServiceBody(request, reply) {
|
||
const body = request.body ?? {};
|
||
const name = String(body.name ?? '').trim();
|
||
const url = String(body.url ?? '').trim();
|
||
if (!name || !/^https?:\/\/.+/.test(url)) {
|
||
reply.code(400).send({ error: 'name und url (http/https) nötig' });
|
||
return null;
|
||
}
|
||
return {
|
||
name: name.slice(0, 60),
|
||
url: url.slice(0, 300),
|
||
icon: (String(body.icon ?? '').trim() || '🔗').slice(0, 8),
|
||
description: String(body.description ?? '').trim().slice(0, 160),
|
||
sort: Number(body.sort) || 0,
|
||
};
|
||
}
|
||
|
||
app.post('/api/services', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const service = parseServiceBody(request, reply);
|
||
if (!service) return;
|
||
const id = createService(service);
|
||
logAudit(getSessionUser(request), 'dienst hinzugefügt', service.name);
|
||
return { ok: true, services: listServices(), id };
|
||
});
|
||
|
||
app.put('/api/services/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
const service = parseServiceBody(request, reply);
|
||
if (!service) return;
|
||
updateService({ ...service, id: Number(request.params.id) });
|
||
logAudit(getSessionUser(request), 'dienst geändert', service.name);
|
||
return { ok: true, services: listServices() };
|
||
});
|
||
|
||
app.delete('/api/services/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
logAudit(getSessionUser(request), 'dienst gelöscht', String(request.params.id));
|
||
return { deleted: deleteService(Number(request.params.id)), services: listServices() };
|
||
});
|
||
|
||
// Level-Bestenliste + Server-Aktivität — öffentlich.
|
||
// Avatare kommen aus dem Member-Cache: der ist durch den GuildMembers-Intent
|
||
// ohnehin gefüllt, kostet also keinen API-Aufruf. Wer nicht drin ist,
|
||
// bekommt kein Bild — die Liste soll nicht 50 Abrufe an Discord auslösen.
|
||
app.get('/api/levels', async () => {
|
||
const members = new Map();
|
||
for (const guild of client.guilds.cache.values()) {
|
||
for (const [id, member] of guild.members?.cache ?? []) {
|
||
if (!members.has(id)) members.set(id, member);
|
||
}
|
||
}
|
||
return {
|
||
levels: topLevels(50).map((row) => ({
|
||
...row,
|
||
avatar: members.get(row.user_id)?.displayAvatarURL?.({ size: 64 }) ?? null,
|
||
display_name: members.get(row.user_id)?.displayName ?? row.username,
|
||
})),
|
||
};
|
||
});
|
||
app.get('/api/serverstats', async () => ({
|
||
days: activityRange(30),
|
||
members: [...client.guilds.cache.values()].reduce((sum, g) => sum + (g.memberCount ?? 0), 0),
|
||
}));
|
||
|
||
// Discord-Events — öffentlich (5-min-Cache)
|
||
let eventsCache = { at: 0, data: null };
|
||
app.get('/api/events', async () => {
|
||
if (eventsCache.data && Date.now() - eventsCache.at < 5 * 60_000) return eventsCache.data;
|
||
const events = [];
|
||
for (const guild of client.guilds.cache.values()) {
|
||
const fetched = await guild.scheduledEvents?.fetch?.().catch(() => null);
|
||
for (const e of fetched?.values?.() ?? []) {
|
||
events.push({
|
||
name: e.name,
|
||
description: e.description ?? '',
|
||
start: e.scheduledStartAt?.toISOString() ?? null,
|
||
end: e.scheduledEndAt?.toISOString() ?? null,
|
||
url: e.url,
|
||
cover: e.coverImageURL?.({ size: 1024 }) ?? null,
|
||
interested: e.userCount ?? null,
|
||
});
|
||
}
|
||
}
|
||
events.sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
|
||
eventsCache = { at: Date.now(), data: { events } };
|
||
return eventsCache.data;
|
||
});
|
||
|
||
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
|
||
let roadmapCache = { at: 0, repo: null, data: null };
|
||
app.get('/api/roadmap', async (request, reply) => {
|
||
const repo = roadmapRepo();
|
||
if (roadmapCache.data && roadmapCache.repo === repo && Date.now() - roadmapCache.at < 5 * 60_000) {
|
||
return roadmapCache.data;
|
||
}
|
||
try {
|
||
const milestones = await getMilestones(repo);
|
||
const data = {
|
||
repo,
|
||
milestones: milestones.map((m) => ({
|
||
title: m.title,
|
||
description: m.description ?? '',
|
||
state: m.state, // 'open' | 'closed'
|
||
open: m.open_issues ?? 0,
|
||
closed: m.closed_issues ?? 0,
|
||
due_on: m.due_on ?? null,
|
||
})),
|
||
};
|
||
roadmapCache = { at: Date.now(), repo, data };
|
||
return data;
|
||
} catch (error) {
|
||
request.log.error({ err: error }, 'Roadmap-Abruf fehlgeschlagen');
|
||
// Letzten guten Stand weiterreichen, falls vorhanden
|
||
if (roadmapCache.data && roadmapCache.repo === repo) return roadmapCache.data;
|
||
return reply.code(502).send({ error: 'Roadmap nicht abrufbar' });
|
||
}
|
||
});
|
||
|
||
// RSS-Feed der Devlogs — öffentlich abonnierbar
|
||
app.get('/feed.xml', async (request, reply) => {
|
||
const { items } = listDevlogs(20, 0);
|
||
const esc = (s) =>
|
||
String(s).replaceAll('&', '&').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));
|
||
}
|
||
|
||
/** 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 unten
|
||
.flatMap(([, keys]) => keys.map((k) => [k, getSetting(k) ?? '']))
|
||
),
|
||
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();
|
||
|
||
// 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)
|
||
: '',
|
||
};
|
||
}
|
||
|
||
app.get('/api/gameservers', async (request, reply) => {
|
||
if (requireScope(request, reply, 'server')) return;
|
||
return { servers: listGameservers() };
|
||
});
|
||
|
||
// Alle Spiele, die gamedig abfragen kann — damit die Auswahl im Panel nicht
|
||
// an einer von Hand gepflegten Liste hängt und jedes Update automatisch
|
||
// neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port
|
||
// gilt und ob ein Zugangs-Code nötig ist.
|
||
app.get('/api/gamedig-games', async (request, reply) => {
|
||
if (requireScope(request, reply, 'server')) return;
|
||
return { games: spielListe() };
|
||
});
|
||
|
||
app.post('/api/gameservers', async (request, reply) => {
|
||
if (requireScope(request, reply, 'server')) return;
|
||
const server = parseServerBody(request, reply);
|
||
if (!server) return;
|
||
const id = createGameserver(server);
|
||
logAudit(getSessionUser(request), 'server hinzugefügt', server.name);
|
||
return { ok: true, server: getGameserver(id) };
|
||
});
|
||
|
||
app.put('/api/gameservers/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'server')) return;
|
||
const id = Number(request.params.id);
|
||
if (!getGameserver(id)) return reply.code(404).send({ error: 'Server nicht gefunden' });
|
||
const server = parseServerBody(request, reply);
|
||
if (!server) return;
|
||
updateGameserver({ ...server, id });
|
||
logAudit(getSessionUser(request), 'server geändert', server.name);
|
||
return { ok: true, server: getGameserver(id) };
|
||
});
|
||
|
||
app.delete('/api/gameservers/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'server')) return;
|
||
const server = getGameserver(Number(request.params.id));
|
||
if (!server) return { deleted: false };
|
||
// Status-Embed mit aufräumen
|
||
if (server.message_id) {
|
||
const channelId = getSetting('status_channel_id');
|
||
const channel = channelId ? await client.channels.fetch(channelId).catch(() => null) : null;
|
||
const message = await channel?.messages?.fetch(server.message_id).catch(() => null);
|
||
await message?.delete().catch(() => {});
|
||
}
|
||
deleteGameserver(server.id);
|
||
logAudit(getSessionUser(request), 'server gelöscht', server.name);
|
||
return { deleted: true };
|
||
});
|
||
|
||
// --- Triggers (Admin) ---
|
||
|
||
app.get('/api/triggers', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { triggers: listTriggers() };
|
||
});
|
||
|
||
app.put('/api/triggers', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
|
||
const replyText = String(request.body?.reply ?? '').trim();
|
||
if (keyword.length < 3 || !replyText) {
|
||
return reply.code(400).send({ error: 'keyword (min 3 Zeichen) und reply nötig' });
|
||
}
|
||
saveTrigger(keyword.slice(0, 60), replyText.slice(0, 2000));
|
||
return { ok: true };
|
||
});
|
||
|
||
app.delete('/api/triggers/:keyword', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
|
||
});
|
||
|
||
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
|
||
|
||
app.get('/api/tags', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { tags: listTags() };
|
||
});
|
||
|
||
app.put('/api/tags/:name', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
const name = String(request.params.name).trim().toLowerCase();
|
||
const content = String(request.body?.content ?? '').trim();
|
||
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
|
||
return reply.code(400).send({ error: 'Name: nur Kleinbuchstaben, Zahlen, - und _ (max 40)' });
|
||
}
|
||
if (!content) return reply.code(400).send({ error: 'content nötig' });
|
||
saveTag(name, content.slice(0, 2000));
|
||
return { ok: true, name };
|
||
});
|
||
|
||
app.delete('/api/tags/:name', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
|
||
});
|
||
|
||
// --- Geplante Posts (Admin) ---
|
||
|
||
app.get('/api/scheduled', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { posts: listScheduledPosts() };
|
||
});
|
||
|
||
app.post('/api/scheduled', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
const body = request.body ?? {};
|
||
const channelId = String(body.channel_id ?? '');
|
||
const content = String(body.content ?? '').trim();
|
||
if (!client.channels.cache.get(channelId)?.isTextBased?.()) {
|
||
return reply.code(400).send({ error: 'Kanal nicht gefunden' });
|
||
}
|
||
if (!content) return reply.code(400).send({ error: 'content nötig' });
|
||
|
||
let nextRunAt;
|
||
try {
|
||
nextRunAt = computeNextRun({
|
||
type: body.type, date: body.date, time: body.time, weekday: body.weekday,
|
||
});
|
||
} catch (error) {
|
||
return reply.code(400).send({ error: error.message });
|
||
}
|
||
const id = createScheduledPost({
|
||
channel_id: channelId,
|
||
content: content.slice(0, 4000),
|
||
embed_title: String(body.embed_title ?? '').trim() || null,
|
||
type: body.type,
|
||
date: body.date ?? null,
|
||
time: body.time,
|
||
weekday: body.weekday ?? null,
|
||
next_run_at: nextRunAt,
|
||
});
|
||
request.log.info(`Geplanter Post ${id} (${body.type}) → ${nextRunAt} UTC`);
|
||
return { ok: true, id, next_run_at: nextRunAt };
|
||
});
|
||
|
||
app.delete('/api/scheduled/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { deleted: deleteScheduledPost(Number(request.params.id)) };
|
||
});
|
||
|
||
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
|
||
app.post('/api/compose', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
|
||
const { channel_id: channelId, message_id: messageId, content, embed_title: embedTitle, embed } = request.body ?? {};
|
||
const text = String(content ?? '').trim();
|
||
const cleanEmbed = embed ? sanitizeEmbed(embed) : null;
|
||
if (!channelId || (!text && !cleanEmbed)) {
|
||
return reply.code(400).send({ error: 'channel_id und content oder embed nötig' });
|
||
}
|
||
const channel = await client.channels.fetch(String(channelId)).catch(() => null);
|
||
if (!channel?.isTextBased()) {
|
||
return reply.code(404).send({ error: 'Kanal nicht gefunden' });
|
||
}
|
||
|
||
// Drei Modi: volles Embed (Composer v2) > Embed-Titel-Kurzform > Plaintext
|
||
let payload;
|
||
if (cleanEmbed) {
|
||
payload = { content: text ? text.slice(0, 2000) : null, embeds: [cleanEmbed] };
|
||
} else if (embedTitle?.trim()) {
|
||
payload = {
|
||
content: null,
|
||
embeds: [
|
||
new EmbedBuilder()
|
||
.setColor(brandColor())
|
||
.setTitle(String(embedTitle).slice(0, 200))
|
||
.setDescription(text.slice(0, 4000))
|
||
.setFooter({ text: brandName() })
|
||
.setTimestamp(),
|
||
],
|
||
};
|
||
} else {
|
||
payload = { content: text.slice(0, 2000), embeds: [] };
|
||
}
|
||
|
||
if (messageId) {
|
||
const message = await channel.messages.fetch(String(messageId)).catch(() => null);
|
||
if (!message) return reply.code(404).send({ error: 'Nachricht nicht gefunden' });
|
||
if (message.author?.id !== client.user?.id) {
|
||
return reply.code(403).send({ error: 'Nur eigene Bot-Nachrichten sind editierbar' });
|
||
}
|
||
await message.edit(payload);
|
||
request.log.info(`Composer: Nachricht ${messageId} bearbeitet`);
|
||
logAudit(getSessionUser(request), 'composer bearbeitet', `#${channel.name ?? channelId}`);
|
||
return { ok: true, edited: true, message_id: String(messageId) };
|
||
}
|
||
|
||
const message = await channel.send(payload);
|
||
const { maybeCrosspost } = await import('../embeds.js');
|
||
await maybeCrosspost(message);
|
||
request.log.info(`Composer: Nachricht ${message.id} gesendet`);
|
||
logAudit(getSessionUser(request), 'composer gesendet', `#${channel.name ?? channelId}`);
|
||
return { ok: true, edited: false, message_id: message.id };
|
||
});
|
||
|
||
// Bot-Avatar/-Banner setzen (Admin) — Bild als Base64-Data-URL im Body
|
||
app.post('/api/branding/:target', { bodyLimit: 12 * 1024 * 1024 }, async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
|
||
const target = request.params.target;
|
||
if (!['avatar', 'banner'].includes(target)) {
|
||
return reply.code(400).send({ error: 'target: avatar oder banner' });
|
||
}
|
||
const dataUrl = String(request.body?.data ?? '');
|
||
const match = dataUrl.match(/^data:image\/(png|jpe?g|gif|webp);base64,(.+)$/s);
|
||
if (!match) return reply.code(400).send({ error: 'data: Base64-Data-URL eines Bildes erwartet' });
|
||
const buffer = Buffer.from(match[2], 'base64');
|
||
if (buffer.length > 8 * 1024 * 1024) {
|
||
return reply.code(400).send({ error: 'Bild zu groß (max 8 MB)' });
|
||
}
|
||
|
||
try {
|
||
if (target === 'avatar') await client.user.setAvatar(buffer);
|
||
else await client.user.setBanner(buffer);
|
||
request.log.info(`Bot-${target} aktualisiert (${(buffer.length / 1024).toFixed(0)} KB)`);
|
||
logAudit(getSessionUser(request), 'branding', `bot-${target} geändert`);
|
||
return { ok: true };
|
||
} catch (error) {
|
||
request.log.error({ err: error }, `Bot-${target} fehlgeschlagen`);
|
||
// Discord rate-limitet Avatar-Änderungen hart (~2 pro 10 min)
|
||
return reply.code(502).send({ error: `Discord lehnt ab: ${error.message?.slice(0, 120)}` });
|
||
}
|
||
});
|
||
|
||
// --- Composer-Vorlagen (Scope: content) ---
|
||
|
||
app.get('/api/templates', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { templates: listTemplates() };
|
||
});
|
||
|
||
app.put('/api/templates/:name', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
const name = String(request.params.name).trim().slice(0, 60);
|
||
if (!name) return reply.code(400).send({ error: 'name nötig' });
|
||
const payload = {
|
||
content: String(request.body?.content ?? '').slice(0, 2000),
|
||
embed: request.body?.embed ? sanitizeEmbed(request.body.embed) : null,
|
||
};
|
||
if (!payload.content && !payload.embed) {
|
||
return reply.code(400).send({ error: 'Vorlage ist leer' });
|
||
}
|
||
saveTemplate(name, payload);
|
||
logAudit(getSessionUser(request), 'vorlage gespeichert', name);
|
||
return { ok: true, templates: listTemplates() };
|
||
});
|
||
|
||
app.delete('/api/templates/:name', async (request, reply) => {
|
||
if (requireScope(request, reply, 'content')) return;
|
||
return { deleted: deleteTemplate(String(request.params.name)), templates: listTemplates() };
|
||
});
|
||
|
||
// --- Member-Bereich: Profil, Rollen-Selfservice, Wunsch-Voting ---
|
||
|
||
// Membership kurz cachen, damit nicht jeder Klick die Discord-API trifft
|
||
const memberCheckCache = new Map(); // userId → { ok, at }
|
||
async function requireMember(request, reply) {
|
||
const user = getSessionUser(request);
|
||
if (!user) return reply.code(401).send({ error: 'login required' });
|
||
const cached = memberCheckCache.get(user.id);
|
||
if (cached && Date.now() - cached.at < 5 * 60_000) {
|
||
return cached.ok ? null : reply.code(403).send({ error: 'not a member' });
|
||
}
|
||
const ok = await isGuildMember(client, user.id);
|
||
memberCheckCache.set(user.id, { ok, at: Date.now() });
|
||
return ok ? null : reply.code(403).send({ error: 'not a member' });
|
||
}
|
||
|
||
/** Guild-Member des eingeloggten Users holen (null wenn nicht auffindbar) */
|
||
async function fetchSelfMember(request) {
|
||
const user = getSessionUser(request);
|
||
const guildId = discordGuildId() ?? [...client.guilds.cache.keys()][0];
|
||
if (!user || !guildId) return null;
|
||
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
|
||
return guild ? await guild.members.fetch(user.id).catch(() => null) : null;
|
||
}
|
||
|
||
app.get('/api/invite', async () => ({ invite: discordInviteUrl() }));
|
||
|
||
// Betreiber-Angaben für Impressum/Datenschutz — öffentlich (müssen es sein)
|
||
app.get('/api/legal', async () => ({
|
||
legal: legalInfo(),
|
||
brand: brandName(),
|
||
publicUrl: publicUrl(),
|
||
// Für Querverweise zwischen den beiden Seiten
|
||
hubUrl: hubUrl(),
|
||
botUrl: botUrl(),
|
||
split: hubUrl() !== botUrl(),
|
||
guildConfigured: Boolean(discordGuildId()),
|
||
}));
|
||
|
||
// DSGVO Art. 17: eigene Daten löschen
|
||
app.delete('/api/profile', async (request, reply) => {
|
||
if (await requireMember(request, reply)) return;
|
||
const user = getSessionUser(request);
|
||
const deleted = deleteUserData(user.id);
|
||
request.log.info(`Datenlöschung für ${user.id}: ${JSON.stringify(deleted)}`);
|
||
logAudit(user, 'daten gelöscht (dsgvo)', user.username);
|
||
return { ok: true, deleted };
|
||
});
|
||
|
||
app.get('/api/profile', async (request, reply) => {
|
||
if (await requireMember(request, reply)) return;
|
||
const user = getSessionUser(request);
|
||
const member = await fetchSelfMember(request);
|
||
|
||
const roles = member
|
||
? [...member.roles.cache.values()]
|
||
.filter((r) => r.id !== member.guild.id)
|
||
.sort((a, b) => b.position - a.position)
|
||
.map((r) => ({ id: r.id, name: r.name, color: r.hexColor === '#000000' ? null : r.hexColor }))
|
||
: [];
|
||
|
||
const row = getLevelRow(user.id);
|
||
let level = null;
|
||
if (row) {
|
||
const rank = topLevels(1000).findIndex((l) => l.user_id === user.id) + 1;
|
||
const base = xpForLevel(row.level);
|
||
const next = xpForLevel(row.level + 1);
|
||
level = {
|
||
level: row.level, xp: row.xp, rank: rank || null,
|
||
progress: row.xp - base, needed: next - base,
|
||
};
|
||
}
|
||
|
||
return {
|
||
user: {
|
||
...user,
|
||
displayName: member?.displayName ?? user.username,
|
||
avatar: member?.displayAvatarURL?.({ size: 128 }) ?? user.avatar,
|
||
joinedAt: member?.joinedAt?.toISOString?.() ?? null,
|
||
},
|
||
roles,
|
||
level,
|
||
playtester: isPlaytester(user.id),
|
||
alphaKey: alphaKeyOf(user.id),
|
||
};
|
||
});
|
||
|
||
// Veröffentlichte Rollen-Menüs + eigener Rollen-Stand
|
||
app.get('/api/myroles', async (request, reply) => {
|
||
if (await requireMember(request, reply)) return;
|
||
const member = await fetchSelfMember(request);
|
||
if (!member) return reply.code(404).send({ error: 'Member nicht gefunden' });
|
||
|
||
const menus = listRoleMenus()
|
||
.filter((m) => m.message_id) // nur veröffentlichte
|
||
.map((m) => ({
|
||
id: m.id, title: m.title, description: m.description, exclusive: Boolean(m.exclusive),
|
||
entries: JSON.parse(m.entries || '[]').map((e) => ({
|
||
role_id: e.role_id, label: e.label, emoji: e.emoji ?? '', style: e.style ?? 'secondary',
|
||
has: member.roles.cache.has(e.role_id),
|
||
})),
|
||
}));
|
||
return { menus };
|
||
});
|
||
|
||
// Rolle togglen — nur Rollen aus veröffentlichten Menüs, Exklusiv-Logik wie im Button
|
||
app.post('/api/myroles/toggle', async (request, reply) => {
|
||
if (await requireMember(request, reply)) return;
|
||
const menuId = Number(request.body?.menu_id);
|
||
const roleId = String(request.body?.role_id ?? '');
|
||
const menu = getRoleMenu(menuId);
|
||
const entries = menu ? JSON.parse(menu.entries || '[]') : [];
|
||
const entry = entries.find((e) => e.role_id === roleId);
|
||
if (!menu?.message_id || !entry) {
|
||
return reply.code(400).send({ error: 'Rolle gehört zu keinem aktiven Menü' });
|
||
}
|
||
const member = await fetchSelfMember(request);
|
||
if (!member) return reply.code(404).send({ error: 'Member nicht gefunden' });
|
||
|
||
try {
|
||
if (member.roles.cache.has(roleId)) {
|
||
await member.roles.remove(roleId);
|
||
return { has: false, label: entry.label };
|
||
}
|
||
if (menu.exclusive) {
|
||
for (const other of entries) {
|
||
if (other.role_id !== roleId && member.roles.cache.has(other.role_id)) {
|
||
await member.roles.remove(other.role_id).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
await member.roles.add(roleId);
|
||
return { has: true, label: entry.label };
|
||
} catch {
|
||
return reply.code(502).send({ error: 'Rolle konnte nicht geändert werden — Bot-Rechte prüfen' });
|
||
}
|
||
});
|
||
|
||
// Wunsch einreichen — läuft durch denselben Weg wie /wunsch, damit ein
|
||
// Wunsch von hier genauso aussieht: Embed, 👍 und Thread zum Reden
|
||
app.post('/api/wishes', async (request, reply) => {
|
||
if (await requireMember(request, reply)) return;
|
||
const user = getSessionUser(request);
|
||
const idea = String(request.body?.idea ?? '').trim().slice(0, 500);
|
||
if (idea.length < 5) return reply.code(400).send({ error: 'Idee zu kurz' });
|
||
const categoryId = Number(request.body?.category_id) || null;
|
||
try {
|
||
const { id } = await wunschAnlegen(client, {
|
||
idee: idea,
|
||
autor: user.username,
|
||
autorId: user.id,
|
||
avatar: user.avatar ?? null,
|
||
categoryId,
|
||
});
|
||
return { ok: true, id };
|
||
} catch (error) {
|
||
return reply.code(400).send({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist
|
||
// dieselbe Zeile in wish_votes
|
||
app.post('/api/wishes/:id/vote', async (request, reply) => {
|
||
if (await requireMember(request, reply)) return;
|
||
const user = getSessionUser(request);
|
||
const id = Number(request.params.id);
|
||
const wunsch = getWish(id);
|
||
if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
|
||
return { voted: toggleWishVote(id, user.id) };
|
||
});
|
||
|
||
// Ein einzelner Wunsch mit allem, was im Thread darunter steht.
|
||
// Muss vor /api/wishes/:id/... stehen? Nein — andere Tiefe, kein Konflikt.
|
||
app.get('/api/wishes/:id', async (request, reply) => {
|
||
const id = Number(request.params.id);
|
||
const wunsch = getWish(id);
|
||
if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
|
||
const user = getSessionUser(request);
|
||
const gid = discordGuildId();
|
||
return {
|
||
wunsch: {
|
||
...wunsch,
|
||
voted: user ? myWishVotes(user.id).includes(id) : false,
|
||
thread_url: gid && wunsch.thread_id
|
||
? `https://discord.com/channels/${gid}/${wunsch.thread_id}`
|
||
: null,
|
||
},
|
||
kommentare: wishComments(id),
|
||
canVote: Boolean(user),
|
||
};
|
||
});
|
||
|
||
// Suche vor dem Einreichen — Doppler gar nicht erst entstehen lassen
|
||
app.get('/api/wishes/suche', async (request) => {
|
||
const q = String(request.query?.q ?? '').trim();
|
||
return { treffer: q.length >= 3 ? searchWishes(q) : [] };
|
||
});
|
||
|
||
// Bereiche, in die ein Wunsch faellt. Ohne Eintrag bleibt die Liste eine
|
||
// Liste — Schubladen lohnen sich erst ab einer gewissen Zahl.
|
||
app.get('/api/wish-categories', async (request, reply) => {
|
||
if (requireAnyScope(request, reply)) return;
|
||
return { bereiche: listWishCategories() };
|
||
});
|
||
|
||
function bereichBody(request, reply) {
|
||
const name = String(request.body?.name ?? '').trim();
|
||
if (!name) {
|
||
reply.code(400).send({ error: 'Name fehlt.' });
|
||
return null;
|
||
}
|
||
return {
|
||
name: name.slice(0, 40),
|
||
emoji: String(request.body?.emoji ?? '').trim().slice(0, 32),
|
||
sort: Number(request.body?.sort) || 0,
|
||
};
|
||
}
|
||
|
||
app.post('/api/wish-categories', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const b = bereichBody(request, reply);
|
||
if (!b) return;
|
||
createWishCategory(b);
|
||
logAudit(getSessionUser(request), 'wunsch-bereich angelegt', b.name);
|
||
return { ok: true, bereiche: listWishCategories() };
|
||
});
|
||
|
||
app.put('/api/wish-categories/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const b = bereichBody(request, reply);
|
||
if (!b) return;
|
||
updateWishCategory({ ...b, id: Number(request.params.id) });
|
||
logAudit(getSessionUser(request), 'wunsch-bereich geaendert', b.name);
|
||
return { ok: true, bereiche: listWishCategories() };
|
||
});
|
||
|
||
// Wuensche im geloeschten Bereich bleiben stehen, nur ohne Schild
|
||
app.delete('/api/wish-categories/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const weg = deleteWishCategory(Number(request.params.id));
|
||
logAudit(getSessionUser(request), 'wunsch-bereich entfernt', String(request.params.id));
|
||
return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) };
|
||
});
|
||
|
||
// Fehlende Tags im Forum anlegen — spart das Abtippen der Bereichsnamen
|
||
// und der fuenf Staende, und Tippfehler waeren hier stumme Fehlschlaege
|
||
app.post('/api/wish-categories/tags', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
try {
|
||
const res = await forumTagsAnlegen(client);
|
||
logAudit(getSessionUser(request), 'forum-tags angelegt', res.angelegt.join(', ') || 'keine');
|
||
return { ok: true, ...res };
|
||
} catch (error) {
|
||
return reply.code(400).send({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// --- Wünsche verwalten (Team) ---
|
||
|
||
const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [
|
||
id, { label, emoji: STATUS_EMOJI[id] },
|
||
]));
|
||
|
||
/**
|
||
* Den Wunsch-Post in Discord auf den neuen Stand bringen. Dort haben die
|
||
* Leute abgestimmt, dort gehört die Antwort hin — die Webseite allein
|
||
* erreicht die meisten nie.
|
||
*/
|
||
async function wunschPostAktualisieren(wunsch) {
|
||
if (!wunsch.message_id) return;
|
||
const channelId = votingChannelId();
|
||
if (!channelId) return;
|
||
// Im Forum liegt die Startnachricht im Beitrag selbst, nicht im Kanal —
|
||
// deshalb zuerst dort suchen, wo der Wunsch seinen Thread hat
|
||
const wo = wunsch.thread_id
|
||
? await client.channels.fetch(wunsch.thread_id).catch(() => null)
|
||
: null;
|
||
const channel = wo ?? await client.channels.fetch(channelId).catch(() => null);
|
||
let message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null);
|
||
if (!message && wo) {
|
||
// Textkanal mit Thread darunter: die Nachricht steht eine Ebene höher
|
||
const eltern = await client.channels.fetch(channelId).catch(() => null);
|
||
message = await eltern?.messages?.fetch(wunsch.message_id).catch(() => null);
|
||
}
|
||
if (!message?.embeds?.[0]) return;
|
||
|
||
const s = STATUS[wunsch.status] ?? STATUS.offen;
|
||
const alt = message.embeds[0];
|
||
// Den Kopf nur um den Stand ergänzen, nicht den Bereich überschreiben —
|
||
// dort steht „🚗 Fahrzeuge", das soll bleiben
|
||
const basis = String(alt.title ?? 'Feature-Wunsch').split(' — ')[0];
|
||
const embed = EmbedBuilder.from(alt).setTitle(`${basis} — ${s.emoji} ${s.label}`);
|
||
embed.setFields(wunsch.status_grund
|
||
? [{ name: 'Vom Team', value: String(wunsch.status_grund).slice(0, 1000) }]
|
||
: []);
|
||
await message.edit({ embeds: [embed] }).catch(() => {});
|
||
}
|
||
|
||
app.put('/api/wishes/:id/status', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const id = Number(request.params.id);
|
||
const status = String(request.body?.status ?? '');
|
||
if (!STATUS[status]) return reply.code(400).send({ error: 'Unbekannter Status.' });
|
||
const vorher = getWish(id);
|
||
if (!vorher) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
|
||
setWishStatus(id, status, String(request.body?.grund ?? '').trim().slice(0, 500));
|
||
|
||
const wunsch = getWish(id);
|
||
await wunschPostAktualisieren(wunsch);
|
||
await wunschTagsAktualisieren(client, wunsch, STATUS[status].label);
|
||
|
||
// Kreis schliessen: wer dafür gestimmt hat, erfährt es — aber nur beim
|
||
// Sprung auf umgesetzt, und nur einmal (vorher war ein anderer Status)
|
||
let benachrichtigt = 0;
|
||
if (status === 'umgesetzt' && vorher.status !== 'umgesetzt') {
|
||
const empfaenger = new Set(wishVoters(id));
|
||
if (wunsch.author_id) empfaenger.add(wunsch.author_id);
|
||
for (const userId of empfaenger) {
|
||
const user = await client.users.fetch(userId).catch(() => null);
|
||
if (!user) continue;
|
||
const ok = await user.send(
|
||
`✅ **Umgesetzt!** Der Wunsch, für den du gestimmt hast, ist jetzt drin:\n`
|
||
+ `> ${String(wunsch.idea).slice(0, 300)}`
|
||
+ (wunsch.status_grund ? `\n\n${wunsch.status_grund}` : '')
|
||
).then(() => true).catch(() => false);
|
||
if (ok) benachrichtigt++;
|
||
}
|
||
}
|
||
logAudit(getSessionUser(request), `wunsch ${status}`, String(wunsch.idea).slice(0, 60));
|
||
return { ok: true, benachrichtigt, wishes: topWishes(50) };
|
||
});
|
||
|
||
app.put('/api/wishes/:id/category', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const id = Number(request.params.id);
|
||
if (!getWish(id)) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
|
||
setWishCategory(id, Number(request.body?.category_id) || null);
|
||
return { ok: true, wishes: topWishes(50) };
|
||
});
|
||
|
||
// Doppler zusammenführen: Stimmen wandern zum Original, wer für beide
|
||
// gestimmt hat, zählt dort weiterhin einmal
|
||
app.post('/api/wishes/:id/merge', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const doppler = Number(request.params.id);
|
||
const original = Number(request.body?.original);
|
||
if (!getWish(doppler) || !getWish(original)) {
|
||
return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
|
||
}
|
||
if (doppler === original) {
|
||
return reply.code(400).send({ error: 'Ein Wunsch kann nicht sein eigener Doppler sein.' });
|
||
}
|
||
mergeWish(doppler, original);
|
||
logAudit(getSessionUser(request), 'wünsche zusammengeführt', `#${doppler} → #${original}`);
|
||
return { ok: true, wishes: topWishes(50) };
|
||
});
|
||
|
||
app.delete('/api/wishes/:id', async (request, reply) => {
|
||
if (requireScope(request, reply, 'community')) return;
|
||
const wunsch = getWish(Number(request.params.id));
|
||
if (!wunsch) return { deleted: false, wishes: topWishes(50) };
|
||
// Erst die Dateinamen holen, dann loeschen — danach sind sie weg
|
||
const bilder = wishCommentImages(wunsch.id);
|
||
deleteWish(wunsch.id);
|
||
await bilderLoeschen(wunschBilderDir, bilder);
|
||
logAudit(getSessionUser(request), 'wunsch gelöscht', String(wunsch.idea).slice(0, 60));
|
||
return { deleted: true, wishes: topWishes(50) };
|
||
});
|
||
|
||
// --- Team-Verwaltung (nur Owner) ---
|
||
|
||
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
|
||
|
||
app.get('/api/webadmins', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
// Avatare aus dem Member-Cache — kostet keinen Discord-Aufruf,
|
||
// dieselbe Quelle wie bei der Bestenliste
|
||
const bild = (id) => {
|
||
for (const guild of client.guilds.cache.values()) {
|
||
const m = guild.members?.cache?.get(id);
|
||
if (m) return { avatar: m.displayAvatarURL?.({ size: 64 }) ?? null, name: m.displayName };
|
||
}
|
||
return { avatar: null, name: null };
|
||
};
|
||
const owner = bild(config.adminDiscordId);
|
||
return {
|
||
admins: listWebAdmins().map((a) => ({ ...a, ...bild(a.user_id) })),
|
||
scopes: TEAM_SCOPES,
|
||
owner: { user_id: config.adminDiscordId, ...owner },
|
||
};
|
||
});
|
||
|
||
app.put('/api/webadmins', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
const userId = String(request.body?.user_id ?? '').trim();
|
||
const scopes = (Array.isArray(request.body?.scopes) ? request.body.scopes : [])
|
||
.filter((s) => TEAM_SCOPES.includes(s));
|
||
if (!/^\d{15,21}$/.test(userId)) {
|
||
return reply.code(400).send({ error: 'user_id: Discord-User-ID erwartet' });
|
||
}
|
||
if (userId === config.adminDiscordId) {
|
||
return reply.code(400).send({ error: 'Der Owner hat immer alle Rechte' });
|
||
}
|
||
if (scopes.length === 0) return reply.code(400).send({ error: 'mindestens ein Bereich' });
|
||
// Username für die Anzeige holen (best effort)
|
||
let username = String(request.body?.username ?? '').trim();
|
||
if (!username) {
|
||
const user = await client.users.fetch(userId).catch(() => null);
|
||
username = user?.username ?? userId;
|
||
}
|
||
saveWebAdmin(userId, username, scopes);
|
||
request.log.info(`Team-Mitglied ${username} (${userId}): ${scopes.join(',')}`);
|
||
logAudit(getSessionUser(request), 'team geändert', `${username}: ${scopes.join(',')}`);
|
||
return { ok: true, admins: listWebAdmins() };
|
||
});
|
||
|
||
app.delete('/api/webadmins/:id', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
logAudit(getSessionUser(request), 'team entfernt', String(request.params.id));
|
||
return { deleted: deleteWebAdmin(String(request.params.id)), admins: listWebAdmins() };
|
||
});
|
||
|
||
// Audit-Log: die letzten Team-/Owner-Aktionen im Webinterface (nur Owner)
|
||
app.get('/api/auditlog', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
const bild = (id) => {
|
||
for (const guild of client.guilds.cache.values()) {
|
||
const m = guild.members?.cache?.get(id);
|
||
if (m) return m.displayAvatarURL?.({ size: 48 }) ?? null;
|
||
}
|
||
return null;
|
||
};
|
||
return { entries: listAudit(100).map((e) => ({ ...e, avatar: bild(e.user_id) })) };
|
||
});
|
||
|
||
// --- SSO-Apps (nur Owner): Fremd-Dienste, die den Discord-Login mitnutzen ---
|
||
|
||
app.get('/api/ssoapps', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
return { apps: listSsoApps() };
|
||
});
|
||
|
||
app.post('/api/ssoapps', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
const slug = String(request.body?.slug ?? '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||
const name = String(request.body?.name ?? '').trim();
|
||
const prefix = String(request.body?.redirect_prefix ?? '').trim();
|
||
if (!slug || !name) return reply.code(400).send({ error: 'slug und name nötig' });
|
||
if (!prefix.split(/[\s,]+/).filter(Boolean).every((p) => /^https?:\/\/.+/.test(p))) {
|
||
return reply.code(400).send({ error: 'redirect_prefix: http(s)-URL(s) erwartet' });
|
||
}
|
||
if (getSsoApp(slug)) return reply.code(409).send({ error: 'slug schon vergeben' });
|
||
|
||
const secret = `sso_${crypto.randomBytes(24).toString('hex')}`;
|
||
saveSsoApp({ slug, name, redirect_prefix: prefix, secret });
|
||
logAudit(getSessionUser(request), 'sso-app angelegt', `${name} (${slug})`);
|
||
// Secret nur in dieser einen Antwort!
|
||
return { slug, name, redirect_prefix: prefix, secret };
|
||
});
|
||
|
||
app.delete('/api/ssoapps/:slug', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
logAudit(getSessionUser(request), 'sso-app gelöscht', String(request.params.slug));
|
||
return { deleted: deleteSsoApp(String(request.params.slug)), apps: listSsoApps() };
|
||
});
|
||
|
||
// --- API-Keys (Admin) — für /api/v1/* ---
|
||
|
||
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
||
|
||
app.get('/api/apikeys', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
return { keys: listApiKeys() };
|
||
});
|
||
|
||
app.post('/api/apikeys', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
|
||
const name = String(request.body?.name ?? '').trim();
|
||
const scopes = (request.body?.scopes ?? []).filter((s) => VALID_SCOPES.includes(s));
|
||
if (!name || scopes.length === 0) {
|
||
return reply.code(400).send({ error: 'name und mindestens ein Scope nötig' });
|
||
}
|
||
const { id, key } = createApiKey(name, scopes);
|
||
request.log.info(`API-Key '${name}' erstellt (Scopes: ${scopes.join(',')})`);
|
||
logAudit(getSessionUser(request), 'api-key erstellt', name);
|
||
// Klartext-Key nur in dieser einen Antwort!
|
||
return { id, key, name, scopes };
|
||
});
|
||
|
||
app.delete('/api/apikeys/:id', async (request, reply) => {
|
||
if (requireAdmin(request, reply)) return;
|
||
const deleted = deleteApiKey(Number(request.params.id));
|
||
request.log.info(`API-Key ${request.params.id} widerrufen: ${deleted}`);
|
||
return { deleted };
|
||
});
|
||
|
||
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
|
||
app.post('/api/settings/test/:target', async (request, reply) => {
|
||
if (requireScope(request, reply, 'settings')) return;
|
||
|
||
const target = request.params.target;
|
||
|
||
// Sonderfall: echten Wochen-Rückblick sofort posten
|
||
if (target === 'recap') {
|
||
try {
|
||
const { postWeeklyRecap } = await import('../bot/weekly-recap.js');
|
||
await postWeeklyRecap(client);
|
||
return { ok: true };
|
||
} catch (error) {
|
||
request.log.error({ err: error }, 'Wochen-Rückblick-Test fehlgeschlagen');
|
||
return reply.code(502).send({ error: 'Senden fehlgeschlagen' });
|
||
}
|
||
}
|
||
|
||
// Sonderfall: Backup sofort erstellen
|
||
if (target === 'backup') {
|
||
try {
|
||
const { runBackup } = await import('../backup.js');
|
||
const result = await runBackup(client);
|
||
return { ok: true, sizeBytes: result.sizeBytes };
|
||
} catch (error) {
|
||
request.log.error({ err: error }, 'Backup-Test fehlgeschlagen');
|
||
return reply.code(502).send({ error: 'Backup fehlgeschlagen' });
|
||
}
|
||
}
|
||
|
||
// Alle Gitea-Repos als Bundle sichern (kann je nach Größe dauern)
|
||
if (target === 'repobackup') {
|
||
try {
|
||
const { runRepoBackup } = await import('../repo-backup.js');
|
||
const result = await runRepoBackup();
|
||
logAudit(getSessionUser(request), 'repo-backup gestartet', `${result.ok}/${result.repos} Repos`);
|
||
return { ok: true, ...result };
|
||
} catch (error) {
|
||
request.log.error({ err: error }, 'Repo-Backup fehlgeschlagen');
|
||
return reply.code(502).send({ error: `Repo-Backup: ${error.message?.slice(0, 120)}` });
|
||
}
|
||
}
|
||
const channelId =
|
||
target === 'commit' ? commitChannelId()
|
||
: target === 'devlog' ? devlogChannelId()
|
||
: target === 'release' ? releaseChannelId()
|
||
: null;
|
||
if (!channelId) {
|
||
return reply.code(400).send({ error: 'Kein Kanal konfiguriert' });
|
||
}
|
||
try {
|
||
const channel = await client.channels.fetch(channelId);
|
||
await channel.send({
|
||
embeds: [
|
||
new EmbedBuilder()
|
||
.setColor(brandColor())
|
||
.setTitle('🔧 Test')
|
||
.setDescription(
|
||
`Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.`
|
||
)
|
||
.setFooter({ text: brandFooter('SETUP'), iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) }),
|
||
],
|
||
});
|
||
return { ok: true };
|
||
} catch (error) {
|
||
request.log.error({ err: error }, 'Test-Nachricht fehlgeschlagen');
|
||
return reply.code(502).send({ error: 'Senden fehlgeschlagen — Rechte im Kanal prüfen' });
|
||
}
|
||
});
|
||
}
|