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