diff --git a/auth.js b/auth.js index a73f15d..968fa42 100644 --- a/auth.js +++ b/auth.js @@ -2,7 +2,7 @@ import { Router } from 'express'; import jwt from 'jsonwebtoken'; import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js'; import { authenticateToken, requireRole } from '../middleware/auth.js'; -import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getUserRole } from '../services/discord.js'; +import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getGuildMemberships, getUserRole, getUserRoleFromMemberships } from '../services/discord.js'; const router = Router(); @@ -38,15 +38,18 @@ router.get('/discord/callback', async (req, res) => { // Get Discord user info const discordUser = await getDiscordUser(tokenData.access_token); - // Check if user is in the guild - const member = await getGuildMember(discordUser.id); + // Check if user is in any of the configured guilds + const memberships = await getGuildMemberships(discordUser.id); - if (!member) { + if (!memberships) { return res.redirect(`${frontendUrl}/login?error=not_in_guild`); } - // Determine role based on Discord roles - const role = getUserRole(member.roles); + // Determine role based on Discord roles (highest role from all servers) + const role = getUserRoleFromMemberships(memberships); + + // Use first membership for display name + const member = memberships[0].member; // Get display name (nickname or username) const displayName = member.nick || discordUser.global_name || discordUser.username; @@ -126,13 +129,13 @@ router.post('/refresh-role', authenticateToken, async (req, res) => { } try { - const member = await getGuildMember(req.user.discordId); + const memberships = await getGuildMemberships(req.user.discordId); - if (!member) { - return res.status(403).json({ error: 'No longer in guild' }); + if (!memberships) { + return res.status(403).json({ error: 'No longer in any guild' }); } - const newRole = getUserRole(member.roles); + const newRole = getUserRoleFromMemberships(memberships); db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?') .run(newRole, req.user.discordId); diff --git a/discord.js b/discord.js index 2e8d859..5e2e8a4 100644 --- a/discord.js +++ b/discord.js @@ -1,6 +1,29 @@ // Discord OAuth2 Service const DISCORD_API = 'https://discord.com/api/v10'; +// Lazy initialization - wird erst bei Verwendung geladen (nach dotenv) +let _guildConfigs = null; + +function getGuildConfigs() { + if (_guildConfigs === null) { + _guildConfigs = [ + { + name: 'Bacanaks', + guildId: process.env.DISCORD_GUILD_ID_1, + adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_1, + modRoleId: process.env.DISCORD_MOD_ROLE_ID_1 + }, + { + name: 'Piccadilly', + guildId: process.env.DISCORD_GUILD_ID_2, + adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_2, + modRoleId: process.env.DISCORD_MOD_ROLE_ID_2 + } + ].filter(config => config.guildId); + } + return _guildConfigs; +} + export function getDiscordAuthUrl() { const params = new URLSearchParams({ client_id: process.env.DISCORD_CLIENT_ID, @@ -48,9 +71,10 @@ export async function getDiscordUser(accessToken) { return response.json(); } -export async function getGuildMember(userId) { +// Prüft einen einzelnen Server +async function fetchGuildMember(guildId, userId) { const response = await fetch( - `${DISCORD_API}/guilds/${process.env.DISCORD_GUILD_ID}/members/${userId}`, + `${DISCORD_API}/guilds/${guildId}/members/${userId}`, { headers: { Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}` @@ -60,17 +84,78 @@ export async function getGuildMember(userId) { if (!response.ok) { if (response.status === 404) { - return null; // User not in guild + return null; } - throw new Error('Failed to get guild member'); + throw new Error(`Failed to get guild member from ${guildId}`); } return response.json(); } +// Prüft alle konfigurierten Server und gibt Memberships zurück +export async function getGuildMemberships(userId) { + const configs = getGuildConfigs(); + const memberships = []; + + for (const config of configs) { + try { + const member = await fetchGuildMember(config.guildId, userId); + if (member) { + memberships.push({ + config, + member, + roles: member.roles || [] + }); + } + } catch (err) { + console.error(`[Discord] Failed to check membership for guild ${config.name}:`, err.message); + } + } + + return memberships.length > 0 ? memberships : null; +} + +// Legacy-Funktion für Kompatibilität +export async function getGuildMember(userId) { + const memberships = await getGuildMemberships(userId); + if (!memberships || memberships.length === 0) { + return null; + } + return memberships[0].member; +} + +// Rollen-Priorität: superadmin > moderator > user +const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 }; + +// Bestimmt die höchste Rolle aus allen Server-Memberships +export function getUserRoleFromMemberships(memberships) { + if (!memberships || memberships.length === 0) { + return 'user'; + } + + let highestRole = 'user'; + + for (const { config, roles } of memberships) { + let role = 'user'; + + if (roles.includes(config.adminRoleId)) { + role = 'superadmin'; + } else if (roles.includes(config.modRoleId)) { + role = 'moderator'; + } + + if (ROLE_PRIORITY[role] > ROLE_PRIORITY[highestRole]) { + highestRole = role; + } + } + + return highestRole; +} + +// Legacy-Funktion für Kompatibilität export function getUserRole(memberRoles) { - const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID; - const modRoleId = process.env.DISCORD_MOD_ROLE_ID; + const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID || process.env.DISCORD_ADMIN_ROLE_ID_1; + const modRoleId = process.env.DISCORD_MOD_ROLE_ID || process.env.DISCORD_MOD_ROLE_ID_1; if (memberRoles.includes(adminRoleId)) { return 'superadmin'; diff --git a/docs/discord-bot.md b/docs/discord-bot.md index 8939d90..a0542ce 100644 --- a/docs/discord-bot.md +++ b/docs/discord-bot.md @@ -49,13 +49,15 @@ Wenn der Bot einem Server beitritt, erstellt er automatisch folgende Struktur: | Channel | @everyone | Bot | |---------|-----------|-----| -| Kategorie | Lesen | Schreiben | -| info | Lesen | Schreiben | -| status | Lesen | Schreiben | -| alerts | Lesen | Schreiben | -| updates | Lesen | Schreiben | -| diskussion | Lesen + Schreiben | Schreiben | -| requests | Lesen + Threads erstellen | Schreiben | +| Kategorie | Lesen | ViewChannel + Schreiben | +| info | Lesen (kein Schreiben) | ViewChannel + Schreiben | +| status | Lesen (kein Schreiben) | ViewChannel + Schreiben | +| alerts | Lesen (kein Schreiben) | ViewChannel + Schreiben | +| updates | Lesen (kein Schreiben) | ViewChannel + Schreiben | +| diskussion | Lesen + Schreiben | ViewChannel + Schreiben | +| requests | Lesen + Threads erstellen | ViewChannel + Schreiben | + +**Wichtig**: Der Bot braucht explizit `ViewChannel` Permission für jeden Channel, auch wenn @everyone den Channel sehen kann. Ohne `ViewChannel` kann der Bot nicht in den Channel schreiben ("Missing Access" Fehler). ## Datenbank @@ -80,6 +82,8 @@ CREATE TABLE guild_settings ( ); ``` +Die Datenbank liegt in `backend/db/users.sqlite`. + ### DB-Funktionen In `backend/db/init.js`: @@ -137,12 +141,43 @@ Jeder Server bekommt ein eigenes Embed mit: DISCORD_CLIENT_ID=1458251194806833306 DISCORD_CLIENT_SECRET=xxx DISCORD_BOT_TOKEN=xxx -DISCORD_GUILD_ID=729865854329815051 # Haupt-Server für Login -DISCORD_ADMIN_ROLE_ID=1024693717434650736 -DISCORD_MOD_ROLE_ID=1024693170958766141 + +# Multi-Server OAuth Login +# User muss nur in EINEM der Server Mitglied sein +# Rollen werden pro Server geprüft, höchste Berechtigung zählt + +# Server 1: Bacanaks +DISCORD_GUILD_ID_1=729865854329815051 +DISCORD_ADMIN_ROLE_ID_1=1024693717434650736 +DISCORD_MOD_ROLE_ID_1=1024693170958766141 + +# Server 2: Piccadilly +DISCORD_GUILD_ID_2=730907665802330224 +DISCORD_ADMIN_ROLE_ID_2=1458595551514988584 +DISCORD_MOD_ROLE_ID_2=1458591909210488914 ``` -**Hinweis**: `DISCORD_GUILD_ID` wird nur für den Discord OAuth Login verwendet, nicht für den Bot selbst. +**Hinweis**: Die Guild-IDs werden nur für den Discord OAuth Login verwendet, nicht für den Bot selbst. + +### OAuth Login-Logik + +``` +1. User loggt sich via Discord OAuth ein +2. Für jeden konfigurierten Server: + - Ist User Mitglied? → Rollen prüfen + - Admin-Rolle → superadmin + - Mod-Rolle → moderator + - Nur Mitglied → user +3. Höchste Berechtigung aus allen Servern wird verwendet +4. Nicht in mindestens einem Server → Login verweigert +``` + +| User ist in... | Bacanaks Rolle | Piccadilly Rolle | GSM Rolle | +|----------------|----------------|------------------|-----------| +| Nur Bacanaks | Admin | - | superadmin | +| Nur Piccadilly | - | Mod | moderator | +| Beide | Mitglied | Admin | superadmin | +| Keinem | - | - | ❌ Kein Login | ### Developer Portal Einstellungen @@ -158,12 +193,84 @@ DISCORD_MOD_ROLE_ID=1024693170958766141 | Datei | Beschreibung | |-------|--------------| +| `backend/services/discord.js` | OAuth-Logik, Multi-Guild Membership-Prüfung | | `backend/services/discordBot.js` | Bot-Logik und Event-Handler | +| `backend/routes/auth.js` | Auth-Endpoints (Login, Callback, Refresh) | | `backend/db/init.js` | Guild-Settings DB-Funktionen | | `frontend/src/pages/Dashboard.jsx` | Invite-Button im Dashboard | +## Implementierungsdetails + +### Multi-Guild OAuth + +Die OAuth-Implementierung in `discord.js` verwendet **lazy initialization** für die Guild-Konfigurationen: + +```javascript +let _guildConfigs = null; + +function getGuildConfigs() { + if (_guildConfigs === null) { + _guildConfigs = [ + { name: 'Bacanaks', guildId: process.env.DISCORD_GUILD_ID_1, ... }, + { name: 'Piccadilly', guildId: process.env.DISCORD_GUILD_ID_2, ... } + ].filter(config => config.guildId); + } + return _guildConfigs; +} +``` + +**Wichtig**: Die Konfiguration darf NICHT beim Modul-Import initialisiert werden, da zu diesem Zeitpunkt dotenv die `.env` noch nicht geladen hat. Die lazy initialization stellt sicher, dass die Umgebungsvariablen verfügbar sind. + +### Funktionen + +| Funktion | Beschreibung | +|----------|--------------| +| `getGuildMemberships(userId)` | Prüft alle konfigurierten Server, gibt Array von Memberships zurück | +| `getUserRoleFromMemberships(memberships)` | Bestimmt höchste Rolle aus allen Memberships | +| `getGuildMember(userId)` | Legacy-Funktion, gibt ersten Match zurück | +| `getUserRole(memberRoles)` | Legacy-Funktion für einzelne Rollen-Liste | + +### Rollen-Priorität + +```javascript +const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 }; +``` + +Bei mehreren Memberships wird immer die höchste Rolle verwendet. + +### Voraussetzungen + +- Der Bot muss auf **allen** konfigurierten Discord-Servern sein +- Der Bot braucht Zugriff auf die Guild Members API + ## Troubleshooting +### Login schlägt fehl mit "nicht Mitglied" + +1. **Bot auf allen Servern?** Der Bot muss auf Bacanaks UND Piccadilly eingeladen sein +2. **Env-Variablen prüfen**: + ```bash + grep GUILD /opt/gameserver-monitor/backend/.env + ``` +3. **Mit --update-env neustarten**: + ```bash + pm2 restart gameserver-backend --update-env + ``` +4. **Logs prüfen**: + ```bash + pm2 logs gameserver-backend --lines 30 | grep -i discord + ``` + +### Rolle wird nicht erkannt + +1. **Rollen-IDs prüfen** - Discord Developer Mode aktivieren, Rechtsklick auf Rolle → ID kopieren +2. **User-Rollen abfragen**: + ```bash + curl -s -H "Authorization: Bot BOT_TOKEN" \ + "https://discord.com/api/v10/guilds/GUILD_ID/members/USER_ID" | jq '.roles' + ``` +3. **Konfigurierte IDs vergleichen** mit den tatsächlichen Rollen des Users + ### Bot erstellt keine Channels - Prüfen ob Bot "Manage Channels" Permission hat @@ -180,18 +287,52 @@ Suche nach `[DiscordBot]` Log-Einträgen. ### Bot aus Datenbank entfernen ```bash -sqlite3 /opt/gameserver-monitor/backend/users.sqlite +cd /opt/gameserver-monitor/backend +node -e " +import Database from 'better-sqlite3'; +const db = new Database('./db/users.sqlite'); +db.prepare('DELETE FROM guild_settings WHERE guild_id = ?').run('GUILD_ID_HIER'); +console.log('Deleted'); +" +``` + +Alternativ mit sqlite3 (falls installiert): +```bash +sqlite3 /opt/gameserver-monitor/backend/db/users.sqlite DELETE FROM guild_settings WHERE guild_id = 'xxx'; ``` +### Missing Access Fehler + +Wenn der Bot "Missing Access" meldet obwohl er eingeladen wurde: + +1. **ViewChannel Permission prüfen**: Der Bot braucht explizit `ViewChannel` für jeden Channel +2. Im Discord: Rechtsklick auf Channel → Bearbeiten → Berechtigungen → Bot auswählen → "Kanal ansehen" aktivieren +3. Logs prüfen: `pm2 logs gameserver-backend --lines 50 | grep -i "missing\|access"` + +### Status-Nachricht wurde gelöscht + +Wenn die Status-Nachricht manuell gelöscht wurde, erscheint "Unknown Message" in den Logs. Fix: + +```bash +cd /opt/gameserver-monitor/backend +node -e " +import Database from 'better-sqlite3'; +const db = new Database('./db/users.sqlite'); +// Status Message ID auf NULL setzen, Bot erstellt neue beim nächsten Update +db.prepare('UPDATE guild_settings SET status_message_id = NULL WHERE guild_id = ?').run('GUILD_ID_HIER'); +console.log('Reset status_message_id'); +" +``` + ## Login vs. Bot | Feature | Login (OAuth) | Bot | |---------|--------------|-----| -| Erfordert Mitgliedschaft | Haupt-Discord | Nein | +| Erfordert Mitgliedschaft | Bacanaks oder Piccadilly | Nein | | Server-Steuerung | Ja (je nach Rolle) | Nein | | Status sehen | Ja | Ja | | Alerts erhalten | Nein | Ja | -| Verfügbar für | Haupt-Discord Mitglieder | Alle mit Bot | +| Verfügbar für | Mitglieder beider Discord-Server | Alle mit Bot | -Der Login zur Webapp erfordert Mitgliedschaft im Haupt-Discord-Server (DISCORD_GUILD_ID). Der Bot ist davon unabhängig und zeigt nur passive Status-Updates. +Der Login zur Webapp erfordert Mitgliedschaft in mindestens einem der konfigurierten Discord-Server (Bacanaks oder Piccadilly). Die höchste Rolle aus beiden Servern bestimmt die GSM-Berechtigung. Der Bot ist davon unabhängig und zeigt nur passive Status-Updates.