// Events, Social & Voice
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 1e62351..b625c14 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1113,3 +1113,55 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.gamesel-item:hover, .gamesel-item.active { background: var(--bg3); color: var(--neon); }
.gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; }
.gamesel-item:hover .gamesel-id { color: var(--muted); }
+
+/* ── Member-Bereich: Gate-Banner, Profil, Web-Voting ── */
+.gate-banner {
+ display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
+ margin: 1.2rem auto 0; max-width: 900px; padding: .8rem 1.1rem;
+ background: rgba(255, 77, 0, 0.08); border: 1px solid rgba(255, 77, 0, 0.4);
+ border-radius: 10px; font-family: var(--body);
+}
+.gate-banner span { flex: 1; min-width: 240px; }
+
+.profile-card {
+ display: flex; gap: 1.4rem; align-items: flex-start;
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
+ padding: 1.4rem; margin-bottom: 2rem;
+}
+.profile-avatar { width: 84px; height: 84px; border-radius: 50%; border: 2px solid var(--neon); }
+.profile-name { font-family: var(--display); font-size: 2rem; letter-spacing: .05em; margin: 0; }
+.profile-meta { color: var(--muted); margin: .2rem 0 0; font-family: var(--body); }
+.profile-badge {
+ margin-left: .8rem; padding: .1rem .55rem; border-radius: 999px;
+ border: 1px solid var(--success); color: var(--success); font-size: .85rem;
+}
+.profile-roles { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .8rem; }
+.role-chip {
+ padding: .15rem .6rem; border-radius: 999px; font-size: .85rem;
+ border: 1px solid var(--border); color: var(--text); font-family: var(--body);
+}
+.xp-bar { height: 12px; border-radius: 999px; background: var(--bg3); border: 1px solid var(--border); overflow: hidden; }
+.xp-fill { height: 100%; background: linear-gradient(90deg, var(--neon), var(--neon2)); }
+.alpha-key {
+ font-family: var(--mono); background: var(--bg3); border: 1px solid var(--border);
+ border-radius: 8px; padding: .5rem .8rem; letter-spacing: .08em;
+}
+.rolemenu-box { margin-bottom: 1.4rem; }
+.rolemenu-title { font-family: var(--body); font-weight: 600; font-size: 1.1rem; margin: 0 0 .2rem; }
+.rolemenu-buttons { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
+.rolemenu-btn.active { border-color: var(--neon); color: var(--neon); }
+
+.wish-vote {
+ background: var(--bg3); border: 1px solid var(--border); border-radius: 999px;
+ color: var(--muted); font-family: var(--mono); padding: .25rem .7rem; cursor: pointer;
+ white-space: nowrap; transition: border-color .15s, color .15s;
+}
+.wish-vote:hover { border-color: var(--neon); color: var(--text); }
+.wish-vote.voted { border-color: var(--neon); color: var(--neon); }
+.wish-form { display: flex; gap: .6rem; margin-top: 1rem; }
+.wish-form input { flex: 1; }
+@media (max-width: 640px) {
+ .profile-card { flex-direction: column; align-items: center; text-align: center; }
+ .profile-roles { justify-content: center; }
+ .wish-form { flex-direction: column; }
+}
diff --git a/src/db.js b/src/db.js
index f3ce04e..c5abbfb 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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)
diff --git a/src/runtime-settings.js b/src/runtime-settings.js
index 1f7133a..fab44ae 100644
--- a/src/runtime-settings.js
+++ b/src/runtime-settings.js
@@ -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;
diff --git a/src/web/api.js b/src/web/api.js
index 3d19441..03ca930 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -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'];
diff --git a/src/web/auth.js b/src/web/auth.js
index d6f8280..9ffcc95 100644
--- a/src/web/auth.js
+++ b/src/web/auth.js
@@ -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,
diff --git a/src/web/server.js b/src/web/server.js
index f27837c..3916b56 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -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);