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
+3
View File
@@ -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 | | 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf |
| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite | | 📈 **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) | | 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
### Webinterface (`bot.d4rkst3r.de`) ### 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 …) | | `/events` | öffentlich | Discord-Events (Playtests, Streams …) |
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips | | `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs | | `/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) | | `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
| `/settings` | nur Admin | **Setup-Seite** — siehe unten | | `/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/DELETE /api/apikeys` | Admin | API-Key-Verwaltung |
| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs | | `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 | | `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/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 /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 | | `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
+24
View File
@@ -9,6 +9,7 @@ import Level from './pages/Level.jsx';
import Events from './pages/Events.jsx'; import Events from './pages/Events.jsx';
import Commits from './pages/Commits.jsx'; import Commits from './pages/Commits.jsx';
import Settings from './pages/Settings.jsx'; import Settings from './pages/Settings.jsx';
import Profil from './pages/Profil.jsx';
function DiscordMark() { function DiscordMark() {
return ( return (
@@ -50,6 +51,9 @@ export default function App() {
// Login-Status einmal beim Laden holen // Login-Status einmal beim Laden holen
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true }); const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
const [scrolled, setScrolled] = useState(false); 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(() => { useEffect(() => {
apiGet('/api/me') apiGet('/api/me')
@@ -57,6 +61,10 @@ export default function App() {
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false })); .catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
}, []); }, []);
useEffect(() => {
if (gated) apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
}, [gated]);
useEffect(() => { useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24); const onScroll = () => setScrolled(window.scrollY > 24);
window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('scroll', onScroll, { passive: true });
@@ -79,6 +87,7 @@ export default function App() {
<NavLink to="/level">Level</NavLink> <NavLink to="/level">Level</NavLink>
<NavLink to="/events">Events</NavLink> <NavLink to="/events">Events</NavLink>
<NavLink to="/changelog">Changelog</NavLink> <NavLink to="/changelog">Changelog</NavLink>
{me.user && <NavLink to="/profil">Profil</NavLink>}
{me.admin && <NavLink to="/commits">Commits</NavLink>} {me.admin && <NavLink to="/commits">Commits</NavLink>}
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>} {(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
</nav> </nav>
@@ -99,6 +108,20 @@ export default function App() {
</header> </header>
<main className="main"> <main className="main">
{gated && (
<div className="gate-banner">
<span>
🚪 Der Login ist Mitgliedern des Discord-Servers vorbehalten
tritt erst dem Server bei und logg dich dann nochmal ein.
</span>
{invite && (
<a className="btn btn-save" href={invite} target="_blank" rel="noreferrer">
Zum Discord
</a>
)}
<button className="card-delete" onClick={() => setGated(false)} title="Ausblenden"></button>
</div>
)}
<Routes> <Routes>
<Route path="/" element={<Navigate to="/devlogs" replace />} /> <Route path="/" element={<Navigate to="/devlogs" replace />} />
<Route path="/devlogs" element={<Devlogs me={me} />} /> <Route path="/devlogs" element={<Devlogs me={me} />} />
@@ -107,6 +130,7 @@ export default function App() {
<Route path="/level" element={<Level />} /> <Route path="/level" element={<Level />} />
<Route path="/events" element={<Events />} /> <Route path="/events" element={<Events />} />
<Route path="/changelog" element={<Changelog />} /> <Route path="/changelog" element={<Changelog />} />
<Route path="/profil" element={<Profil me={me} />} />
<Route path="/commits" element={<Commits me={me} />} /> <Route path="/commits" element={<Commits me={me} />} />
<Route path="/settings" element={<Settings me={me} />} /> <Route path="/settings" element={<Settings me={me} />} />
<Route path="*" element={<Navigate to="/devlogs" replace />} /> <Route path="*" element={<Navigate to="/devlogs" replace />} />
+164
View File
@@ -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 (
<section className="page-header">
<div className="page-header-content">
<div className="page-tag">// Profil</div>
<h1 className="page-title">Nicht eingeloggt</h1>
<p className="page-subtitle">
<a href="/auth/login" style={{ textDecoration: 'underline' }}>Mit Discord einloggen</a>, um dein Profil zu sehen.
</p>
</div>
</section>
);
}
const pct = profile?.level ? Math.floor((profile.level.progress / profile.level.needed) * 100) : 0;
return (
<>
<section className="page-header">
<div className="page-bg-text">PROFIL</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Member</div>
<h1 className="page-title">Profil</h1>
<p className="page-subtitle">Dein Stand auf dem Server Rang, Rollen und Keys.</p>
</div>
</section>
<section className="content">
{error && <p className="notice"> {error}</p>}
{!profile && !error && <p className="notice">Lade </p>}
{profile && (
<>
{/* Kopf-Karte */}
<div className="profile-card">
{profile.user.avatar && <img className="profile-avatar" src={profile.user.avatar} alt="" />}
<div className="profile-head">
<h2 className="profile-name">{profile.user.displayName}</h2>
<p className="profile-meta">
Dabei seit {fmtDate(profile.user.joinedAt)}
{profile.playtester && <span className="profile-badge">🧪 Playtester</span>}
</p>
{profile.roles.length > 0 && (
<div className="profile-roles">
{profile.roles.map((r) => (
<span key={r.id} className="role-chip" style={r.color ? { borderColor: r.color, color: r.color } : undefined}>
{r.name}
</span>
))}
</div>
)}
</div>
</div>
{/* Level */}
{profile.level && (
<div className="roadmap-group">
<h2 className="settings-title">
// Level {profile.level.level}
{profile.level.rank ? ` — Platz #${profile.level.rank}` : ''}
</h2>
<div className="xp-bar">
<div className="xp-fill" style={{ width: `${Math.min(100, pct)}%` }} />
</div>
<p className="field-hint" style={{ marginTop: '.4rem' }}>
{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
</p>
</div>
)}
{/* Alpha-Key */}
{profile.alphaKey && (
<div className="roadmap-group">
<h2 className="settings-title">// Dein Alpha-Key</h2>
<div className="field-row">
<code className="alpha-key">{profile.alphaKey}</code>
<button className="btn btn-mini" onClick={copyKey}>{copied ? '✓ Kopiert' : 'Kopieren'}</button>
</div>
</div>
)}
{/* Rollen-Selfservice */}
{menus.length > 0 && (
<div className="roadmap-group">
<h2 className="settings-title">// Rollen wählen</h2>
{menus.map((m) => (
<div className="rolemenu-box" key={m.id}>
<h3 className="rolemenu-title">{m.title}</h3>
{m.description && <p className="field-hint">{m.description}</p>}
{m.exclusive && <p className="field-hint">Nur eine Rolle aus diesem Menü gleichzeitig.</p>}
<div className="rolemenu-buttons">
{m.entries.map((e) => (
<button
key={e.role_id}
className={`btn rolemenu-btn${e.has ? ' active' : ''}`}
disabled={busy === `${m.id}:${e.role_id}`}
onClick={() => toggleRole(m.id, e.role_id)}
>
{e.emoji ? `${e.emoji} ` : ''}{e.label}{e.has ? ' ✓' : ''}
</button>
))}
</div>
</div>
))}
</div>
)}
</>
)}
</section>
</>
);
}
+58 -8
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { apiGet } from '../api.js'; import { apiGet, apiPost } from '../api.js';
import { Markdown } from '../markdown.jsx'; import { Markdown } from '../markdown.jsx';
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }); const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
@@ -83,16 +83,43 @@ function Heatmap({ days }) {
export default function Roadmap() { export default function Roadmap() {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [wishes, setWishes] = useState([]); const [wishes, setWishes] = useState([]);
const [canVote, setCanVote] = useState(false);
const [idea, setIdea] = useState('');
const [submitMsg, setSubmitMsg] = useState('');
const [heatmap, setHeatmap] = useState(null); const [heatmap, setHeatmap] = useState(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
useEffect(() => { useEffect(() => {
apiGet('/api/roadmap').then(setData).catch(() => setError(true)); 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(() => {}); apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {});
window.scrollTo(0, 0); 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 ( return (
<> <>
<section className="page-header"> <section className="page-header">
@@ -116,19 +143,42 @@ export default function Roadmap() {
{heatmap && heatmap.length > 0 && <Heatmap days={heatmap} />} {heatmap && heatmap.length > 0 && <Heatmap days={heatmap} />}
{wishes.length > 0 && ( {(wishes.length > 0 || canVote) && (
<div className="roadmap-group"> <div className="roadmap-group">
<h2 className="settings-title">// Community-Wünsche</h2> <h2 className="settings-title">// Community-Wünsche</h2>
{wishes.map((w, i) => ( {wishes.map((w, i) => (
<div className="wish-row" key={`${w.created_at}-${i}`}> <div className="wish-row" key={w.message_id ?? `${w.created_at}-${i}`}>
<span className="wish-score">👍 {w.score}</span> {canVote && w.message_id ? (
<button
className={`wish-vote${w.voted ? ' voted' : ''}`}
title={w.voted ? 'Vote zurücknehmen' : 'Dafür stimmen'}
onClick={() => vote(w.message_id)}
>
👍 {w.score}
</button>
) : (
<span className="wish-score">👍 {w.score}</span>
)}
<span className="wish-idea">{w.idea}</span> <span className="wish-idea">{w.idea}</span>
<span className="wish-author">{w.author}</span> <span className="wish-author">{w.author}</span>
</div> </div>
))} ))}
<p className="notice" style={{ padding: '.6rem 0 0' }}> {canVote ? (
Eigene Idee? Im Discord einfach <code>/wunsch</code> benutzen. <div className="wish-form">
</p> <input
type="text" maxLength={500} placeholder="Deine Idee für den Server …"
value={idea}
onChange={(e) => setIdea(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }}
/>
<button className="btn btn-save" onClick={submitWish}>Einreichen</button>
</div>
) : (
<p className="notice" style={{ padding: '.6rem 0 0' }}>
Eigene Idee? Im Discord <code>/wunsch</code> benutzen oder hier einloggen und direkt abstimmen.
</p>
)}
{submitMsg && <p className="notice" style={{ padding: '.6rem 0 0' }}>{submitMsg}</p>}
</div> </div>
)} )}
+15
View File
@@ -1199,6 +1199,21 @@ export default function Settings({ me }) {
</div> </div>
<div className="settings-section">
<h2 className="settings-title">// Web-Login (Member)</h2>
<div className="field">
<label className="toggle">
<input type="checkbox" checked={form.member_gate_enabled} onChange={(e) => setForm({ ...form, member_gate_enabled: e.target.checked })} />
<span>Login nur für Discord-Member</span>
<span className="field-hint">Nicht-Member werden nach dem Discord-Login abgewiesen (braucht gesetzte Guild-ID)</span>
</label>
</div>
<div className="field">
<label>Discord-Invite-Link <span className="field-hint">wird Abgewiesenen als Zum Discord"-Button angezeigt</span></label>
<input type="text" placeholder="https://discord.gg/…" value={form.discord_invite_url} onChange={(e) => setForm({ ...form, discord_invite_url: e.target.value })} />
</div>
</div>
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Events, Social &amp; Voice</h2> <h2 className="settings-title">// Events, Social &amp; Voice</h2>
<div className="field"> <div className="field">
+52
View File
@@ -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-item:hover, .gamesel-item.active { background: var(--bg3); color: var(--neon); }
.gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; } .gamesel-id { color: var(--muted2); font-family: var(--mono); font-size: .75rem; }
.gamesel-item:hover .gamesel-id { color: var(--muted); } .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; }
}
+28 -1
View File
@@ -142,9 +142,11 @@ const deletePlaytester = db.prepare('DELETE FROM playtesters WHERE user_id = ?')
const listPlaytestersStmt = db.prepare( const listPlaytestersStmt = db.prepare(
'SELECT user_id, username, applied_at FROM playtesters ORDER BY applied_at' '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 addPlaytester = (userId, username) => insertPlaytester.run(userId, username).changes > 0;
export const removePlaytester = (userId) => deletePlaytester.run(userId).changes > 0; export const removePlaytester = (userId) => deletePlaytester.run(userId).changes > 0;
export const listPlaytesters = () => listPlaytestersStmt.all(); 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 hasStarboardStmt = db.prepare('SELECT 1 FROM starboard WHERE message_id = ?');
const insertStarboard = db.prepare( 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 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 hasWishStmt = db.prepare('SELECT 1 FROM wishes WHERE message_id = ?');
const topWishesStmt = db.prepare( 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 saveWish = (messageId, author, idea) => insertWish.run(messageId, author, idea);
export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId)); export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId));
export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId); export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId);
export const topWishes = (limit = 20) => topWishesStmt.all(limit); 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(` const insertGiveaway = db.prepare(`
INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at) INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at)
VALUES (@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); .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 */ /** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */
export function votingChannelId() { export function votingChannelId() {
return getSetting('voting_channel_id') || null; return getSetting('voting_channel_id') || null;
+178 -3
View File
@@ -13,6 +13,7 @@ import {
createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver, createGameserver, updateGameserver, listGameservers, getGameserver, deleteGameserver,
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin, saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate, saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
} from '../db.js'; } from '../db.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.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 { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.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 { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin } from './auth.js'; import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -110,7 +112,14 @@ export function registerApiRoutes(app, client) {
}); });
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich // 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() })); app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
// Level-Bestenliste + Server-Aktivität — öffentlich // Level-Bestenliste + Server-Aktivität — öffentlich
@@ -302,6 +311,8 @@ ${rssItems}
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')), twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '', tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
server_alert_channel_id: getSetting('server_alert_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) { if (body.sticky_roles_enabled !== undefined) {
setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0'); 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) { if (body.levels_enabled !== undefined) {
setSetting('levels_enabled', body.levels_enabled ? '1' : '0'); setSetting('levels_enabled', body.levels_enabled ? '1' : '0');
} }
@@ -1006,6 +1027,160 @@ ${rssItems}
return { deleted: deleteTemplate(String(request.params.name)), templates: listTemplates() }; 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) --- // --- Team-Verwaltung (nur Owner) ---
const TEAM_SCOPES = ['content', 'community', 'rollen', 'bewerbungen', 'server', 'settings', 'devlogs']; 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 // Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { config } from '../config.js'; 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 DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'd4rkbot_session'; const SESSION_COOKIE = 'd4rkbot_session';
@@ -28,7 +28,18 @@ export function isAdmin(user) {
return user?.id === config.adminDiscordId; 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 // Login: mit CSRF-State zu Discord weiterleiten
app.get('/auth/login', async (request, reply) => { app.get('/auth/login', async (request, reply) => {
const state = crypto.randomBytes(16).toString('hex'); const state = crypto.randomBytes(16).toString('hex');
@@ -81,6 +92,11 @@ export function registerAuthRoutes(app) {
} }
const me = await userRes.json(); 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 // Nur das Nötigste in die Session — keine Tokens speichern
const session = { const session = {
id: me.id, 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) // 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 } }); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 8 } });
registerAuthRoutes(app); registerAuthRoutes(app, client);
registerApiRoutes(app, client); registerApiRoutes(app, client);
registerApiV1(app, client); registerApiV1(app, client);
registerDevlogEndpoint(app, client); registerDevlogEndpoint(app, client);