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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user