This commit is contained in:
Jeremy Kirsch 2026-05-17 21:05:19 +02:00
parent 01f0c4272a
commit e4ecf2e3e9
13 changed files with 3467 additions and 0 deletions

34
.env.example Normal file
View File

@ -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

233
DEPLOY.md Normal file
View File

@ -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=<langer-zufälliger-string>
DB_HOST=localhost
DB_PORT=3306
DB_NAME=blair_dashboard
DB_USER=blair_user
DB_PASS=<dein-db-passwort>
DISCORD_CLIENT_ID=<deine-discord-client-id>
DISCORD_CLIENT_SECRET=<dein-discord-client-secret>
APP_URL=https://deinedomain.de
OWNER_DISCORD_ID=<deine-discord-id>
OWNER_DISCORD_NAME=<dein-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).

21
db.js Normal file
View File

@ -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;

13
middleware/auth.js Normal file
View File

@ -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 };

56
middleware/passport.js Normal file
View File

@ -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);
}
});
};

24
package.json Normal file
View File

@ -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"
}
}

2412
public/index.html Normal file

File diff suppressed because it is too large Load Diff

233
routes/DEPLOY.md Normal file
View File

@ -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=<langer-zufälliger-string>
DB_HOST=localhost
DB_PORT=3306
DB_NAME=blair_dashboard
DB_USER=blair_user
DB_PASS=<dein-db-passwort>
DISCORD_CLIENT_ID=<deine-discord-client-id>
DISCORD_CLIENT_SECRET=<dein-discord-client-secret>
APP_URL=https://deinedomain.de
OWNER_DISCORD_ID=<deine-discord-id>
OWNER_DISCORD_NAME=<dein-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).

152
routes/admin.js Normal file
View File

@ -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;

58
routes/api.js Normal file
View File

@ -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;

42
routes/auth.js Normal file
View File

@ -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;

99
schema.sql Normal file
View File

@ -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":"<strong>Parabolic scream</strong> audible within ~28 studs"},{"icon":"😢","text":"<strong>Crying Event</strong> 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":"<strong>90% sanity threshold</strong> — can hunt within seconds"},{"icon":"💨","text":"<strong>Gains speed</strong> after every successful kill"},{"icon":"✝️","text":"Crucifix range +40 studs AND 60s cooldown post-burn"},{"icon":"🎲","text":"1/30 chance it <strong>roars and ignores the crucifix</strong>"}]'),
('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 <strong>one octave lower</strong>"},{"icon":"👣","text":"Fake footsteps during hunts"},{"icon":"😂","text":"Parabolic Microphone picks up a unique <strong>Faejkur laugh</strong>"},{"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":"<strong>Cannot roam</strong> — permanently glued to its favorite room"},{"icon":"🏃","text":"Revenant-fast <strong>inside</strong>, very slow <strong>far from</strong> 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 <strong>repeats exactly 3 times</strong>"},{"icon":"📋","text":"Spirit Board skips letters (KITCHEN → K T C H E N)"},{"icon":"🧂","text":"May skip exactly the <strong>2nd footstep</strong> 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 <strong>floating head model</strong> — 10% base + 10% per lit candle"},{"icon":"🕯️","text":"Each lit candle <strong>slows head form by 1 speed</strong> (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":"<strong>Near hunt end: goes silent</strong> — no heartbeat, lights stop"},{"icon":"💡","text":"Lights stop BUT doors remain locked — <strong>still in a hunt!</strong>"},{"icon":"🔄","text":"<strong>No LOS speed boost</strong> — 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":"<strong>Cannot hunt</strong> while its room ceiling light is on"},{"icon":"🕯️","text":"Blows out candles within <strong>90 studs</strong> (vs. 40 for others)"},{"icon":"💥","text":"5% chance to <strong>shatter the light bulb</strong> 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 <strong>most items</strong>"},{"icon":"💨","text":"Can make nearby objects <strong>completely vanish</strong>"},{"icon":"🌡️","text":"Multiple cold rooms = Nook or Yama"},{"icon":"📦","text":"<strong>Lure tactic:</strong> 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":"<strong>Permanently slows</strong> each time stunned by incense or salt"},{"icon":"🧂","text":"Salt footstep speed <strong>audibly decreases</strong> as it weakens"},{"icon":"🚪","text":"Opens and closes doors during hunts"},{"icon":"🎵","text":"<strong>Cannot perform Singing Event</strong>"}]'),
('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 <strong>stuns for 3 seconds</strong> (once per 2 min)"},{"icon":"📹","text":"<strong>Invisible on Video Cameras/CCTV</strong>"},{"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":"<strong>Poltsplosion</strong> — throws up to 10 items simultaneously"},{"icon":"🔌","text":"<strong>Only ghost</strong> 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":"<strong>Extremely slow</strong> without LOS, <strong>blisteringly fast</strong> 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 <strong>~80%</strong> when any player is in its room"},{"icon":"🚫","text":"<strong>Cannot</strong> do: Physical Manifestation, Fake Hunt, Flash, Red Lights, Singing"},{"icon":"🔮","text":"Appears <strong>translucent</strong> 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 <strong>6 seconds</strong> (vs 2s for others)"},{"icon":"🛡️","text":"Cleansing ghost room blocks hunts for <strong>3 minutes</strong>"},{"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":"<strong>4-fingered handprint</strong> on UV — unique to Strigoi"},{"icon":"💧","text":"Near running water: can <strong>fade completely invisible</strong>"},{"icon":"🚰","text":"Turn off activated tap: <strong>1/8 chance to instantly trigger a hunt</strong>"},{"icon":"👁️","text":"Slowest blink rate in game away from water"}]'),
('vuult','Vuult','["EMF","Orbs","SLS"]',50,'variable','Variable 0100% based on Vuultage charge (016+)','Vuultage charge · Each point = +10% sanity threshold · Smashes EMF at 16','Its threat is determined by Vuultage charge (016+).','[{"icon":"⚡","text":"<strong>Vuultage:</strong> +2 per nearby electric device, +5 generator on, +10 thunderstorm"},{"icon":"📈","text":"Each charge point <strong>raises hunt threshold by 10%</strong>"},{"icon":"💥","text":"At 16+ charge: can <strong>smash your EMF Reader</strong> 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":"<strong>NEVER steps in salt</strong> — 100% confirmation"},{"icon":"🌀","text":"Can <strong>teleport</strong> to any random player — triggers EMF 2"},{"icon":"👻","text":"Floating animation during Flash Events"},{"icon":"📡","text":"SLS rig may <strong>hover</strong> 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":"<strong>Spirit Box roar/growl</strong> instead of whisper — unique Yama response"},{"icon":"🚶","text":"Changes favorite room <strong>every time it roams</strong>"},{"icon":"📡","text":"SLS rig can <strong>wander outside</strong> the ghost room"},{"icon":"⏸️","text":"Incense <strong>locks Yama in place for 2 full minutes</strong>"}]'),
('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":"<strong>Mostly blind</strong> — stationary players with no electronics may be ignored"},{"icon":"👂","text":"<strong>Exceptional hearing</strong> — any movement or electronic = pursuit"},{"icon":"🔄","text":"<strong>No LOS speed boost</strong> — 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":"<strong>Slows when you look at it</strong> — rage state if you stare too long"},{"icon":"😡","text":"Rage state: Revenant-tier speed for ~10 seconds"},{"icon":"📋","text":"50% chance Spirit Board turns <strong>red and spells ZoZo</strong>"},{"icon":"🚫","text":"Cannot perform Crying or Singing Events"}]');

90
server.js Normal file
View File

@ -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 */ }
}
});