From e4ecf2e3e9e734d6bcbd7f126d74bce385efa5d4 Mon Sep 17 00:00:00 2001 From: Jeremy Kirsch Date: Sun, 17 May 2026 21:05:19 +0200 Subject: [PATCH] . --- .env.example | 34 + DEPLOY.md | 233 ++++ db.js | 21 + middleware/auth.js | 13 + middleware/passport.js | 56 + package.json | 24 + public/index.html | 2412 ++++++++++++++++++++++++++++++++++++++++ routes/DEPLOY.md | 233 ++++ routes/admin.js | 152 +++ routes/api.js | 58 + routes/auth.js | 42 + schema.sql | 99 ++ server.js | 90 ++ 13 files changed, 3467 insertions(+) create mode 100644 .env.example create mode 100644 DEPLOY.md create mode 100644 db.js create mode 100644 middleware/auth.js create mode 100644 middleware/passport.js create mode 100644 package.json create mode 100644 public/index.html create mode 100644 routes/DEPLOY.md create mode 100644 routes/admin.js create mode 100644 routes/api.js create mode 100644 routes/auth.js create mode 100644 schema.sql create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..75056eb --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# ═══════════════════════════════════════ +# Blair Dashboard — Environment Config +# Kopiere diese Datei zu .env und fülle sie aus +# ═══════════════════════════════════════ + +# Server +PORT=3000 +NODE_ENV=production + +# Session Secret — beliebiger langer zufälliger String +# Generieren mit: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +SESSION_SECRET=AENDERN_ZU_LANGEM_ZUFALLS_STRING + +# MySQL / MariaDB +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=blair_dashboard +DB_USER=blair_user +DB_PASS=SICHERES_PASSWORT + +# Discord OAuth App +# Erstellen unter: https://discord.com/developers/applications +DISCORD_CLIENT_ID=DEINE_CLIENT_ID +DISCORD_CLIENT_SECRET=DEIN_CLIENT_SECRET + +# Die URL deines Servers (ohne trailing slash) +# Lokal: http://localhost:3000 +# Produktiv: https://blair.deinedomain.de +APP_URL=https://blair.deinedomain.de + +# Deine Discord User ID (wird automatisch als erster Admin eingetragen) +# Discord → Einstellungen → Erweitert → Entwicklermodus → Rechtsklick auf dich → ID kopieren +OWNER_DISCORD_ID=DEINE_DISCORD_ID +OWNER_DISCORD_NAME=DeinDiscordName diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..0f69758 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,233 @@ +# Blair Dashboard — Deployment Guide +## Node.js + MySQL auf eigenem Linux VPS + +--- + +## 1. Voraussetzungen auf dem Server installieren + +```bash +# Node.js 20 LTS +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# MySQL / MariaDB (falls noch nicht vorhanden) +sudo apt install mariadb-server -y +sudo mysql_secure_installation + +# PM2 (Prozessmanager — hält den Server am Laufen) +sudo npm install -g pm2 + +# Nginx (Reverse Proxy) +sudo apt install nginx -y +``` + +--- + +## 2. Projekt auf den Server übertragen + +```bash +# Option A: Per SCP (von deinem PC aus) +scp -r blair-selfhosted/ user@dein-server.de:/var/www/blair + +# Option B: Git (empfohlen für Updates) +# Auf dem Server: +cd /var/www +git clone https://github.com/dein-repo/blair-dashboard blair +``` + +--- + +## 3. Datenbank einrichten + +```bash +sudo mysql -u root -p + +# Im MySQL-Prompt: +CREATE DATABASE blair_dashboard CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'blair_user'@'localhost' IDENTIFIED BY 'SICHERES_PASSWORT_HIER'; +GRANT ALL PRIVILEGES ON blair_dashboard.* TO 'blair_user'@'localhost'; +FLUSH PRIVILEGES; +EXIT; + +# Schema importieren +mysql -u blair_user -p blair_dashboard < /var/www/blair/schema.sql +``` + +--- + +## 4. Discord App erstellen + +1. Gehe zu https://discord.com/developers/applications +2. "New Application" → Name: "Blair Dashboard" +3. Links → **OAuth2** → Redirects → Add Redirect: + ``` + https://deinedomain.de/auth/discord/callback + ``` +4. Kopiere **Client ID** 1505635813072044062 und **Client Secret** fXQyM6oXGQWR23m3QbilHLTJiObg_kP- + +--- + +## 5. .env Datei erstellen + +```bash +cd /var/www/blair +cp .env.example .env +nano .env +``` + +Ausfüllen: +```env +PORT=3000 +NODE_ENV=production +SESSION_SECRET= +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=blair_dashboard +DB_USER=blair_user +DB_PASS= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +APP_URL=https://deinedomain.de +OWNER_DISCORD_ID= +OWNER_DISCORD_NAME= +``` + +Session Secret generieren: +```bash +node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +``` + +--- + +## 6. Dependencies installieren & starten + +```bash +cd /var/www/blair +npm install + +# Testlauf (Fehler prüfen) +node server.js + +# Wenn alles läuft: mit PM2 als Dienst starten +pm2 start server.js --name blair +pm2 save +pm2 startup # zeigt Befehl an, den du dann als root ausführst +``` + +PM2 Befehle: +```bash +pm2 status # Status aller Apps +pm2 logs blair # Live-Logs +pm2 restart blair # Neustart nach Änderungen +pm2 stop blair # Stoppen +``` + +--- + +## 7. Nginx als Reverse Proxy einrichten + +```bash +sudo nano /etc/nginx/sites-available/blair +``` + +Inhalt: +```nginx +server { + listen 80; + server_name deinedomain.de www.deinedomain.de; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/blair /etc/nginx/sites-enabled/ +sudo nginx -t # Konfiguration prüfen +sudo systemctl reload nginx +``` + +--- + +## 8. HTTPS mit Let's Encrypt (kostenlos) + +```bash +sudo apt install certbot python3-certbot-nginx -y +sudo certbot --nginx -d deinedomain.de -d www.deinedomain.de + +# Automatische Erneuerung testen +sudo certbot renew --dry-run +``` + +--- + +## 9. Discord OAuth Redirect URL updaten + +Wenn du jetzt HTTPS hast, gehe nochmal zu: +https://discord.com/developers/applications → deine App → OAuth2 → Redirects + +Und aktualisiere zu: +``` +https://deinedomain.de/auth/discord/callback +``` + +--- + +## Fertig! 🎃 + +Öffne `https://deinedomain.de` im Browser. + +- **Als Admin anmelden:** Discord-Login → deine `OWNER_DISCORD_ID` wird automatisch als erster Admin eingetragen +- **Weitere Admins hinzufügen:** Im Admin-Panel → Whitelist-Sektion → Discord ID eingeben + +--- + +## Struktur + +``` +blair-selfhosted/ +├── server.js ← Express-Server (Einstiegspunkt) +├── db.js ← MySQL Connection Pool +├── schema.sql ← Datenbankschema + Standardgeister +├── package.json +├── .env.example ← Kopieren zu .env, ausfüllen +├── middleware/ +│ ├── passport.js ← Discord OAuth + Admin-Check +│ └── auth.js ← requireAuth / requireAdmin +├── routes/ +│ ├── auth.js ← /auth/discord, /auth/me, /auth/logout +│ ├── api.js ← /api/ghosts, /api/submissions (public) +│ └── admin.js ← /admin/* (nur Whitelist-Admins) +└── public/ + └── index.html ← Das komplette Frontend (SPA) +``` + +## Sicherheitsarchitektur + +``` +Browser + │ + ├─ GET /auth/me → Sucht Session → gibt User + isAdmin zurück + ├─ GET /auth/discord → Startet Discord OAuth + ├─ GET /auth/discord/callback → Discord bestätigt → Session anlegen + │ + ├─ GET /api/ghosts → Öffentlich, kein Auth nötig + ├─ POST /api/submissions → Nur eingeloggte User (requireAuth) + │ + └─ /admin/* → requireAdmin-Middleware prüft: + 1. Ist User eingeloggt? (Session) + 2. Ist Discord-ID in admin_whitelist? (DB-Abfrage) + → NEIN → 403 Forbidden + → JA → Route ausführen +``` + +**Admin-Prüfung passiert bei JEDEM Request live in der DB** — wenn du jemanden aus der Whitelist entfernst, verliert er sofort den Zugriff (nicht erst beim nächsten Login). diff --git a/db.js b/db.js new file mode 100644 index 0000000..33f3533 --- /dev/null +++ b/db.js @@ -0,0 +1,21 @@ +const mysql = require('mysql2/promise'); + +const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT) || 3306, + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASS, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + charset: 'utf8mb4', +}); + +// Hilfsfunktion für einfachere Abfragen +pool.query = async (sql, params) => { + const [rows] = await pool.execute(sql, params || []); + return rows; +}; + +module.exports = pool; diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..ff2c810 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,13 @@ +// Nur eingeloggte User +function requireAuth(req, res, next) { + if (req.isAuthenticated()) return next(); + res.status(401).json({ error: 'Nicht eingeloggt' }); +} + +// Nur Admins (Whitelist) +function requireAdmin(req, res, next) { + if (req.isAuthenticated() && req.user?.isAdmin) return next(); + res.status(403).json({ error: 'Kein Admin-Zugriff' }); +} + +module.exports = { requireAuth, requireAdmin }; diff --git a/middleware/passport.js b/middleware/passport.js new file mode 100644 index 0000000..a4fd41e --- /dev/null +++ b/middleware/passport.js @@ -0,0 +1,56 @@ +const DiscordStrategy = require('passport-discord').Strategy; +const db = require('../db'); + +module.exports = (passport) => { + passport.use(new DiscordStrategy({ + clientID: process.env.DISCORD_CLIENT_ID, + clientSecret: process.env.DISCORD_CLIENT_SECRET, + callbackURL: `${process.env.APP_URL}/auth/discord/callback`, + scope: ['identify'], + }, + async (accessToken, refreshToken, profile, done) => { + try { + const avatar = profile.avatar + ? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png` + : null; + + // User in DB upserten — speichert username + avatar für deserializeUser + await db.query( + `INSERT INTO users (discord_id, username, avatar) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE username=VALUES(username), avatar=VALUES(avatar)`, + [profile.id, profile.username, avatar] + ); + + const isAdmin = (await db.query( + 'SELECT discord_id FROM admin_whitelist WHERE discord_id = ?', + [profile.id] + )).length > 0; + + return done(null, { id: profile.id, username: profile.username, avatar, isAdmin }); + } catch (err) { + return done(err, null); + } + })); + + // Nur die Discord-ID in der Session — der Cookie bleibt winzig + passport.serializeUser((user, done) => done(null, user.id)); + + // Bei jedem Request alles frisch aus der DB laden + passport.deserializeUser(async (id, done) => { + try { + const users = await db.query( + 'SELECT discord_id, username, avatar FROM users WHERE discord_id = ?', [id] + ); + if (!users.length) return done(null, false); + + const isAdmin = (await db.query( + 'SELECT discord_id FROM admin_whitelist WHERE discord_id = ?', [id] + )).length > 0; + + done(null, { id, username: users[0].username, avatar: users[0].avatar, isAdmin }); + } catch (e) { + done(e, null); + } + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3d83a0 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "blair-dashboard", + "version": "1.0.0", + "description": "Blair Roblox Ghost Dashboard — Self-Hosted", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "express-session": "^1.17.3", + "mysql2": "^3.6.0", + "passport": "^0.6.0", + "passport-discord": "^0.1.4", + "dotenv": "^16.3.1", + "express-mysql-session": "^3.0.0", + "helmet": "^7.1.0", + "cors": "^2.8.5" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..29cb0f4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,2412 @@ + + + + + + + Blair — Ghost Dashboard + + + + + + + + + + + +
+
+
Lade...
+
+ + + + + + + + + + +
+
+
Sicher?
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/routes/DEPLOY.md b/routes/DEPLOY.md new file mode 100644 index 0000000..0f69758 --- /dev/null +++ b/routes/DEPLOY.md @@ -0,0 +1,233 @@ +# Blair Dashboard — Deployment Guide +## Node.js + MySQL auf eigenem Linux VPS + +--- + +## 1. Voraussetzungen auf dem Server installieren + +```bash +# Node.js 20 LTS +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# MySQL / MariaDB (falls noch nicht vorhanden) +sudo apt install mariadb-server -y +sudo mysql_secure_installation + +# PM2 (Prozessmanager — hält den Server am Laufen) +sudo npm install -g pm2 + +# Nginx (Reverse Proxy) +sudo apt install nginx -y +``` + +--- + +## 2. Projekt auf den Server übertragen + +```bash +# Option A: Per SCP (von deinem PC aus) +scp -r blair-selfhosted/ user@dein-server.de:/var/www/blair + +# Option B: Git (empfohlen für Updates) +# Auf dem Server: +cd /var/www +git clone https://github.com/dein-repo/blair-dashboard blair +``` + +--- + +## 3. Datenbank einrichten + +```bash +sudo mysql -u root -p + +# Im MySQL-Prompt: +CREATE DATABASE blair_dashboard CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'blair_user'@'localhost' IDENTIFIED BY 'SICHERES_PASSWORT_HIER'; +GRANT ALL PRIVILEGES ON blair_dashboard.* TO 'blair_user'@'localhost'; +FLUSH PRIVILEGES; +EXIT; + +# Schema importieren +mysql -u blair_user -p blair_dashboard < /var/www/blair/schema.sql +``` + +--- + +## 4. Discord App erstellen + +1. Gehe zu https://discord.com/developers/applications +2. "New Application" → Name: "Blair Dashboard" +3. Links → **OAuth2** → Redirects → Add Redirect: + ``` + https://deinedomain.de/auth/discord/callback + ``` +4. Kopiere **Client ID** 1505635813072044062 und **Client Secret** fXQyM6oXGQWR23m3QbilHLTJiObg_kP- + +--- + +## 5. .env Datei erstellen + +```bash +cd /var/www/blair +cp .env.example .env +nano .env +``` + +Ausfüllen: +```env +PORT=3000 +NODE_ENV=production +SESSION_SECRET= +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=blair_dashboard +DB_USER=blair_user +DB_PASS= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +APP_URL=https://deinedomain.de +OWNER_DISCORD_ID= +OWNER_DISCORD_NAME= +``` + +Session Secret generieren: +```bash +node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +``` + +--- + +## 6. Dependencies installieren & starten + +```bash +cd /var/www/blair +npm install + +# Testlauf (Fehler prüfen) +node server.js + +# Wenn alles läuft: mit PM2 als Dienst starten +pm2 start server.js --name blair +pm2 save +pm2 startup # zeigt Befehl an, den du dann als root ausführst +``` + +PM2 Befehle: +```bash +pm2 status # Status aller Apps +pm2 logs blair # Live-Logs +pm2 restart blair # Neustart nach Änderungen +pm2 stop blair # Stoppen +``` + +--- + +## 7. Nginx als Reverse Proxy einrichten + +```bash +sudo nano /etc/nginx/sites-available/blair +``` + +Inhalt: +```nginx +server { + listen 80; + server_name deinedomain.de www.deinedomain.de; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/blair /etc/nginx/sites-enabled/ +sudo nginx -t # Konfiguration prüfen +sudo systemctl reload nginx +``` + +--- + +## 8. HTTPS mit Let's Encrypt (kostenlos) + +```bash +sudo apt install certbot python3-certbot-nginx -y +sudo certbot --nginx -d deinedomain.de -d www.deinedomain.de + +# Automatische Erneuerung testen +sudo certbot renew --dry-run +``` + +--- + +## 9. Discord OAuth Redirect URL updaten + +Wenn du jetzt HTTPS hast, gehe nochmal zu: +https://discord.com/developers/applications → deine App → OAuth2 → Redirects + +Und aktualisiere zu: +``` +https://deinedomain.de/auth/discord/callback +``` + +--- + +## Fertig! 🎃 + +Öffne `https://deinedomain.de` im Browser. + +- **Als Admin anmelden:** Discord-Login → deine `OWNER_DISCORD_ID` wird automatisch als erster Admin eingetragen +- **Weitere Admins hinzufügen:** Im Admin-Panel → Whitelist-Sektion → Discord ID eingeben + +--- + +## Struktur + +``` +blair-selfhosted/ +├── server.js ← Express-Server (Einstiegspunkt) +├── db.js ← MySQL Connection Pool +├── schema.sql ← Datenbankschema + Standardgeister +├── package.json +├── .env.example ← Kopieren zu .env, ausfüllen +├── middleware/ +│ ├── passport.js ← Discord OAuth + Admin-Check +│ └── auth.js ← requireAuth / requireAdmin +├── routes/ +│ ├── auth.js ← /auth/discord, /auth/me, /auth/logout +│ ├── api.js ← /api/ghosts, /api/submissions (public) +│ └── admin.js ← /admin/* (nur Whitelist-Admins) +└── public/ + └── index.html ← Das komplette Frontend (SPA) +``` + +## Sicherheitsarchitektur + +``` +Browser + │ + ├─ GET /auth/me → Sucht Session → gibt User + isAdmin zurück + ├─ GET /auth/discord → Startet Discord OAuth + ├─ GET /auth/discord/callback → Discord bestätigt → Session anlegen + │ + ├─ GET /api/ghosts → Öffentlich, kein Auth nötig + ├─ POST /api/submissions → Nur eingeloggte User (requireAuth) + │ + └─ /admin/* → requireAdmin-Middleware prüft: + 1. Ist User eingeloggt? (Session) + 2. Ist Discord-ID in admin_whitelist? (DB-Abfrage) + → NEIN → 403 Forbidden + → JA → Route ausführen +``` + +**Admin-Prüfung passiert bei JEDEM Request live in der DB** — wenn du jemanden aus der Whitelist entfernst, verliert er sofort den Zugriff (nicht erst beim nächsten Login). diff --git a/routes/admin.js b/routes/admin.js new file mode 100644 index 0000000..0c63a0a --- /dev/null +++ b/routes/admin.js @@ -0,0 +1,152 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const { requireAdmin } = require('../middleware/auth'); + +// Alle Admin-Routen erfordern Admin-Zugriff +router.use(requireAdmin); + +// ── Stats ───────────────────────────────────────────── + +router.get('/stats', async (req, res) => { + try { + const [[{ ghosts }]] = await db.execute('SELECT COUNT(*) as ghosts FROM ghosts'); + const [[{ total }]] = await db.execute('SELECT COUNT(*) as total FROM submissions'); + const [[{ pending }]] = await db.execute("SELECT COUNT(*) as pending FROM submissions WHERE status='pending'"); + const [[{ approved }]]= await db.execute("SELECT COUNT(*) as approved FROM submissions WHERE status='approved'"); + const [[{ rejected }]]= await db.execute("SELECT COUNT(*) as rejected FROM submissions WHERE status='rejected'"); + res.json({ ghosts, total, pending, approved, rejected }); + } catch (e) { + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// ── Ghosts CRUD ─────────────────────────────────────── + +// Geist erstellen +router.post('/ghosts', async (req, res) => { + const { id, name, evidence, sanity, hunt, sanity_special, tip, description, tells } = req.body; + if (!id || !name || !Array.isArray(evidence) || evidence.length !== 3) { + return res.status(400).json({ error: 'Ungültige Daten (id, name, genau 3 evidence erforderlich)' }); + } + try { + await db.query( + `INSERT INTO ghosts (id, name, evidence, sanity, hunt, sanity_special, tip, description, tells, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, name, JSON.stringify(evidence), sanity||50, hunt||'mid', + sanity_special||'', tip||'', description||'', JSON.stringify(tells||[]), + req.user.username] + ); + res.json({ ok: true }); + } catch (e) { + if (e.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'ID bereits vorhanden' }); + console.error(e); + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// Geist bearbeiten +router.put('/ghosts/:id', async (req, res) => { + const { name, evidence, sanity, hunt, sanity_special, tip, description, tells } = req.body; + if (!name || !Array.isArray(evidence) || evidence.length !== 3) { + return res.status(400).json({ error: 'Ungültige Daten' }); + } + try { + const result = await db.query( + `UPDATE ghosts SET name=?, evidence=?, sanity=?, hunt=?, sanity_special=?, + tip=?, description=?, tells=?, updated_by=? WHERE id=?`, + [name, JSON.stringify(evidence), sanity||50, hunt||'mid', + sanity_special||'', tip||'', description||'', JSON.stringify(tells||[]), + req.user.username, req.params.id] + ); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Geist nicht gefunden' }); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// Geist löschen +router.delete('/ghosts/:id', async (req, res) => { + try { + const result = await db.query('DELETE FROM ghosts WHERE id=?', [req.params.id]); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Geist nicht gefunden' }); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// ── Submissions ─────────────────────────────────────── + +// Alle Submissions laden (mit Filter) +router.get('/submissions', async (req, res) => { + const { status } = req.query; + try { + const rows = status + ? await db.query('SELECT * FROM submissions WHERE status=? ORDER BY created_at DESC', [status]) + : await db.query('SELECT * FROM submissions ORDER BY created_at DESC'); + res.json(rows); + } catch (e) { + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// Submission reviewen (approve/reject) +router.patch('/submissions/:id', async (req, res) => { + const { status, admin_note } = req.body; + if (!['approved','rejected'].includes(status)) return res.status(400).json({ error: 'Status muss approved oder rejected sein' }); + try { + await db.query( + `UPDATE submissions SET status=?, admin_note=?, reviewed_at=NOW(), reviewed_by=? WHERE id=?`, + [status, admin_note||'', req.user.username, req.params.id] + ); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// ── Admin Whitelist ─────────────────────────────────── + +// Whitelist laden +router.get('/whitelist', async (req, res) => { + try { + const rows = await db.query('SELECT * FROM admin_whitelist ORDER BY added_at ASC'); + res.json(rows); + } catch (e) { + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// Admin hinzufügen +router.post('/whitelist', async (req, res) => { + const { discord_id, discord_username } = req.body; + if (!discord_id || discord_id.length < 10) return res.status(400).json({ error: 'Ungültige Discord ID' }); + try { + await db.query( + 'INSERT INTO admin_whitelist (discord_id, discord_username, added_by) VALUES (?,?,?)', + [discord_id.trim(), discord_username||discord_id, req.user.username] + ); + res.json({ ok: true }); + } catch (e) { + if (e.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Bereits in der Whitelist' }); + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// Admin entfernen (kann sich nicht selbst entfernen) +router.delete('/whitelist/:discord_id', async (req, res) => { + if (req.params.discord_id === req.user.id) { + return res.status(400).json({ error: 'Du kannst dich nicht selbst aus der Whitelist entfernen' }); + } + try { + await db.query('DELETE FROM admin_whitelist WHERE discord_id=?', [req.params.discord_id]); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +module.exports = router; diff --git a/routes/api.js b/routes/api.js new file mode 100644 index 0000000..5326e58 --- /dev/null +++ b/routes/api.js @@ -0,0 +1,58 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const { requireAuth } = require('../middleware/auth'); + +// ── Ghosts (public read) ────────────────────────────── + +// Alle Geister laden +router.get('/ghosts', async (req, res) => { + try { + const rows = await db.query('SELECT * FROM ghosts ORDER BY name ASC'); + const ghosts = rows.map(row => ({ + ...row, + evidence: typeof row.evidence === 'string' ? JSON.parse(row.evidence) : row.evidence, + tells: typeof row.tells === 'string' ? JSON.parse(row.tells) : row.tells, + })); + res.json(ghosts); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// ── Submissions (auth required) ─────────────────────── + +// Eigene Submissions laden +router.get('/submissions/mine', requireAuth, async (req, res) => { + try { + const rows = await db.query( + 'SELECT * FROM submissions WHERE discord_id = ? ORDER BY created_at DESC', + [req.user.id] + ); + res.json(rows); + } catch (e) { + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +// Neue Submission einreichen +router.post('/submissions', requireAuth, async (req, res) => { + const { ghost_name, type, content } = req.body; + if (!content?.trim()) return res.status(400).json({ error: 'Inhalt fehlt' }); + if (!['tip','edit','new'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' }); + + try { + await db.query( + `INSERT INTO submissions (ghost_name, type, username, discord_id, content) + VALUES (?, ?, ?, ?, ?)`, + [ghost_name || '', type, req.user.username, req.user.id, content.trim()] + ); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'DB-Fehler' }); + } +}); + +module.exports = router; diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..769365d --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,42 @@ +const express = require('express'); +const passport = require('passport'); +const router = express.Router(); + +// Discord OAuth starten +router.get('/discord', + passport.authenticate('discord') +); + +// Discord OAuth Callback +router.get('/discord/callback', + passport.authenticate('discord', { + failureRedirect: '/?error=login_failed', + }), + (req, res) => { + res.redirect('/'); + } +); + +// Abmelden +router.post('/logout', (req, res) => { + req.logout(() => { + res.json({ ok: true }); + }); +}); + +// Aktuellen User zurückgeben (für Frontend) +router.get('/me', (req, res) => { + if (!req.isAuthenticated()) { + return res.json({ user: null }); + } + res.json({ + user: { + id: req.user.id, + username: req.user.username, + avatar: req.user.avatar, + isAdmin: req.user.isAdmin, + } + }); +}); + +module.exports = router; diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..ee6d8a1 --- /dev/null +++ b/schema.sql @@ -0,0 +1,99 @@ +-- ═══════════════════════════════════════════════════════ +-- Blair Dashboard — MySQL Schema +-- Ausführen mit: mysql -u root -p < schema.sql +-- ODER: Im phpMyAdmin / DBeaver importieren +-- ═══════════════════════════════════════════════════════ + +CREATE DATABASE IF NOT EXISTS blair_dashboard + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE blair_dashboard; + +-- Dedizierter DB-User (sicherer als root) +-- Passe Passwort an und führe das als root aus: +-- CREATE USER 'blair_user'@'localhost' IDENTIFIED BY 'SICHERES_PASSWORT'; +-- GRANT ALL PRIVILEGES ON blair_dashboard.* TO 'blair_user'@'localhost'; +-- FLUSH PRIVILEGES; + +-- ─── Sessions ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS sessions ( + session_id VARCHAR(128) NOT NULL PRIMARY KEY, + expires INT(11) UNSIGNED NOT NULL, + data MEDIUMTEXT, + INDEX idx_expires (expires) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + +-- ─── Users (Discord Login Cache) ──────────────────────── +CREATE TABLE IF NOT EXISTS users ( + discord_id VARCHAR(30) NOT NULL PRIMARY KEY, + username VARCHAR(100) NOT NULL DEFAULT '', + avatar VARCHAR(255) NOT NULL DEFAULT '', + last_login DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ─── Admin Whitelist ──────────────────────────────────── +CREATE TABLE IF NOT EXISTS admin_whitelist ( + discord_id VARCHAR(30) NOT NULL PRIMARY KEY, + discord_username VARCHAR(100) NOT NULL DEFAULT '', + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + added_by VARCHAR(100) NOT NULL DEFAULT 'system' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ─── Ghosts ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ghosts ( + id VARCHAR(80) NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + evidence JSON NOT NULL DEFAULT '[]', + sanity TINYINT NOT NULL DEFAULT 50, + hunt VARCHAR(20) NOT NULL DEFAULT 'mid', + sanity_special VARCHAR(255) NOT NULL DEFAULT '', + tip TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + tells JSON NOT NULL DEFAULT '[]', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + updated_by VARCHAR(100) NOT NULL DEFAULT 'system', + INDEX idx_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ─── Submissions ──────────────────────────────────────── +CREATE TABLE IF NOT EXISTS submissions ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + ghost_name VARCHAR(100) NOT NULL DEFAULT '', + type VARCHAR(20) NOT NULL DEFAULT 'tip', + username VARCHAR(100) NOT NULL DEFAULT 'Anonym', + discord_id VARCHAR(30) DEFAULT NULL, + content TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + admin_note TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + reviewed_at DATETIME DEFAULT NULL, + reviewed_by VARCHAR(100) DEFAULT NULL, + INDEX idx_status (status), + INDEX idx_discord_id (discord_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ─── Default Ghosts ───────────────────────────────────── +INSERT IGNORE INTO ghosts (id, name, evidence, sanity, hunt, sanity_special, tip, description, tells) VALUES +('banshee','Banshee','["EMF","Freezing","SLS"]',50,'mid','Target individual sanity — not team average','Special Scream via Parabolic Mic · höhere Wahrscheinlichkeit Weinen','The Banshee chooses a single target and only chases that player until they die, then re-rolls.','[{"icon":"🎙️","text":"Parabolic scream audible within ~28 studs"},{"icon":"😢","text":"Crying Event more common than most ghosts"},{"icon":"🎯","text":"Ignores all others — only chases its chosen target"},{"icon":"📻","text":"Music Box: ignores non-target if target is inside"}]'), +('demon','Demon','["Freezing","Writing","SpiritBox"]',90,'high','Hunts at 90% — most aggressive','Wird schneller nach jedem Kill · Crucifix Range 2.25× · 1/30 Chance Crucifix ignorieren','The most dangerous ghost — hunts near-constantly and gains speed after every kill.','[{"icon":"⚡","text":"90% sanity threshold — can hunt within seconds"},{"icon":"💨","text":"Gains speed after every successful kill"},{"icon":"✝️","text":"Crucifix range +40 studs AND 60s cooldown post-burn"},{"icon":"🎲","text":"1/30 chance it roars and ignores the crucifix"}]'), +('faejkur','Faejkur','["EMF","Freezing","Writing"]',50,'mid','Standard 50%','Mimt Sounds eine Oktave tiefer · Fake Footsteps im Hunt','Copies ambient sounds and replays them one octave lower.','[{"icon":"🎵","text":"All mimicked sounds play at one octave lower"},{"icon":"👣","text":"Fake footsteps during hunts"},{"icon":"😂","text":"Parabolic Microphone picks up a unique Faejkur laugh"},{"icon":"📡","text":"Mimicked EMF: smooth single tone instead of choppy beeps"}]'), +('harrow','Harrow','["Writing","Orbs","SLS"]',50,'mid','+10% per player in its room (stacks)','Kann nicht roamen · Schnell im Raum, langsam außerhalb','The only ghost permanently bound to its favorite room.','[{"icon":"📍","text":"Cannot roam — permanently glued to its favorite room"},{"icon":"🏃","text":"Revenant-fast inside, very slow far from its room"},{"icon":"🧂","text":"Salt outside the favorite room = not Harrow"},{"icon":"😭","text":"Cannot perform Crying Event outside its room"}]'), +('jiangshi','Jiangshi','["Freezing","UV","SLS"]',50,'mid','Standard 50% · Added May 2026','3× repetitive actions · skips letters on Spirit Board · skips 2nd salt footstep','Everything happens in threes. Any interaction triggers three times consecutively.','[{"icon":"3️⃣","text":"Any interaction repeats exactly 3 times"},{"icon":"📋","text":"Spirit Board skips letters (KITCHEN → K T C H E N)"},{"icon":"🧂","text":"May skip exactly the 2nd footstep in salt"},{"icon":"👣","text":"Occasional hop during hunts — skipped footstep audio"}]'), +('krasue','Krasue','["EMF","Freezing","UV"]',50,'mid','Standard 50%','Schwimmender Kopf near Kerzen · Kerzen verlangsamen sie','The only ghost with a unique visual model — a floating head.','[{"icon":"💀","text":"Unique floating head model — 10% base + 10% per lit candle"},{"icon":"🕯️","text":"Each lit candle slows head form by 1 speed (max −5)"},{"icon":"🕯️","text":"Stack candles to nullify its LOS speed boost"},{"icon":"✝️","text":"Crucifix in ghost room documented to block appearance change"}]'), +('lament','Lament','["EMF","Orbs","SpiritBox"]',50,'mid','Standard 50% · No LOS speed','Kann Hunt Fake beenden → Lights stop, silent, NV works but hunt still active','Extremely deceptive — near the end of a hunt it goes completely silent.','[{"icon":"🔇","text":"Near hunt end: goes silent — no heartbeat, lights stop"},{"icon":"💡","text":"Lights stop BUT doors remain locked — still in a hunt!"},{"icon":"🔄","text":"No LOS speed boost — fully loopable"},{"icon":"⚡","text":"Very rare: drains all nearby players stamina during manifestation"}]'), +('mare','Mare','["Freezing","SpiritBox","SLS"]',50,'mid','35% when lit; 50% in darkness','Kein Lightflickers · Kann nicht jagen wenn Raumlicht an','The darkness-dependent ghost. Cannot hunt while its room light is on.','[{"icon":"💡","text":"Cannot hunt while its room ceiling light is on"},{"icon":"🕯️","text":"Blows out candles within 90 studs (vs. 40 for others)"},{"icon":"💥","text":"5% chance to shatter the light bulb when turning it off"},{"icon":"⚡","text":"EMF spike when light goes OFF — flip the switch to test"}]'), +('nook','Nook','["EMF","Freezing","Orbs"]',50,'mid','25% when in highest-item room','Kann Raum wechseln · Klaut Items · Favorite Room = meisten Items','The roam king — its favorite room is wherever the most items are.','[{"icon":"🏃","text":"Roams most — favors room with most items"},{"icon":"💨","text":"Can make nearby objects completely vanish"},{"icon":"🌡️","text":"Multiple cold rooms = Nook or Yama"},{"icon":"📦","text":"Lure tactic: pile items in a room to redirect its favorite room"}]'), +('oni','Oni','["Writing","UV","SLS"]',50,'mid','Standard 50% · Weakens permanently','Opens & closes doors during hunt · fast salt steps · Cannot Sing','Starts very dangerous but permanently weakens each time players use incense, crucifixes, or salt.','[{"icon":"📉","text":"Permanently slows each time stunned by incense or salt"},{"icon":"🧂","text":"Salt footstep speed audibly decreases as it weakens"},{"icon":"🚪","text":"Opens and closes doors during hunts"},{"icon":"🎵","text":"Cannot perform Singing Event"}]'), +('phantom','Phantom','["SLS","UV","Orbs"]',50,'mid','Standard 50% · Added May 2026','Invisible on Video Camera · Photo flash stuns 3s','Camera-shy and invisible to video feeds.','[{"icon":"📷","text":"Photo flash stuns for 3 seconds (once per 2 min)"},{"icon":"📹","text":"Invisible on Video Cameras/CCTV"},{"icon":"👻","text":"Photo during Flash/Fake Hunt events = completely disappears"},{"icon":"🎥","text":"Does not disrupt CCTV footage at all"}]'), +('poltergeist','Poltergeist','["UV","Orbs","SpiritBox"]',50,'mid','Standard 50%','Aggresiv mit Türen/Items · Poltsplosion: bis zu 10 Items gleichzeitig','The chaos thrower — can launch up to 10 items simultaneously.','[{"icon":"💥","text":"Poltsplosion — throws up to 10 items simultaneously"},{"icon":"🔌","text":"Only ghost that consistently interacts with electronics during hunts"},{"icon":"📚","text":"Can throw Ghost Writing Books"},{"icon":"📦","text":"Test: pile items in ghost room — if they explode outward = Poltergeist"}]'), +('revenant','Revenant','["EMF","Writing","UV"]',50,'mid','Standard 50%','Normale Footsteps (Salt) = No Revenant · slow → LOS = very fast','The most extreme speed swing in the game.','[{"icon":"👁️","text":"Extremely slow without LOS, blisteringly fast on LOS"},{"icon":"🏃","text":"Incense stun reverts to base slow speed"},{"icon":"💡","text":"Performs Light Event more often than most ghosts"},{"icon":"🧂","text":"Fast salt-step audio (shared with Demon, Harrow)"}]'), +('shade','Shade','["EMF","Writing","SLS"]',35,'low','35% — lowest in game · Very passive','Red Lights Event kann nicht durch Shade · Schnelle Interaktion = Kein Shade','Almost never hunts. Activity drops dramatically with players nearby.','[{"icon":"👥","text":"Activity drops ~80% when any player is in its room"},{"icon":"🚫","text":"Cannot do: Physical Manifestation, Fake Hunt, Flash, Red Lights, Singing"},{"icon":"🔮","text":"Appears translucent from Summoning Circle, won\'t hunt"},{"icon":"⚡","text":"Incense stun lasts ~6 seconds"}]'), +('spirit','Spirit','["Writing","UV","SpiritBox"]',50,'mid','Standard 50% · Incense vulnerable','Incense stun 6s (vs 2s) · 3 min hunt block after cleanse','Average baseline ghost but uniquely vulnerable to incense.','[{"icon":"🌿","text":"Incense stun lasts 6 seconds (vs 2s for others)"},{"icon":"🛡️","text":"Cleansing ghost room blocks hunts for 3 minutes"},{"icon":"⛔","text":"Ghost halts completely before regaining speed after incense"},{"icon":"🧘","text":"Less likely to throw the Incense Burner item"}]'), +('strigoi','Strigoi','["EMF","UV","Orbs"]',50,'mid','Standard 50% · Water interaction','UV 4 Finger · turning off water can start a hunt · invisible near water','Water-powered — near active water sources it can turn completely invisible.','[{"icon":"🖐️","text":"4-fingered handprint on UV — unique to Strigoi"},{"icon":"💧","text":"Near running water: can fade completely invisible"},{"icon":"🚰","text":"Turn off activated tap: 1/8 chance to instantly trigger a hunt"},{"icon":"👁️","text":"Slowest blink rate in game away from water"}]'), +('vuult','Vuult','["EMF","Orbs","SLS"]',50,'variable','Variable 0–100% based on Vuultage charge (0–16+)','Vuultage charge · Each point = +10% sanity threshold · Smashes EMF at 16','Its threat is determined by Vuultage charge (0–16+).','[{"icon":"⚡","text":"Vuultage: +2 per nearby electric device, +5 generator on, +10 thunderstorm"},{"icon":"📈","text":"Each charge point raises hunt threshold by 10%"},{"icon":"💥","text":"At 16+ charge: can smash your EMF Reader during hunt"},{"icon":"💡","text":"Performs Light Events frequently, tampers with breaker early"}]'), +('wraith','Wraith','["Freezing","Orbs","SLS"]',50,'mid','Standard 50% · Keine Steps im Salz','Keine Steps im Salz · Teleportiert zu Spielern','Never steps in salt — the single most reliable identification tell.','[{"icon":"🧂","text":"NEVER steps in salt — 100% confirmation"},{"icon":"🌀","text":"Can teleport to any random player — triggers EMF 2"},{"icon":"👻","text":"Floating animation during Flash Events"},{"icon":"📡","text":"SLS rig may hover rather than walk"}]'), +('yama','Yama','["Writing","SpiritBox","SLS"]',50,'mid','Standard 50% · Incense = 2 min locked','Roams constantly · incense sticks it 2 min · Spirit Box loud roar','Changes its favorite room every time it roams — roams constantly.','[{"icon":"🗣️","text":"Spirit Box roar/growl instead of whisper — unique Yama response"},{"icon":"🚶","text":"Changes favorite room every time it roams"},{"icon":"📡","text":"SLS rig can wander outside the ghost room"},{"icon":"⏸️","text":"Incense locks Yama in place for 2 full minutes"}]'), +('yurei','Yurei','["Freezing","UV","SpiritBox"]',50,'mid','Standard 50% · Mostly blind','Macht Auto nicht an · takes time to even step on salt','Almost blind but hyper-aware of sound.','[{"icon":"👁️","text":"Mostly blind — stationary players with no electronics may be ignored"},{"icon":"👂","text":"Exceptional hearing — any movement or electronic = pursuit"},{"icon":"🔄","text":"No LOS speed boost — fully loopable"},{"icon":"🚗","text":"Does not interact with phones or vehicles"}]'), +('zozo','ZoZo','["EMF","UV","SpiritBox"]',80,'high','80% threshold — 2nd most aggressive','Verflucht Ouija Board · Slows when you look · rages when you stare','Staring slows it — but stare too long and it rages to Revenant-tier speed.','[{"icon":"👀","text":"Slows when you look at it — rage state if you stare too long"},{"icon":"😡","text":"Rage state: Revenant-tier speed for ~10 seconds"},{"icon":"📋","text":"50% chance Spirit Board turns red and spells ZoZo"},{"icon":"🚫","text":"Cannot perform Crying or Singing Events"}]'); diff --git a/server.js b/server.js new file mode 100644 index 0000000..6f45b64 --- /dev/null +++ b/server.js @@ -0,0 +1,90 @@ +require('dotenv').config(); +const express = require('express'); +const session = require('express-session'); +const passport = require('passport'); +const helmet = require('helmet'); +const path = require('path'); +const db = require('./db'); +const authRoutes = require('./routes/auth'); +const apiRoutes = require('./routes/api'); +const adminRoutes = require('./routes/admin'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Damit Nginx + HTTPS korrekt funktioniert +app.set('trust proxy', 1); + +// ── Security headers ────────────────────────────────── +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com"], + styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com", "fonts.gstatic.com"], + fontSrc: ["'self'", "fonts.gstatic.com", "fonts.googleapis.com"], + imgSrc: ["'self'", "data:", "cdn.discordapp.com"], + connectSrc: ["'self'", "discord.com"], + }, + }, +})); + +// ── Body parsers ────────────────────────────────────── +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// ── Session with MySQL store ────────────────────────── +const MySQLStore = require('express-mysql-session')(session); +app.use(session({ + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: new MySQLStore({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT) || 3306, + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASS, + createDatabaseTable: true, + }), + cookie: { + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage + secure: false, // Nginx macht HTTPS, Node sieht nur HTTP intern + httpOnly: true, + sameSite: 'lax', + }, +})); + +// ── Passport ────────────────────────────────────────── +require('./middleware/passport')(passport); +app.use(passport.initialize()); +app.use(passport.session()); + +// ── Static files ────────────────────────────────────── +app.use(express.static(path.join(__dirname, 'public'))); + +// ── Routes ──────────────────────────────────────────── +app.use('/auth', authRoutes); +app.use('/api', apiRoutes); +app.use('/admin', adminRoutes); + +// ── SPA fallback ────────────────────────────────────── +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// ── Start ───────────────────────────────────────────── +app.listen(PORT, async () => { + console.log(`\n🎃 Blair Dashboard läuft auf http://localhost:${PORT}`); + console.log(` Modus: ${process.env.NODE_ENV || 'development'}\n`); + + // Owner automatisch als Admin eintragen (falls noch nicht vorhanden) + if (process.env.OWNER_DISCORD_ID) { + try { + await db.query( + 'INSERT IGNORE INTO admin_whitelist (discord_id, discord_username, added_by) VALUES (?, ?, ?)', + [process.env.OWNER_DISCORD_ID, process.env.OWNER_DISCORD_NAME || 'Owner', 'system'] + ); + } catch (e) { /* ignore */ } + } +});