From 01f0c4272a29162bbde274487b21276e15b8fbdc Mon Sep 17 00:00:00 2001 From: Jeremy Kirsch Date: Sun, 17 May 2026 21:00:13 +0200 Subject: [PATCH] . --- .env.example | 34 - DEPLOY.md | 233 ----- admin.js | 152 ---- api.js | 58 -- auth.js | 42 - db.js | 21 - index.html | 833 ------------------ .../blair-selfhosted/middleware/auth.js | 13 - package.json | 24 - passport.js | 48 - schema.sql | 90 -- server.js | 87 -- 12 files changed, 1635 deletions(-) delete mode 100644 .env.example delete mode 100644 DEPLOY.md delete mode 100644 admin.js delete mode 100644 api.js delete mode 100644 auth.js delete mode 100644 db.js delete mode 100644 index.html delete mode 100644 mnt/user-data/outputs/blair-selfhosted/middleware/auth.js delete mode 100644 package.json delete mode 100644 passport.js delete mode 100644 schema.sql delete mode 100644 server.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 75056eb..0000000 --- a/.env.example +++ /dev/null @@ -1,34 +0,0 @@ -# ═══════════════════════════════════════ -# 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 deleted file mode 100644 index 0f69758..0000000 --- a/DEPLOY.md +++ /dev/null @@ -1,233 +0,0 @@ -# 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/admin.js b/admin.js deleted file mode 100644 index 34126b2..0000000 --- a/admin.js +++ /dev/null @@ -1,152 +0,0 @@ -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/api.js b/api.js deleted file mode 100644 index c13fa04..0000000 --- a/api.js +++ /dev/null @@ -1,58 +0,0 @@ -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/auth.js b/auth.js deleted file mode 100644 index 769365d..0000000 --- a/auth.js +++ /dev/null @@ -1,42 +0,0 @@ -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/db.js b/db.js deleted file mode 100644 index 33f3533..0000000 --- a/db.js +++ /dev/null @@ -1,21 +0,0 @@ -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/index.html b/index.html deleted file mode 100644 index ad9113a..0000000 --- a/index.html +++ /dev/null @@ -1,833 +0,0 @@ - - - - - -Blair — Ghost Dashboard - - - - - - - - - - -
Lade...
- - - - - - - - - - -
-
-
Sicher?
-
-
-
-
-
- - - - diff --git a/mnt/user-data/outputs/blair-selfhosted/middleware/auth.js b/mnt/user-data/outputs/blair-selfhosted/middleware/auth.js deleted file mode 100644 index ff2c810..0000000 --- a/mnt/user-data/outputs/blair-selfhosted/middleware/auth.js +++ /dev/null @@ -1,13 +0,0 @@ -// 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/package.json b/package.json deleted file mode 100644 index d1df622..0000000 --- a/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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" - } -} \ No newline at end of file diff --git a/passport.js b/passport.js deleted file mode 100644 index 57f09cd..0000000 --- a/passport.js +++ /dev/null @@ -1,48 +0,0 @@ -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 { - // Admin-Status prüfen - const rows = await db.query( - 'SELECT discord_id FROM admin_whitelist WHERE discord_id = ?', - [profile.id] - ); - const isAdmin = rows.length > 0; - - const user = { - id: profile.id, - username: profile.username, - avatar: profile.avatar - ? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png` - : null, - isAdmin, - }; - return done(null, user); - } catch (err) { - return done(err, null); - } - })); - - passport.serializeUser((user, done) => done(null, user)); - passport.deserializeUser(async (obj, done) => { - // Admin-Status bei jedem Request neu prüfen (live Whitelist) - try { - const rows = await db.query( - 'SELECT discord_id FROM admin_whitelist WHERE discord_id = ?', - [obj.id] - ); - obj.isAdmin = rows.length > 0; - done(null, obj); - } catch (e) { - done(null, obj); - } - }); -}; diff --git a/schema.sql b/schema.sql deleted file mode 100644 index 6f37925..0000000 --- a/schema.sql +++ /dev/null @@ -1,90 +0,0 @@ --- ═══════════════════════════════════════════════════════ --- 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; - --- ─── 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 deleted file mode 100644 index c43a485..0000000 --- a/server.js +++ /dev/null @@ -1,87 +0,0 @@ -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; - -// ── 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: process.env.NODE_ENV === 'production', - 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 */ } - } -});