.
This commit is contained in:
parent
01f0c4272a
commit
e4ecf2e3e9
34
.env.example
Normal file
34
.env.example
Normal 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
233
DEPLOY.md
Normal 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
21
db.js
Normal 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
13
middleware/auth.js
Normal 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
56
middleware/passport.js
Normal 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
24
package.json
Normal 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
2412
public/index.html
Normal file
File diff suppressed because it is too large
Load Diff
233
routes/DEPLOY.md
Normal file
233
routes/DEPLOY.md
Normal 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
152
routes/admin.js
Normal 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
58
routes/api.js
Normal 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
42
routes/auth.js
Normal 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
99
schema.sql
Normal 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 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":"<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
90
server.js
Normal 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 */ }
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user