feat(admin): einfacher Passwort-Login (ADMIN_PWD) per Cookie; Bulk-Delete & Rename Endpoints; Frontend: Loginfeld, Checkbox-Selektion, Toolbar mit Löschen/Umbenennen
This commit is contained in:
parent
129578cb3a
commit
5b26193bf3
3 changed files with 200 additions and 1 deletions
|
|
@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import express, { Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import crypto from 'node:crypto';
|
||||
import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message } from 'discord.js';
|
||||
import {
|
||||
joinVoiceChannel,
|
||||
|
|
@ -27,6 +28,7 @@ const __dirname = path.dirname(__filename);
|
|||
const PORT = Number(process.env.PORT ?? 8080);
|
||||
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
|
||||
const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? '';
|
||||
const ADMIN_PWD = process.env.ADMIN_PWD ?? '';
|
||||
const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
|
|
@ -218,6 +220,64 @@ app.get('/api/health', (_req: Request, res: Response) => {
|
|||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Admin Auth ---
|
||||
type AdminPayload = { iat: number; exp: number };
|
||||
function b64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
}
|
||||
function signAdminToken(payload: AdminPayload): string {
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const sig = crypto.createHmac('sha256', ADMIN_PWD || 'no-admin').update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
function verifyAdminToken(token: string | undefined): boolean {
|
||||
if (!token || !ADMIN_PWD) return false;
|
||||
const [body, sig] = token.split('.');
|
||||
if (!body || !sig) return false;
|
||||
const expected = crypto.createHmac('sha256', ADMIN_PWD).update(body).digest('base64url');
|
||||
if (expected !== sig) return false;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload;
|
||||
if (typeof payload.exp !== 'number') return false;
|
||||
return Date.now() < payload.exp;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function readCookie(req: Request, key: string): string | undefined {
|
||||
const c = req.headers.cookie;
|
||||
if (!c) return undefined;
|
||||
for (const part of c.split(';')) {
|
||||
const [k, v] = part.trim().split('=');
|
||||
if (k === key) return decodeURIComponent(v || '');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function requireAdmin(req: Request, res: Response, next: () => void) {
|
||||
if (!ADMIN_PWD) return res.status(503).json({ error: 'Admin nicht konfiguriert' });
|
||||
const token = readCookie(req, 'admin');
|
||||
if (!verifyAdminToken(token)) return res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||
next();
|
||||
}
|
||||
|
||||
app.post('/api/admin/login', (req: Request, res: Response) => {
|
||||
if (!ADMIN_PWD) return res.status(503).json({ error: 'Admin nicht konfiguriert' });
|
||||
const { password } = req.body as { password?: string };
|
||||
if (!password || password !== ADMIN_PWD) return res.status(401).json({ error: 'Falsches Passwort' });
|
||||
const token = signAdminToken({ iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 });
|
||||
res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/logout', (_req: Request, res: Response) => {
|
||||
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/status', (req: Request, res: Response) => {
|
||||
res.json({ authenticated: verifyAdminToken(readCookie(req, 'admin')) });
|
||||
});
|
||||
|
||||
app.get('/api/sounds', (req: Request, res: Response) => {
|
||||
const q = String(req.query.q ?? '').toLowerCase();
|
||||
const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__';
|
||||
|
|
@ -284,6 +344,46 @@ app.get('/api/sounds', (req: Request, res: Response) => {
|
|||
res.json({ items: withRecentFlag, total, folders: foldersOut });
|
||||
});
|
||||
|
||||
// --- Admin: Bulk-Delete ---
|
||||
app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response) => {
|
||||
const { paths } = req.body as { paths?: string[] };
|
||||
if (!Array.isArray(paths) || paths.length === 0) return res.status(400).json({ error: 'paths[] erforderlich' });
|
||||
const results: Array<{ path: string; ok: boolean; error?: string }> = [];
|
||||
for (const rel of paths) {
|
||||
const full = path.join(SOUNDS_DIR, rel);
|
||||
try {
|
||||
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||||
fs.unlinkSync(full);
|
||||
results.push({ path: rel, ok: true });
|
||||
} else {
|
||||
results.push({ path: rel, ok: false, error: 'nicht gefunden' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
results.push({ path: rel, ok: false, error: e?.message ?? 'Fehler' });
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, results });
|
||||
});
|
||||
|
||||
// --- Admin: Umbenennen einer Datei ---
|
||||
app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) => {
|
||||
const { from, to } = req.body as { from?: string; to?: string };
|
||||
if (!from || !to) return res.status(400).json({ error: 'from und to erforderlich' });
|
||||
const src = path.join(SOUNDS_DIR, from);
|
||||
// Ziel nur Name ändern, Endung mp3 sicherstellen
|
||||
const parsed = path.parse(from);
|
||||
const dstRel = path.join(parsed.dir || '', `${to.replace(/[^a-zA-Z0-9_.\-]/g, '_')}.mp3`);
|
||||
const dst = path.join(SOUNDS_DIR, dstRel);
|
||||
try {
|
||||
if (!fs.existsSync(src)) return res.status(404).json({ error: 'Quelle nicht gefunden' });
|
||||
if (fs.existsSync(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' });
|
||||
fs.renameSync(src, dst);
|
||||
res.json({ ok: true, from, to: dstRel });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e?.message ?? 'Rename fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/channels', (_req: Request, res: Response) => {
|
||||
if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume } from './api';
|
||||
import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename } from './api';
|
||||
import type { VoiceChannelInfo, Sound } from './types';
|
||||
import { getCookie, setCookie } from './cookies';
|
||||
|
||||
|
|
@ -16,6 +16,10 @@ export default function App() {
|
|||
const [volume, setVolume] = useState<number>(1);
|
||||
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
||||
const [theme, setTheme] = useState<string>(() => localStorage.getItem('theme') || 'dark');
|
||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
||||
const [adminPwd, setAdminPwd] = useState<string>('');
|
||||
const [selectedSet, setSelectedSet] = useState<Record<string, boolean>>({});
|
||||
const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
|
@ -31,6 +35,7 @@ export default function App() {
|
|||
} catch (e: any) {
|
||||
setError(e?.message || 'Fehler beim Laden der Channels');
|
||||
}
|
||||
try { setIsAdmin(await adminStatus()); } catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
|
@ -109,6 +114,9 @@ export default function App() {
|
|||
<h1>Discord Soundboard</h1>
|
||||
<p>Schicke dem Bot per privater Nachricht eine .mp3 — neue Sounds erscheinen automatisch.</p>
|
||||
<div className="badge">Geladene Sounds: {total}</div>
|
||||
{isAdmin && (
|
||||
<div className="badge">Admin-Modus</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section className="controls glass">
|
||||
|
|
@ -151,8 +159,53 @@ export default function App() {
|
|||
<option value="rainbow">Rainbow Chaos</option>
|
||||
</select>
|
||||
</div>
|
||||
{!isAdmin && (
|
||||
<div className="control" style={{ display: 'flex', gap: 8 }}>
|
||||
<input type="password" value={adminPwd} onChange={(e) => setAdminPwd(e.target.value)} placeholder="Admin Passwort" />
|
||||
<button type="button" className="tab" onClick={async () => {
|
||||
const ok = await adminLogin(adminPwd);
|
||||
if (ok) { setIsAdmin(true); setAdminPwd(''); }
|
||||
else alert('Login fehlgeschlagen');
|
||||
}}>Login</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Admin Toolbar */}
|
||||
{isAdmin && (
|
||||
<section className="controls glass" style={{ marginTop: -8 }}>
|
||||
<div className="control" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="button" className="tab" onClick={async () => {
|
||||
const toDelete = Object.entries(selectedSet).filter(([, v]) => v).map(([k]) => k);
|
||||
if (toDelete.length === 0) return;
|
||||
if (!confirm(`Wirklich ${toDelete.length} Datei(en) löschen?`)) return;
|
||||
try { await adminDelete(toDelete); } catch (e: any) { alert(e?.message || 'Löschen fehlgeschlagen'); }
|
||||
// refresh
|
||||
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
|
||||
const s = await fetchSounds(query, folderParam);
|
||||
setSounds(s.items);
|
||||
setTotal(s.total);
|
||||
setFolders(s.folders);
|
||||
setSelectedSet({});
|
||||
}}>🗑️ Löschen</button>
|
||||
{selectedCount === 1 && (
|
||||
<RenameInline onSubmit={async (newName) => {
|
||||
const from = Object.keys(selectedSet).find((k) => selectedSet[k]);
|
||||
if (!from) return;
|
||||
try { await adminRename(from, newName); } catch (e: any) { alert(e?.message || 'Umbenennen fehlgeschlagen'); return; }
|
||||
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
|
||||
const s = await fetchSounds(query, folderParam);
|
||||
setSounds(s.items);
|
||||
setTotal(s.total);
|
||||
setFolders(s.folders);
|
||||
setSelectedSet({});
|
||||
}} />
|
||||
)}
|
||||
<button type="button" className="tab" onClick={async () => { await adminLogout(); setIsAdmin(false); }}>Logout</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{folders.length > 0 && (
|
||||
<nav className="tabs glass">
|
||||
{/* Favoriten Tab */}
|
||||
|
|
@ -206,6 +259,14 @@ export default function App() {
|
|||
const isFav = !!favs[key];
|
||||
return (
|
||||
<div key={`${s.fileName}-${s.name}`} className="sound-wrap">
|
||||
{isAdmin && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedSet[key]}
|
||||
onChange={(e) => setSelectedSet((prev) => ({ ...prev, [key]: e.target.checked }))}
|
||||
style={{ position: 'absolute', left: 8, top: 8 }}
|
||||
/>
|
||||
)}
|
||||
<button className="sound" type="button" onClick={() => handlePlay(s.name, s.relativePath)} disabled={loading}>
|
||||
{s.isRecent ? '🆕 ' : ''}{s.name}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,44 @@ export async function getVolume(guildId: string): Promise<number> {
|
|||
return typeof data?.volume === 'number' ? data.volume : 1;
|
||||
}
|
||||
|
||||
// Admin
|
||||
export async function adminStatus(): Promise<boolean> {
|
||||
const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return !!data?.authenticated;
|
||||
}
|
||||
|
||||
export async function adminLogin(password: string): Promise<boolean> {
|
||||
const res = await fetch(`${API_BASE}/admin/login`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function adminLogout(): Promise<void> {
|
||||
await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' });
|
||||
}
|
||||
|
||||
export async function adminDelete(paths: string[]): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/sounds/delete`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ paths })
|
||||
});
|
||||
if (!res.ok) throw new Error('Löschen fehlgeschlagen');
|
||||
}
|
||||
|
||||
export async function adminRename(from: string, to: string): Promise<string> {
|
||||
const res = await fetch(`${API_BASE}/admin/sounds/rename`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ from, to })
|
||||
});
|
||||
if (!res.ok) throw new Error('Umbenennen fehlgeschlagen');
|
||||
const data = await res.json();
|
||||
return data?.to as string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue