Member-Bereich: Profilseite, Rollen-Selfservice, Web-Voting, Member-Gate

Der Discord-Login ist jetzt für alle Member nützlich:
- /profil: Rang + XP-Fortschritt, Discord-Rollen als Chips, Playtester-
  Badge, eigener Alpha-Key mit Kopier-Button
- Rollen-Selfservice: veröffentlichte Rollen-Menüs direkt im Browser
  togglen (gleiche Exklusiv-Logik wie die Discord-Buttons; nur Rollen
  aus aktiven Menüs erlaubt)
- Wunsch-Voting im Web: auf /roadmap Wünsche einreichen (postet wie
  /wunsch in den Voting-Kanal) und upvoten (wish_votes-Tabelle,
  ein Vote pro Member, unabhängig von Discord-👍)
- Member-Gate: Login nur für Mitglieder der konfigurierten Guild;
  Abgewiesene sehen einen Banner mit Discord-Invite-Link (neue
  Settings member_gate_enabled + discord_invite_url im System-Tab)
- Membership-Check mit 5-Minuten-Cache, Owner immer erlaubt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 21:52:59 +02:00
co-authored by Claude Fable 5
parent 3bfa559f2e
commit aa12dff7ab
11 changed files with 551 additions and 15 deletions
+28 -1
View File
@@ -142,9 +142,11 @@ const deletePlaytester = db.prepare('DELETE FROM playtesters WHERE user_id = ?')
const listPlaytestersStmt = db.prepare(
'SELECT user_id, username, applied_at FROM playtesters ORDER BY applied_at'
);
const hasPlaytesterStmt = db.prepare('SELECT 1 FROM playtesters WHERE user_id = ?');
export const addPlaytester = (userId, username) => insertPlaytester.run(userId, username).changes > 0;
export const removePlaytester = (userId) => deletePlaytester.run(userId).changes > 0;
export const listPlaytesters = () => listPlaytestersStmt.all();
export const isPlaytester = (userId) => Boolean(hasPlaytesterStmt.get(userId));
const hasStarboardStmt = db.prepare('SELECT 1 FROM starboard WHERE message_id = ?');
const insertStarboard = db.prepare(
@@ -206,13 +208,38 @@ const insertWish = db.prepare('INSERT INTO wishes (message_id, author, idea) VAL
const wishScoreStmt = db.prepare('UPDATE wishes SET score = MAX(0, score + ?) WHERE message_id = ?');
const hasWishStmt = db.prepare('SELECT 1 FROM wishes WHERE message_id = ?');
const topWishesStmt = db.prepare(
'SELECT idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?'
'SELECT message_id, idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?'
);
export const saveWish = (messageId, author, idea) => insertWish.run(messageId, author, idea);
export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId));
export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId);
export const topWishes = (limit = 20) => topWishesStmt.all(limit);
// Web-Votes auf Wünsche (Discord-👍 laufen separat über Reactions)
db.exec(`
CREATE TABLE IF NOT EXISTS wish_votes (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
PRIMARY KEY (message_id, user_id)
);
`);
const hasWishVoteStmt = db.prepare('SELECT 1 FROM wish_votes WHERE message_id = ? AND user_id = ?');
const insertWishVote = db.prepare('INSERT OR IGNORE INTO wish_votes (message_id, user_id) VALUES (?, ?)');
const deleteWishVote = db.prepare('DELETE FROM wish_votes WHERE message_id = ? AND user_id = ?');
const myWishVotesStmt = db.prepare('SELECT message_id FROM wish_votes WHERE user_id = ?');
/** Web-Vote togglen; passt den Score mit an. @returns {boolean} true = jetzt gevotet */
export function toggleWishVote(messageId, userId) {
if (hasWishVoteStmt.get(messageId, userId)) {
deleteWishVote.run(messageId, userId);
wishScoreStmt.run(-1, messageId);
return false;
}
insertWishVote.run(messageId, userId);
wishScoreStmt.run(1, messageId);
return true;
}
export const myWishVotes = (userId) => myWishVotesStmt.all(userId).map((r) => r.message_id);
const insertGiveaway = db.prepare(`
INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at)
VALUES (@message_id, @channel_id, @prize, @winners, @ends_at)
+10
View File
@@ -161,6 +161,16 @@ export function levelRewards() {
.sort((a, b) => a.level - b.level);
}
/** Member-Gate: Web-Login nur für Discord-Member (Default: an, wenn Guild gesetzt) */
export function memberGateEnabled() {
return getSetting('member_gate_enabled') !== '0';
}
/** Discord-Invite-Link für die „Tritt erst dem Server bei"-Seite — leer = keiner */
export function discordInviteUrl() {
return getSetting('discord_invite_url') || null;
}
/** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */
export function votingChannelId() {
return getSetting('voting_channel_id') || null;
+178 -3
View File
@@ -13,6 +13,7 @@ import {
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
} from '../db.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
@@ -20,9 +21,10 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
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 } from '../runtime-settings.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl } from '../runtime-settings.js';
import { xpForLevel } from '../bot/levels.js';
import { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin } from './auth.js';
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
const PAGE_SIZE = 20;
@@ -110,7 +112,14 @@ export function registerApiRoutes(app, client) {
});
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich
app.get('/api/wishes', async () => ({ wishes: topWishes(20) }));
app.get('/api/wishes', async (request) => {
const user = getSessionUser(request);
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
return {
wishes: topWishes(20).map((w) => ({ ...w, voted: mine.has(w.message_id) })),
canVote: Boolean(user),
};
});
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
// Level-Bestenliste + Server-Aktivität — öffentlich
@@ -302,6 +311,8 @@ ${rssItems}
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') ?? '',
};
}
@@ -399,6 +410,16 @@ ${rssItems}
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');
}
@@ -1006,6 +1027,160 @@ ${rssItems}
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() }));
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 (postet wie /wunsch in den Voting-Kanal)
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 channelId = votingChannelId();
if (!channelId) return reply.code(400).send({ error: 'Feature-Voting ist nicht aktiviert' });
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return reply.code(500).send({ error: 'Voting-Kanal nicht gefunden' });
const embed = new EmbedBuilder()
.setColor(brandColor())
.setAuthor({ name: user.username, iconURL: user.avatar ?? undefined })
.setTitle('💡 Feature-Wunsch')
.setDescription(idea)
.setURL(`${publicUrl()}/roadmap`)
.setFooter({
text: `${brandFooter('VOTING')} • 👍 = will ich!`,
iconURL: client.user?.displayAvatarURL?.({ size: 64 }),
});
const message = await channel.send({ embeds: [embed] });
await message.react('👍').catch(() => {});
saveWish(message.id, user.username, idea);
return { ok: true };
});
// Web-Vote togglen (unabhängig von Discord-👍)
app.post('/api/wishes/:messageId/vote', async (request, reply) => {
if (await requireMember(request, reply)) return;
const user = getSessionUser(request);
const messageId = String(request.params.messageId);
if (!topWishes(1000).some((w) => w.message_id === messageId)) {
return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
}
return { voted: toggleWishVote(messageId, user.id) };
});
// --- Team-Verwaltung (nur Owner) ---
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs'];
+18 -2
View File
@@ -1,7 +1,7 @@
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
import crypto from 'node:crypto';
import { config } from '../config.js';
import { publicUrl } from '../runtime-settings.js';
import { publicUrl, discordGuildId, memberGateEnabled } from '../runtime-settings.js';
const DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'd4rkbot_session';
@@ -28,7 +28,18 @@ export function isAdmin(user) {
return user?.id === config.adminDiscordId;
}
export function registerAuthRoutes(app) {
/** Member-Gate: ist die User-ID Mitglied der konfigurierten Guild? (Owner immer) */
export async function isGuildMember(client, userId) {
if (userId === config.adminDiscordId) return true;
const guildId = discordGuildId();
if (!guildId) return true; // ohne Guild-Konfig kein Gate
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
if (!guild) return true; // Bot nicht auf der Guild → Gate nicht erzwingbar
const member = await guild.members.fetch(userId).catch(() => null);
return Boolean(member);
}
export function registerAuthRoutes(app, client) {
// Login: mit CSRF-State zu Discord weiterleiten
app.get('/auth/login', async (request, reply) => {
const state = crypto.randomBytes(16).toString('hex');
@@ -81,6 +92,11 @@ export function registerAuthRoutes(app) {
}
const me = await userRes.json();
// Member-Gate: nur Mitglieder des Discord-Servers bekommen eine Session
if (memberGateEnabled() && !(await isGuildMember(client, me.id))) {
return reply.clearCookie(STATE_COOKIE, { path: '/auth' }).redirect('/?gate=1');
}
// Nur das Nötigste in die Session — keine Tokens speichern
const session = {
id: me.id,
+1 -1
View File
@@ -63,7 +63,7 @@ export async function startWebServer(client) {
// Multipart für den Devlog-Endpoint (Bilder von devlog.py, max 10 MB pro Datei)
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } });
registerAuthRoutes(app);
registerAuthRoutes(app, client);
registerApiRoutes(app, client);
registerApiV1(app, client);
registerDevlogEndpoint(app, client);