diff --git a/server/src/plugins/game-library/index.ts b/server/src/plugins/game-library/index.ts index f48d0bd..461d787 100644 --- a/server/src/plugins/game-library/index.ts +++ b/server/src/plugins/game-library/index.ts @@ -1,6 +1,7 @@ import type express from 'express'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; +import { requireAdmin as requireAdminFactory } from '../../core/auth.js'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; @@ -58,34 +59,6 @@ const STEAM_API_KEY = process.env.STEAM_API_KEY || '2481D43449821AA4FF66502A9166 // ── Admin auth helpers (same system as soundboard) ── -function readCookie(req: express.Request, name: string): string | undefined { - const raw = req.headers.cookie || ''; - const match = raw.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); - return match ? decodeURIComponent(match[1]) : undefined; -} - -function b64url(str: string): string { - return Buffer.from(str).toString('base64url'); -} - -function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { - if (!token || !adminPwd) return false; - const [body, sig] = token.split('.'); - if (!body || !sig) return false; - const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); - if (expected !== sig) return false; - try { - const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as { iat: number; exp: number }; - return typeof payload.exp === 'number' && Date.now() < payload.exp; - } catch { return false; } -} - -function signAdminToken(adminPwd: string): string { - const payload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 }; - const body = b64url(JSON.stringify(payload)); - const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); - return `${body}.${sig}`; -} // ── Data Persistence ── @@ -893,37 +866,7 @@ const gameLibraryPlugin: Plugin = { // Admin endpoints (same auth as soundboard) // ═══════════════════════════════════════════════════════════════════ - const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { - if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } - if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { - res.status(401).json({ error: 'Nicht eingeloggt' }); - return; - } - next(); - }; - - // ── GET /api/game-library/admin/status ── - app.get('/api/game-library/admin/status', (req, res) => { - if (!ctx.adminPwd) { res.json({ admin: false, configured: false }); return; } - const valid = verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')); - res.json({ admin: valid, configured: true }); - }); - - // ── POST /api/game-library/admin/login ── - app.post('/api/game-library/admin/login', (req, res) => { - const password = String(req.body?.password || ''); - if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } - if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } - const token = signAdminToken(ctx.adminPwd); - res.setHeader('Set-Cookie', `admin=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`); - res.json({ ok: true }); - }); - - // ── POST /api/game-library/admin/logout ── - app.post('/api/game-library/admin/logout', (_req, res) => { - res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax'); - res.json({ ok: true }); - }); + const requireAdmin = requireAdminFactory(ctx.adminPwd); // ── GET /api/game-library/admin/profiles ── Alle Profile mit Details app.get('/api/game-library/admin/profiles', requireAdmin, (_req, res) => { diff --git a/server/src/plugins/notifications/index.ts b/server/src/plugins/notifications/index.ts index 98afae8..f04c8c6 100644 --- a/server/src/plugins/notifications/index.ts +++ b/server/src/plugins/notifications/index.ts @@ -1,8 +1,8 @@ import type express from 'express'; -import crypto from 'node:crypto'; import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { getState, setState } from '../../core/persistence.js'; +import { requireAdmin as requireAdminFactory } from '../../core/auth.js'; const NB = '[Notifications]'; @@ -26,40 +26,6 @@ let _client: Client | null = null; let _ctx: PluginContext | null = null; let _publicUrl = ''; -// ── Admin Auth (JWT-like with HMAC) ── - -type AdminPayload = { iat: number; exp: number }; - -function readCookie(req: express.Request, name: string): string | undefined { - const header = req.headers.cookie; - if (!header) return undefined; - const match = header.split(';').map(s => s.trim()).find(s => s.startsWith(`${name}=`)); - return match?.split('=').slice(1).join('='); -} - -function b64url(str: string): string { - return Buffer.from(str).toString('base64url'); -} - -function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { - if (!adminPwd || !token) return false; - const parts = token.split('.'); - if (parts.length !== 2) return false; - const [body, sig] = parts; - const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); - if (expected !== sig) return false; - try { - const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as AdminPayload; - return typeof payload.exp === 'number' && Date.now() < payload.exp; - } catch { return false; } -} - -function signAdminToken(adminPwd: string): string { - const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 }; - const body = b64url(JSON.stringify(payload)); - const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); - return `${body}.${sig}`; -} // ── Exported notification functions (called by other plugins) ── @@ -159,33 +125,7 @@ const notificationsPlugin: Plugin = { }, registerRoutes(app, ctx) { - const requireAdmin = (req: express.Request, res: express.Response, next: () => void): void => { - if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } - if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } - next(); - }; - - // Admin status - app.get('/api/notifications/admin/status', (req, res) => { - if (!ctx.adminPwd) { res.json({ admin: false }); return; } - res.json({ admin: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) }); - }); - - // Admin login - app.post('/api/notifications/admin/login', (req, res) => { - if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } - const { password } = req.body ?? {}; - if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } - const token = signAdminToken(ctx.adminPwd); - res.setHeader('Set-Cookie', `admin=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 86400}`); - res.json({ ok: true }); - }); - - // Admin logout - app.post('/api/notifications/admin/logout', (_req, res) => { - res.setHeader('Set-Cookie', 'admin=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); - res.json({ ok: true }); - }); + const requireAdmin = requireAdminFactory(ctx.adminPwd); // List available text channels (requires admin) app.get('/api/notifications/channels', requireAdmin, async (_req, res) => { diff --git a/web/src/plugins/game-library/GameLibraryTab.tsx b/web/src/plugins/game-library/GameLibraryTab.tsx index 2f30c4d..28fb090 100644 --- a/web/src/plugins/game-library/GameLibraryTab.tsx +++ b/web/src/plugins/game-library/GameLibraryTab.tsx @@ -89,7 +89,7 @@ function formatDate(iso: string): string { COMPONENT ══════════════════════════════════════════════════════════════════ */ -export default function GameLibraryTab({ data }: { data: any }) { +export default function GameLibraryTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) { // ── State ── const [profiles, setProfiles] = useState([]); const [mode, setMode] = useState<'overview' | 'user' | 'common'>('overview'); @@ -111,11 +111,9 @@ export default function GameLibraryTab({ data }: { data: any }) { // ── Admin state ── const [showAdmin, setShowAdmin] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); + const isAdmin = isAdminProp; const [adminProfiles, setAdminProfiles] = useState([]); const [adminLoading, setAdminLoading] = useState(false); - const [adminError, setAdminError] = useState(''); // ── SSE data sync ── useEffect(() => { @@ -133,42 +131,6 @@ export default function GameLibraryTab({ data }: { data: any }) { } catch { /* silent */ } }, []); - // ── Admin: check login status on mount ── - useEffect(() => { - fetch('/api/game-library/admin/status', { credentials: 'include' }) - .then(r => r.json()) - .then(d => setIsAdmin(d.admin === true)) - .catch(() => {}); - }, []); - - // ── Admin: login ── - const adminLogin = useCallback(async () => { - setAdminError(''); - try { - const resp = await fetch('/api/game-library/admin/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: adminPwd }), - credentials: 'include', - }); - if (resp.ok) { - setIsAdmin(true); - setAdminPwd(''); - } else { - const d = await resp.json(); - setAdminError(d.error || 'Fehler'); - } - } catch { - setAdminError('Verbindung fehlgeschlagen'); - } - }, [adminPwd]); - - // ── Admin: logout ── - const adminLogout = useCallback(async () => { - await fetch('/api/game-library/admin/logout', { method: 'POST', credentials: 'include' }); - setIsAdmin(false); - setShowAdmin(false); - }, []); // ── Admin: load profiles ── const loadAdminProfiles = useCallback(async () => { @@ -552,9 +514,11 @@ export default function GameLibraryTab({ data }: { data: any }) { )}
- + {isAdmin && ( + + )}
{/* ── Profile Chips ── */} @@ -990,29 +954,10 @@ export default function GameLibraryTab({ data }: { data: any }) { - {!isAdmin ? ( -
-

Admin-Passwort eingeben:

-
- setAdminPwd(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }} - autoFocus - /> - -
- {adminError &&

{adminError}

} -
- ) : (
✅ Eingeloggt als Admin -
{adminLoading ? ( @@ -1044,7 +989,6 @@ export default function GameLibraryTab({ data }: { data: any }) {
)} - )} )} diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index dbe0103..a1140b2 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -1000,13 +1000,15 @@ export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: So )} )} - + {isAdmin && ( + + )} diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index cd85e68..46506db 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -56,7 +56,7 @@ const QUALITY_PRESETS = [ // ── Component ── -export default function StreamingTab({ data }: { data: any }) { +export default function StreamingTab({ data, isAdmin: isAdminProp = false }: { data: any; isAdmin?: boolean }) { // ── State ── const [streams, setStreams] = useState([]); const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); @@ -75,9 +75,7 @@ export default function StreamingTab({ data }: { data: any }) { // ── Admin / Notification Config ── const [showAdmin, setShowAdmin] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [adminError, setAdminError] = useState(''); + const isAdmin = isAdminProp; const [availableChannels, setAvailableChannels] = useState>([]); const [notifyConfig, setNotifyConfig] = useState>([]); const [configLoading, setConfigLoading] = useState(false); @@ -138,12 +136,8 @@ export default function StreamingTab({ data }: { data: any }) { return () => document.removeEventListener('click', handler); }, [openMenu]); - // Check admin status on mount + // Load notification bot status on mount useEffect(() => { - fetch('/api/notifications/admin/status', { credentials: 'include' }) - .then(r => r.json()) - .then(d => setIsAdmin(d.admin === true)) - .catch(() => {}); fetch('/api/notifications/status') .then(r => r.json()) .then(d => setNotifyStatus(d)) @@ -610,34 +604,6 @@ export default function StreamingTab({ data }: { data: any }) { setOpenMenu(null); }, [buildStreamLink]); - // ── Admin functions ── - const adminLogin = useCallback(async () => { - setAdminError(''); - try { - const resp = await fetch('/api/notifications/admin/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: adminPwd }), - credentials: 'include', - }); - if (resp.ok) { - setIsAdmin(true); - setAdminPwd(''); - loadNotifyConfig(); - } else { - const d = await resp.json(); - setAdminError(d.error || 'Fehler'); - } - } catch { - setAdminError('Verbindung fehlgeschlagen'); - } - }, [adminPwd]); - - const adminLogout = useCallback(async () => { - await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' }); - setIsAdmin(false); - setShowAdmin(false); - }, []); const loadNotifyConfig = useCallback(async () => { setConfigLoading(true); @@ -796,9 +762,11 @@ export default function StreamingTab({ data }: { data: any }) { {starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'} )} - + {isAdmin && ( + + )} {streams.length === 0 && !isBroadcasting ? ( @@ -912,24 +880,6 @@ export default function StreamingTab({ data }: { data: any }) { - {!isAdmin ? ( -
-

Admin-Passwort eingeben:

-
- setAdminPwd(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }} - autoFocus - /> - -
- {adminError &&

{adminError}

} -
- ) : (
@@ -937,7 +887,6 @@ export default function StreamingTab({ data }: { data: any }) { ? <>{'\u2705'} Bot online: {notifyStatus.botTag} : <>{'\u26A0\uFE0F'} Bot offline — DISCORD_TOKEN_NOTIFICATIONS setzen} -
{configLoading ? ( @@ -993,7 +942,6 @@ export default function StreamingTab({ data }: { data: any }) { )}
- )} )}