diff --git a/README.md b/README.md index 27f00c7..c1afcb4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf | | 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite | +| 👤 **Member-Bereich** | Discord-Login für alle Member: `/profil` (Rang + XP-Balken, Rollen, Playtester-Badge, eigener Alpha-Key), Rollen-Selfservice im Browser (nutzt die Rollen-Menüs), Wünsche einreichen + upvoten auf der Roadmap; **Member-Gate**: Login nur für Server-Mitglieder (abweisbare bekommen den Invite-Link) | | 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) | ### Webinterface (`bot.d4rkst3r.de`) @@ -61,6 +62,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | `/events` | öffentlich | Discord-Events (Playtests, Streams …) | | `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips | | `/feed.xml` | öffentlich | RSS-Feed der Devlogs | +| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key | | `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) | | `/settings` | nur Admin | **Setup-Seite** — siehe unten | @@ -262,6 +264,7 @@ urllib.request.urlopen(req) | `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung | | `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs | | `POST /api/compose` · `GET/PUT/DELETE /api/templates` | Team (content) | Embed-Composer senden/bearbeiten · Vorlagen | +| `GET /api/profile` · `GET/POST /api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten | | `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite | | `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | | `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d1d625f..54bb1b8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import Level from './pages/Level.jsx'; import Events from './pages/Events.jsx'; import Commits from './pages/Commits.jsx'; import Settings from './pages/Settings.jsx'; +import Profil from './pages/Profil.jsx'; function DiscordMark() { return ( @@ -50,6 +51,9 @@ export default function App() { // Login-Status einmal beim Laden holen const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true }); const [scrolled, setScrolled] = useState(false); + // Member-Gate: OAuth war okay, aber User ist nicht auf dem Discord (?gate=1) + const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate')); + const [invite, setInvite] = useState(null); useEffect(() => { apiGet('/api/me') @@ -57,6 +61,10 @@ export default function App() { .catch(() => setMe({ user: null, admin: false, scopes: [], loading: false })); }, []); + useEffect(() => { + if (gated) apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {}); + }, [gated]); + useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 24); window.addEventListener('scroll', onScroll, { passive: true }); @@ -79,6 +87,7 @@ export default function App() { Level Events Changelog + {me.user && Profil} {me.admin && Commits} {(me.admin || me.scopes?.length > 0) && Setup} @@ -99,6 +108,20 @@ export default function App() {
+ {gated && ( +
+ + 🚪 Der Login ist Mitgliedern des Discord-Servers vorbehalten — + tritt erst dem Server bei und logg dich dann nochmal ein. + + {invite && ( + + Zum Discord + + )} + +
+ )} } /> } /> @@ -107,6 +130,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Profil.jsx b/frontend/src/pages/Profil.jsx new file mode 100644 index 0000000..42cf456 --- /dev/null +++ b/frontend/src/pages/Profil.jsx @@ -0,0 +1,164 @@ +// Member-Profil: Rang & XP, Discord-Rollen, Alpha-Key, Playtester-Status +// + Rollen-Selfservice (Rollen-Menüs direkt im Browser togglen). +import { useEffect, useState } from 'react'; +import { apiGet, apiPost } from '../api.js'; + +function fmtDate(iso) { + if (!iso) return '—'; + return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }) + .format(new Date(iso)); +} + +export default function Profil({ me }) { + const [profile, setProfile] = useState(null); + const [menus, setMenus] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(''); + const [copied, setCopied] = useState(false); + + useEffect(() => { + apiGet('/api/profile').then(setProfile).catch((e) => setError(e?.message ?? 'Fehler')); + apiGet('/api/myroles').then((d) => setMenus(d.menus)).catch(() => {}); + window.scrollTo(0, 0); + }, []); + + async function toggleRole(menuId, roleId) { + setBusy(`${menuId}:${roleId}`); + try { + const res = await apiPost('/api/myroles/toggle', { menu_id: menuId, role_id: roleId }); + setMenus((old) => old.map((m) => m.id !== menuId ? m : { + ...m, + entries: m.entries.map((e) => { + if (e.role_id === roleId) return { ...e, has: res.has }; + // Exklusiv: die anderen sind jetzt sicher weg + return m.exclusive && res.has ? { ...e, has: false } : e; + }), + })); + // Rollen-Chips oben mitziehen + apiGet('/api/profile').then(setProfile).catch(() => {}); + } catch { /* Server meldet schon */ } + setBusy(''); + } + + async function copyKey() { + try { + await navigator.clipboard.writeText(profile.alphaKey); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* Browser ohne Clipboard-Rechte */ } + } + + if (!me.loading && !me.user) { + return ( +
+
+
// Profil
+

Nicht eingeloggt

+

+ Mit Discord einloggen, um dein Profil zu sehen. +

+
+
+ ); + } + + const pct = profile?.level ? Math.floor((profile.level.progress / profile.level.needed) * 100) : 0; + + return ( + <> +
+
PROFIL
+
+ +
+ {error &&

⚠️ {error}

} + {!profile && !error &&

Lade …

} + + {profile && ( + <> + {/* Kopf-Karte */} +
+ {profile.user.avatar && } +
+

{profile.user.displayName}

+

+ Dabei seit {fmtDate(profile.user.joinedAt)} + {profile.playtester && 🧪 Playtester} +

+ {profile.roles.length > 0 && ( +
+ {profile.roles.map((r) => ( + + {r.name} + + ))} +
+ )} +
+
+ + {/* Level */} + {profile.level && ( +
+

+ // Level {profile.level.level} + {profile.level.rank ? ` — Platz #${profile.level.rank}` : ''} +

+
+
+
+

+ {profile.level.progress.toLocaleString('de-DE')} / {profile.level.needed.toLocaleString('de-DE')} XP + bis Level {profile.level.level + 1} ({pct}%) · gesamt {profile.level.xp.toLocaleString('de-DE')} XP +

+
+ )} + + {/* Alpha-Key */} + {profile.alphaKey && ( +
+

// Dein Alpha-Key

+
+ {profile.alphaKey} + +
+
+ )} + + {/* Rollen-Selfservice */} + {menus.length > 0 && ( +
+

// Rollen wählen

+ {menus.map((m) => ( +
+

{m.title}

+ {m.description &&

{m.description}

} + {m.exclusive &&

Nur eine Rolle aus diesem Menü gleichzeitig.

} +
+ {m.entries.map((e) => ( + + ))} +
+
+ ))} +
+ )} + + )} +
+ + ); +} diff --git a/frontend/src/pages/Roadmap.jsx b/frontend/src/pages/Roadmap.jsx index bc0507e..5f391a6 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { apiGet } from '../api.js'; +import { apiGet, apiPost } from '../api.js'; import { Markdown } from '../markdown.jsx'; const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }); @@ -83,16 +83,43 @@ function Heatmap({ days }) { export default function Roadmap() { const [data, setData] = useState(null); const [wishes, setWishes] = useState([]); + const [canVote, setCanVote] = useState(false); + const [idea, setIdea] = useState(''); + const [submitMsg, setSubmitMsg] = useState(''); const [heatmap, setHeatmap] = useState(null); const [error, setError] = useState(false); useEffect(() => { apiGet('/api/roadmap').then(setData).catch(() => setError(true)); - apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {}); + apiGet('/api/wishes').then((d) => { setWishes(d.wishes); setCanVote(d.canVote); }).catch(() => {}); apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {}); window.scrollTo(0, 0); }, []); + async function vote(messageId) { + try { + const res = await apiPost(`/api/wishes/${messageId}/vote`); + setWishes((old) => old.map((w) => w.message_id !== messageId + ? w + : { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) })); + } catch (e) { + if (e.status === 403) setSubmitMsg('⚠️ Voten dürfen nur Discord-Member.'); + } + } + + async function submitWish() { + const text = idea.trim(); + if (text.length < 5) { setSubmitMsg('⚠️ Etwas mehr Text bitte.'); return; } + try { + await apiPost('/api/wishes', { idea: text }); + setIdea(''); + setSubmitMsg('✅ Wunsch eingereicht — er steht jetzt auch im Discord zur Abstimmung!'); + apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {}); + } catch (e) { + setSubmitMsg(e.status === 403 ? '⚠️ Einreichen dürfen nur Discord-Member.' : '⚠️ Konnte den Wunsch nicht einreichen.'); + } + } + return ( <>
@@ -116,19 +143,42 @@ export default function Roadmap() { {heatmap && heatmap.length > 0 && } - {wishes.length > 0 && ( + {(wishes.length > 0 || canVote) && (

// Community-Wünsche

{wishes.map((w, i) => ( -
- 👍 {w.score} +
+ {canVote && w.message_id ? ( + + ) : ( + 👍 {w.score} + )} {w.idea} {w.author}
))} -

- Eigene Idee? Im Discord einfach /wunsch benutzen. -

+ {canVote ? ( +
+ setIdea(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }} + /> + +
+ ) : ( +

+ Eigene Idee? Im Discord /wunsch benutzen — oder hier einloggen und direkt abstimmen. +

+ )} + {submitMsg &&

{submitMsg}

}
)} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 5f70d23..c08ae94 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1199,6 +1199,21 @@ export default function Settings({ me }) {
+
+

// Web-Login (Member)

+
+ +
+
+ + setForm({ ...form, discord_invite_url: e.target.value })} /> +
+
+

// 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);