chore: restrukturierung der microservices in dev/lobby und dev/sector-dev (monorepo)
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# ==============================================================================
|
||||
# Void-Genesis - Lobby Backend (Dev Environment)
|
||||
# ==============================================================================
|
||||
|
||||
# Wir nutzen eine leichtgewichtige Alpine-Version der aktuellen Node.js LTS (z.B. 22)
|
||||
# Alpine-Images sind extrem klein und reduzieren die Angriffsfläche.
|
||||
FROM node:22-alpine
|
||||
|
||||
# Setze das Arbeitsverzeichnis innerhalb des Containers auf /app.
|
||||
# Alle folgenden Befehle (RUN, CMD, COPY) werden in diesem Ordner ausgeführt.
|
||||
WORKDIR /app
|
||||
|
||||
# Wir kopieren zuerst NUR die package.json (und package-lock.json, falls vorhanden).
|
||||
# Das ist ein Best-Practice für Docker: Docker speichert diesen Schritt im Cache.
|
||||
# 'npm install' wird beim erneuten Bauen nur dann ausgeführt, wenn sich an den
|
||||
# Abhängigkeiten in der package.json etwas geändert hat. Das spart enorm Zeit!
|
||||
COPY package*.json ./
|
||||
|
||||
# Installiere alle Abhängigkeiten (inklusive Dev-Dependencies wie nodemon)
|
||||
RUN npm install
|
||||
|
||||
# HINWEIS: Den restlichen Quellcode (src/) kopieren wir hier im Dev-Dockerfile
|
||||
# absichtlich nicht mit COPY. Der Code wird stattdessen über die docker-compose.yml
|
||||
# als Volume gemountet. So greifen deine Änderungen am Root-Server sofort im Container.
|
||||
|
||||
# Wir dokumentieren hier, dass der Container auf Port 3000 lauscht.
|
||||
# Das Backend wird diesen Port später nutzen.
|
||||
EXPOSE 3000
|
||||
|
||||
# Der Standardbefehl, wenn der Container startet. Wir rufen das Script "dev" auf,
|
||||
# welches wir gleich im nächsten Schritt in der package.json definieren werden.
|
||||
CMD ["npm", "run", "dev"]
|
||||
+1618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "void-genesis-lobby-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend für die Master-Account-Lobby",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import * as authService from '../services/auth.service.js';
|
||||
|
||||
// Ein Standard-Regulärer-Ausdruck (Regex) zur Prüfung eines gültigen E-Mail-Formats
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Registierung-Route.
|
||||
*/
|
||||
export const register = async (req, res) => {
|
||||
try {
|
||||
// Daten aus dem Body extrahieren
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
// Basis-Validierung (leere Felder, Passwortlänge)
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({
|
||||
// ERR_MISSING_FIELDS: Bitte alle Felder ausfüllen.
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({
|
||||
// ERR_PASSWORD_TOO_SHORT: Das Passwort muss mindestens 8 Zeichen lang sein.
|
||||
error: 'ERR_PASSWORD_TOO_SHORT'
|
||||
});
|
||||
}
|
||||
if (username.length < 3) {
|
||||
return res.status(400).json({
|
||||
// ERR_USERNAME_TOO_SHORT: Der Benutzername muss mindestens 3 Zeichen lang sein.
|
||||
error: 'ERR_USERNAME_TOO_SHORT'
|
||||
});
|
||||
}
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
return res.status(400).json({
|
||||
/// ERR_INVALID_EMAIL: Das E-Mail-Format ist ungültig.
|
||||
error: 'ERR_INVALID_EMAIL'
|
||||
});
|
||||
}
|
||||
|
||||
// Den Auth-Service aufrufen (die eigenliche Arbeit)
|
||||
const result = await authService.registerUser(username, email, password);
|
||||
|
||||
// Erfolgreiche Antwort an der Frontend senden
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
// MSG_REGISTER_SUCCESS: Registrierung erfolgreich. Bitte prüfe deine E-Mails.
|
||||
message: 'MSG_REGISTER_SUCCESS',
|
||||
accountId: result.accountId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Fehlerbehandlung: Wenn es unser eigener Conflict-Fehler ist (zB Email existiert)
|
||||
if (error.code === 'ERR_USERNAME_TAKEN') {
|
||||
return res.status(409).json({
|
||||
// ERR_USERNAME_TAKEN: Dieser Benutzername ist bereits vergeben.
|
||||
error: 'ERR_USERNAME_TAKEN'
|
||||
});
|
||||
}
|
||||
if (error.code === 'ERR_EMAIL_TAKEN') {
|
||||
return res.status(409).json({
|
||||
// ERR_USERNAME_TAKEN: Diese E-Mail-Addresse ist bereits vergeben.
|
||||
error: 'ERR_EMAIL_TAKEN'
|
||||
});
|
||||
}
|
||||
|
||||
// Bei unerwarteten Fehlern (z.B. Datenbank oder Brovo nicht erreichbar.)
|
||||
console.error('[AuthController] Fehler bei der Registierung:', error);
|
||||
return res.status(500).json({
|
||||
// ERR_INTERNAL_SERVER: Ein interner Serverfehler ist aufgetreten.
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für die E-Mail-Verifizierungs-Route
|
||||
*/
|
||||
export const verifyEmail = async (req, res) => {
|
||||
try {
|
||||
// Daten aus dem Request-Body extrahieren
|
||||
const { email , otpCode } = req.body;
|
||||
|
||||
// Vasis-Validierung
|
||||
if (!email || !otpCode) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
|
||||
// Den Service aufrufen, um die eigenliche Arbeit zu machen
|
||||
await authService.verifyEmail(email, otpCode);
|
||||
|
||||
// Erfolgreiche Antwort ans Frontend senden
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'MSG_EMAIL_VERIFIED'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (['ERR_USER_NOT_FOUND', 'ERR_ALREADY_VERIFIED', 'ERR_INVALID_OTP', 'ERR_OTP_EXPIRED'].includes(error.code)) {
|
||||
|
||||
return res.status(400).json({
|
||||
error: error.code
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[AuthController] Fehler bei E-Mail-Verifizierung:', error);
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Login-Route
|
||||
*/
|
||||
export const login = async (req, res) => {
|
||||
try {
|
||||
const { identifier, password } = req.body;
|
||||
|
||||
// Basis-Validierung
|
||||
if (!identifier || !password) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
|
||||
// Service aufrufen
|
||||
const result = await authService.loginUser(identifier, password);
|
||||
|
||||
// Erfolgreiche Antwort mit den Tokens
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
// MSG_LOGIN_SUCCESS: Erfolgreich eingeloggt
|
||||
message: 'MSG_LOGIN_SUCCESS',
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
user: result.user
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_INVALID_CREDENTIALS') {
|
||||
return res.status(401).json({
|
||||
error: 'ERR_INVALID_CREDENTIALS'
|
||||
});
|
||||
}
|
||||
if (error.code === 'ERR_EMAIL_NOT_VERIFIED') {
|
||||
return res.status(403).json({
|
||||
error: 'ERR_EMAIL_NOT_VERIFIED',
|
||||
email: error.email
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[AuthController] Fehler beim Login:', error);
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Token-Refresh-Route
|
||||
*/
|
||||
export const refresh = async (req, res) => {
|
||||
try {
|
||||
// Das Refresh-Token aus dem Request-Body holen
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
// Validierung: Is überhaupt ein Token mitgesendet worden ?
|
||||
if (!refreshToken) {
|
||||
// ERR_MISSING_TOKEN: Es wurde kein Refresh-Token übergeben
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
// Den Service aufrufen, um das Token zu prüfen und ein neues zu generieren
|
||||
const result = await authService.refreshAccessToken(refreshToken);
|
||||
|
||||
// Das neue Access-Token an das Frontend zurück geben
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Ungültige, abgelaufene oder in der DB gelöschte Tokens abfangen
|
||||
if (error.code === 'ERR_UNAUTHORIZED') {
|
||||
return res.status(401).json({
|
||||
// ERR_UNAUTHORIZED: Zugriff verweigert. Die Sitzung ist ungültig.
|
||||
error: 'ERR_UNAUTHORIZED'
|
||||
});
|
||||
}
|
||||
|
||||
// Bei unerwarteten Fehlern (z.B. Datenbank oder Brovo nicht erreichbar.)
|
||||
console.error('[AuthController] Fehler beim Token-Refresh:', error);
|
||||
return res.status(500).json({
|
||||
// ERR_INTERNAL_SERVER: Ein interner Serverfehler ist aufgetreten.
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Resend-OTP-Route
|
||||
* Wird aufgerufen, wenn der Nutzer im Frontend auf "Code erneut senden" klickt
|
||||
*/
|
||||
export const resendOTP = async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
|
||||
// Aufruf unseres Service
|
||||
await authService.resendOTP(email);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
// MSG_OTP_RESENT: Ein neuer Code wurde an deine E-Mail gesendet.
|
||||
message: 'MSG_OTP_RESENT'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (['ERR_USER_NOT_FOUND', 'ERR_ALREADY_VERIFIED', 'ERR_COOLDOWN_ACTIVE'].includes(error.code)) {
|
||||
const statusCode = error.code === 'ERR_COOLDOWN_ACTIVE' ? 429 : 400;
|
||||
return res.status(statusCode).json({
|
||||
error: error.code
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[AuthController] Fehler beim erneuten Senden des OTP:', error);
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für die Passwort vergessen anfrage
|
||||
*/
|
||||
export const requestPasswordReset = async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
|
||||
await authService.requestPasswordReset(email);
|
||||
|
||||
// Auch wenn die E-Mail nicht existiert, geben wir oft ein Success zurück,
|
||||
// damit Angreifer nicht testen können, welche E-Mails registriert sind.
|
||||
// In unserem Service werfen wir aber ERR_USER_NOT_FOUND. Wir fangen das unten ab.
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'MSG_RESET_EMAIL_SENT' // Muss später ins i18n
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Wenn der Nutzer nicht existiert, tun wir für das Frontend trotzdem so, als wäre
|
||||
// die E-Mail rausgegangen (Sicherheitsbest-Practice).
|
||||
if (error.code === 'ERR_USER_NOT_FOUND') {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'MSG_RESET_EMAIL_SENT'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ERR_COOLDOWN_ACTIVE') {
|
||||
return res.status(429).json({
|
||||
error: error.code
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[AuthController] Fehler bei requestPasswordReset:', error);
|
||||
return res.status(500).json({ error: 'ERR_INTERNAL_SERVER' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für das finale Setzen des neuen Passworts.
|
||||
*/
|
||||
export const resetPassword = async (req, res) => {
|
||||
try {
|
||||
const { email, otpCode, newPassword } = req.body;
|
||||
|
||||
// Basis-Validierung
|
||||
if (!email || !otpCode || !newPassword) {
|
||||
return res.status(400).json({ error: 'ERR_MISSING_FIELDS' });
|
||||
}
|
||||
|
||||
// Passwort-Richtlinie prüfen (wie bei der Registrierung)
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({ error: 'ERR_PASSWORD_TOO_SHORT' });
|
||||
}
|
||||
|
||||
// Service aufrufen
|
||||
await authService.resetPassword(email, otpCode, newPassword);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'MSG_PASSWORD_RESET_SUCCESS' // Muss später ins i18n
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (['ERR_USER_NOT_FOUND', 'ERR_INVALID_OTP', 'ERR_OTP_EXPIRED'].includes(error.code)) {
|
||||
return res.status(400).json({ error: error.code });
|
||||
}
|
||||
|
||||
console.error('[AuthController] Fehler bei resetPassword:', error);
|
||||
return res.status(500).json({ error: 'ERR_INTERNAL_SERVER' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import pool from '../db.js';
|
||||
import * as sectorService from '../services/sector.service.js';
|
||||
|
||||
/**
|
||||
* Liefert alle Sektoren für das Frontend.
|
||||
* Übergibt die HTTP-Anfrage an den zuständigen Service und liefert das Ergebnis aus.
|
||||
*/
|
||||
export const getSectorsList = async (req, res) => {
|
||||
const accountId = req.user.accountId;
|
||||
|
||||
try {
|
||||
const sectorData = await sectorService.getSectorsList(accountId);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
...sectorData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SectorController] Fehler in getSectorsList:', error);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für den Sektor-Beitritt (Charaktererstellung).
|
||||
* Nimmt die Request an, validiert grob und leitet an den Service weiter.
|
||||
*/
|
||||
export const joinSector = async (req, res) => {
|
||||
const accountId = req.user.accountId;
|
||||
const { sectorId, localUsername, gender } = req.body;
|
||||
|
||||
if (!sectorId || !localUsername || !gender) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
});
|
||||
}
|
||||
|
||||
if (localUsername.length < 3) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_USERNAME_TOO_SHORT'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await sectorService.joinSector(accountId, sectorId, localUsername, gender);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: 'MSG_SECTOR_JOINED_SUCCESS'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_ALREADY_IN_SECTOR' || error.code === 'ERR_LOCAL_USERNAME_TAKEN') {
|
||||
return res.status(409).json({
|
||||
error: error.code
|
||||
});
|
||||
}
|
||||
|
||||
// Generischer Fallback für echte Systemabstürze
|
||||
console.error('[SectorController] Fehler beim Sektor-Beitritt:', error);
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Datenbank-Konfiguration
|
||||
// ==============================================================================
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
// Wir nutzen einen Connection Pool. Das ist deutlich effizienter als für jede
|
||||
// Anfrage eine neue Verbindung zur Datenbank aufzubauen. Der Pool hält im
|
||||
// Hintergrund mehrere Verbindungen offen und verteilt sie dynamisch.
|
||||
const { Pool } = pg;
|
||||
|
||||
const pool = new Pool({
|
||||
// Diese Variablen kommen direkt aus unserer docker-compose.yml
|
||||
// (Bereich 'environment' des Backend-Containers)
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
// Wir testen beim Start einmalig, ob die Verbindung klappt.
|
||||
// So sehen wir im Docker-Log sofort, falls z.B. das Passwort falsch ist.
|
||||
pool.connect((err, client, release) => {
|
||||
if (err) {
|
||||
console.error('❌ Fehler beim Verbinden mit der PostgreSQL-Datenbank:', err.stack);
|
||||
} else {
|
||||
console.log('✅ Erfolgreich mit der PostgreSQL-Datenbank (void_lobby) verbunden!');
|
||||
// WICHTIG: Den Client immer direkt wieder freigeben, wenn wir fertig sind
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
// Wir exportieren den Pool, damit wir ihn in anderen Dateien (wie unseren Routes)
|
||||
// für Datenbankabfragen nutzen können.
|
||||
export default pool;
|
||||
@@ -0,0 +1,30 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Middleware zum Schützen privater API-Routen.
|
||||
* Prüft, ob ein gültiges Access_Token im Aithorization-Headder mitgesendet wurde.
|
||||
*/
|
||||
export const requireAuth = (req, res, next) => {
|
||||
// Der Standard für Tokens ist der Header: "Authorization: Bearer <token>"
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: 'ERR_UNAUTHORIZED_NO_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
|
||||
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
error: 'ERR_UNAUTHORIZED_INVALID_TOKEN'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import express from 'express';
|
||||
import * as authController from '../controllers/auth.controller.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/register', authController.register);
|
||||
router.post('/verify-email', authController.verifyEmail);
|
||||
router.post('/login', authController.login);
|
||||
router.post('/refresh', authController.refresh);
|
||||
router.post('/resend-otp', authController.resendOTP);
|
||||
router.post('/forgot-password', authController.requestPasswordReset);
|
||||
router.post('/reset-password', authController.resetPassword);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,10 @@
|
||||
import express from 'express';
|
||||
import * as sectorController from '../controllers/sector.controller.js';
|
||||
import { requireAuth } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/list', requireAuth, sectorController.getSectorsList);
|
||||
router.post('/join', requireAuth, sectorController.joinSector);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,192 @@
|
||||
import pool from '../db.js';
|
||||
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Datenbank Initialisierungs-Skript
|
||||
// Führt alle nötigen CREATE TABLE Befehle aus, falls die Tabellen nicht existieren.
|
||||
// ==============================================================================
|
||||
|
||||
const createTablesQuery = `
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- DROP TABLES
|
||||
-- WICHTIG: Die Reihenfolge beim Löschen ist entscheidend!
|
||||
-- Tabellen mit Fremdschlüsseln (Foreign Keys) müssen zuerst gelöscht werden.
|
||||
-- ------------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
DROP TABLE IF EXISTS auth_otps;
|
||||
DROP TABLE IF EXISTS lobby_accounts_sectors; -- Zuerst die Brückentabelle löschen
|
||||
DROP TABLE IF EXISTS sectors; -- Dann die Sektoren-Tabelle
|
||||
DROP TABLE IF EXISTS lobby_accounts; -- Zuletzt die Haupt-Nutzer-Tabelle
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: lobby_accounts
|
||||
-- Basis-Nutzerdaten für das gesamte Void-Genesis Universum.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lobby_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_email_verified BOOLEAN DEFAULT false,
|
||||
res_premium INT DEFAULT 0,
|
||||
is_admin BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: sectors
|
||||
-- Beinhaltet die globalen Metadaten zu allen verfügbaren Spiel-Sektoren.
|
||||
-- Hier definieren wir, welche Server existieren und welche Eigenschaften sie haben.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sectors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'offline',
|
||||
api_url VARCHAR(255),
|
||||
speed_fleet INT DEFAULT 1,
|
||||
speed_economy INT DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: lobby_accounts_sectors (Brückentabelle)
|
||||
-- Verknüpft einen Lobby-Account mit einem Sektor.
|
||||
-- Existiert hier ein Eintrag, bedeutet das: Der Nutzer hat in diesem Sektor einen Spielstand.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lobby_accounts_sectors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lobby_account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
|
||||
sector_id UUID REFERENCES sectors(id) ON DELETE CASCADE,
|
||||
local_username VARCHAR(50) NOT NULL,
|
||||
gender VARCHAR(20) NOT NULL,
|
||||
res_premium INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(lobby_account_id, sector_id),
|
||||
UNIQUE(sector_id, local_username)
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: auth_otps
|
||||
-- Speichert temporäre 6-stellige Codes für Registrierung oder Passwort-Reset.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS auth_otps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
|
||||
otp_code VARCHAR(6) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: refresh_tokens
|
||||
-- Speichert langlebige Tokens für die "Angemeldet bleiben"-Funktion.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
|
||||
token VARCHAR(500) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TRIGGER: Auto-update 'updated_at' timestamp
|
||||
-- Diese Funktion setzt das 'updated_at' Feld bei jeder Änderung auf die aktuelle Zeit.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION update_modified_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Wir wenden den Trigger nur an, wenn er noch nicht existiert.
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Trigger für lobby_accounts
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_lobby_accounts_modtime') THEN
|
||||
CREATE TRIGGER update_lobby_accounts_modtime
|
||||
BEFORE UPDATE ON lobby_accounts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
END IF;
|
||||
|
||||
-- Trigger für sectors
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_sectors_modtime') THEN
|
||||
CREATE TRIGGER update_sectors_modtime
|
||||
BEFORE UPDATE ON sectors
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
END IF;
|
||||
|
||||
-- Trigger für lobby_accounts_sectors (WICHTIG für unsere "Zuletzt gespielt" Funktion)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_lobby_accounts_sectors_modtime') THEN
|
||||
CREATE TRIGGER update_lobby_accounts_sectors_modtime
|
||||
BEFORE UPDATE ON lobby_accounts_sectors
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
END IF;
|
||||
END $$;
|
||||
`;
|
||||
|
||||
// ==============================================================================
|
||||
// Ausführungs-Logik
|
||||
// ==============================================================================
|
||||
async function initDatabase() {
|
||||
console.log('⏳ Starte Datenbank-Initialisierung...');
|
||||
|
||||
// Wir holen uns eine Verbindung aus dem Pool
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Wir senden den kompletten SQL-String an die Datenbank
|
||||
await client.query(createTablesQuery);
|
||||
console.log('✅ Tabellen wurden erfolgreich erstellt (oder existierten bereits).');
|
||||
|
||||
const sectorCheck = await client.query('SELECT count(*) FROM sectors');
|
||||
if (parseInt(sectorCheck.rows[0].count) === 0) {
|
||||
console.log('🌱 Füge 24 Test-Sektoren ein...');
|
||||
|
||||
const greekAlphabet = [
|
||||
'Development', 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta',
|
||||
'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu',
|
||||
'Nu', 'Xi', 'Omicron', 'Pi', 'Rho', 'Sigma',
|
||||
'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega'
|
||||
];
|
||||
|
||||
// Wir generieren für jeden Namen ein dynamisches INSERT
|
||||
const insertPromises = greekAlphabet.map((name, index) => {
|
||||
// Wir variieren die Geschwindigkeiten leicht für optische Vielfalt
|
||||
const speed = (index % 4) + 1;
|
||||
|
||||
const apiUrl = name === 'Development'
|
||||
? 'http://sector-backend:3000'
|
||||
: `http://sector-api-${name.toLowerCase()}:3000`;
|
||||
|
||||
return client.query(`
|
||||
INSERT INTO sectors (name, status, api_url, speed_fleet, speed_economy)
|
||||
VALUES ($1, 'offline', $2, $3, $3)
|
||||
`, [name, apiUrl, speed]);
|
||||
});
|
||||
|
||||
await Promise.all(insertPromises);
|
||||
console.log('✅ 25 Test-Sektoren erfolgreich eingefügt.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Erstellen der Tabellen:', error);
|
||||
} finally {
|
||||
// Ganz wichtig: Verbindung wieder freigeben
|
||||
client.release();
|
||||
// Den Node-Prozess beenden, damit das Skript nicht in der Konsole hängen bleibt
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Skript direkt aufrufen
|
||||
initDatabase();
|
||||
@@ -0,0 +1,59 @@
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Lobby Backend Server (Einstiegspunkt)
|
||||
// ==============================================================================
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import pool from './db.js';
|
||||
|
||||
import authRoutes from './routes/auth.routes.js';
|
||||
import sectorRoutes from './routes/sector.routes.js';
|
||||
import { pingSectors } from './services/heartbeat.service.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/sectors', sectorRoutes);
|
||||
|
||||
// Unsere bestehende Health-Route
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Void-Genesis Lobby Backend läuft!',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Eine Test-Route für die Datenbank
|
||||
app.get('/api/db-test', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT NOW()');
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'Datenbankverbindung steht!',
|
||||
db_time: result.rows[0].now
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler bei der DB-Abfrage:', err);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'Datenbankfehler',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Server starten
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Void-Genesis Lobby Backend gestartet auf Port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
console.log('💓 Heartbeat-System initialisiert (Intervall: 30s)');
|
||||
pingSectors();
|
||||
setInterval(pingSectors, 30000);
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
import argon2 from 'argon2';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import pool from '../db.js';
|
||||
import { sendEmail } from './email.service.js';
|
||||
|
||||
/**
|
||||
* Generiert einen zufälligen 6-stelligen Code als String
|
||||
*/
|
||||
const generateOTP = () => {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Registriert einen neuen Nutzer, speichert das OTP und löst den E-Mail-Versand aus.
|
||||
*
|
||||
* @param {string} username - Der gewünschte Benutzername
|
||||
* @param {string} email - Die E-Mail-Addresse
|
||||
* @param {string} password - Das Klartext-Passwort
|
||||
* @returns {object} - Die accountId des neuen Nutzers
|
||||
*/
|
||||
export const registerUser = async (username, email, password) => {
|
||||
// Wir holen uns einen dedizierten Client aus dem pool für unsere Transaktion
|
||||
const client = await pool.connect();
|
||||
|
||||
const lowerCaseEmail = email.toLowerCase();
|
||||
|
||||
try {
|
||||
// Transaktion starten: Entweder alles klappt, oder nichts wird gespeichert
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Prüfen, ob der Nutzer oder die E-Mail schon existiert
|
||||
const checkResult = await client.query(`
|
||||
SELECT username, email FROM lobby_accounts
|
||||
WHERE LOWER(username) = LOWER($1) OR email = $2`,
|
||||
[username, lowerCaseEmail]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length > 0) {
|
||||
const existingRecord = checkResult.rows[0];
|
||||
|
||||
if (existingRecord.email === lowerCaseEmail) {
|
||||
const error = new Error('Email already exists');
|
||||
error.code = 'ERR_EMAIL_TAKEN';
|
||||
throw error;
|
||||
} else {
|
||||
const error = new Error('Username already exists');
|
||||
error.code = 'ERR_USERNAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Passwort sicher hashen
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
// Nutzer in de Datenbank eintragen
|
||||
const insertAccount = await client.query(`
|
||||
INSERT INTO lobby_accounts (username, email, password_hash)
|
||||
VALUES ($1, $2, $3) RETURNING id`,
|
||||
[username, lowerCaseEmail, passwordHash]
|
||||
);
|
||||
const accountId = insertAccount.rows[0].id;
|
||||
|
||||
// OTP generieren uns in der auth_otps Tabelle speichern (15min Gültigkeit)
|
||||
const otpCode = generateOTP();
|
||||
await client.query(`
|
||||
INSERT INTO auth_otps (account_id, otp_code, type, expires_at)
|
||||
VALUES ($1, $2, 'register', NOW() + INTERVAL '15 minutes')`,
|
||||
[accountId, otpCode]
|
||||
);
|
||||
|
||||
// Registierungs E-Mail über unseren E-Mail Service versenden
|
||||
const mailHtml = `
|
||||
<h2>Willkommen bei Void-Genesis, ${username}!</h2>
|
||||
<p>Dein Verifizierungscode lautet:</p>
|
||||
<h1 style="color: #00f0ff; letter-spacing: 5px;">${otpCode}</h1>
|
||||
<p>Dieser Code ist 15 Minuten lang gültig.</p>
|
||||
`;
|
||||
await sendEmail(lowerCaseEmail, username, 'Void-Genesis - Aktiviere deinen Account', mailHtml);
|
||||
|
||||
// Wenn wir hier ankommen, lief alles fehlerfrei, wir committen die Daten.
|
||||
await client.query('COMMIT');
|
||||
|
||||
return { accountId };
|
||||
} catch (error) {
|
||||
// bei JEDEM Fehler machen wir einen Rollback. Die Datenbank bleibt sauber.
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
if (error.code === '23505') {
|
||||
if (error.constraint === 'lobby_accounts_username_key') {
|
||||
const customErr = new Error('Username already exists');
|
||||
customErr.code = 'ERR_USERNAME_TAKEN';
|
||||
throw customErr;
|
||||
}
|
||||
if (error.constraint === 'lobby_accounts_email_key') {
|
||||
const customErr = new Error('Email already exists');
|
||||
customErr.code = 'ERR_EMAIL_TAKEN';
|
||||
throw customErr;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert die E-Mail-Adresse eines Nutzers
|
||||
*
|
||||
* @param {string} email - Die E-Mail-Addresse des Nutzers
|
||||
* @param {string} otpCode - Der 6-stellige Code aus der E-Mail
|
||||
* @returns {boolean} - true wenn erfolgreich
|
||||
*/
|
||||
export const verifyEmail = async (email, otpCode) => {
|
||||
const client = await pool.connect();
|
||||
const lowerCaseEmail = email.toLowerCase();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Nutzer anhand der E-Mail suchen un die ID sowie Verifizierungsstatus abfragen
|
||||
const accountResult = await client.query(
|
||||
'SELECT id, is_email_verified FROM lobby_accounts WHERE email = $1',
|
||||
[lowerCaseEmail]
|
||||
);
|
||||
|
||||
if (accountResult.rows.length === 0) {
|
||||
// ERR_USER_NOT_FOUND: Es existiert kein Account mit dieser E-Mail
|
||||
const error = new Error('User not found');
|
||||
error.code = 'ERR_USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
// Prüfen, ob der Nutzer nicht ohnehin schon verifiziert ist
|
||||
if (account.is_email_verified) {
|
||||
// ERR_ALREADY_VERIFIED: Diese E-Mail-Addresse wurde bereits bestätigt
|
||||
const error = new Error('Already verified');
|
||||
error.code = 'ERR_ALREADY_VERIFIED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Den passenden OTP-Code in der Datenbank suchen
|
||||
const otpResult = await client.query(`
|
||||
SELECT id, expires_at FROM auth_otps
|
||||
WHERE account_id = $1 AND otp_code = $2 AND type = 'register'`,
|
||||
[account.id, otpCode]
|
||||
);
|
||||
|
||||
if (otpResult.rows.length === 0) {
|
||||
// ERR_INVALID_OTP: Der eingegebene Code ist falsch.
|
||||
const error = new Error('Invalid OTP');
|
||||
error.code = 'ERR_INVALID_OTP';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const otpRecord = otpResult.rows[0];
|
||||
|
||||
// Prüfen, ob der Cide bereits abgelaufen ist (älter als 15 Minuten)
|
||||
if (new Date() > new Date(otpRecord.expires_at)) {
|
||||
// ERR_OTP_EXPIRED: Der Code ist abgelaufen. Bitte fordere einen neuen an.
|
||||
const error = new Error('OTP expired');
|
||||
error.code = 'ERR_OTP_EXPIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Account als verifiziert markieren
|
||||
await client.query(
|
||||
'UPDATE lobby_accounts SET is_email_verified = true WHERE id = $1',
|
||||
[account.id]
|
||||
);
|
||||
|
||||
// Den genutzten OTP-Code aus der Datenbank löschen (Sicherheit und Sauberkeit)
|
||||
await client.query(
|
||||
'DELETE FROM auth_otps WHERE id = $1',
|
||||
[otpRecord.id]
|
||||
);
|
||||
|
||||
// Alles lief perfekt, Änderungen speichern
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Loggt einen Nutzer ein, verifiziert das Passwort uns erstellt JWT-Tokens
|
||||
*
|
||||
* @param {string} identifier - Kann der Benutzername oder die E-Mail-Address sein
|
||||
* @param {string} password - Das eingegebene Klartext-Passwort
|
||||
* @returns {object} - Enthält accessToken, refreshToken und grundlegende Nutzerdaten
|
||||
*/
|
||||
export const loginUser = async (identifier, password) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Nutzer suchen
|
||||
const accountResult = await client.query(`
|
||||
SELECT id, username, email, password_hash, is_email_verified, is_admin, res_premium
|
||||
FROM lobby_accounts
|
||||
WHERE LOWER(username) = LOWER($1) OR email = LOWER($1)`,
|
||||
[identifier]
|
||||
);
|
||||
|
||||
// Wenn kein Nutzer gefunden wurde
|
||||
if (accountResult.rows.length === 0) {
|
||||
// ERR_INVALID_CREDENTIALS: Benutzername/E-Mail oder Passwort falsch
|
||||
const error = new Error('Invalid credentials');
|
||||
error.code = 'ERR_INVALID_CREDENTIALS';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
// Prüfen, ob die Email bereits bestätigt wurde
|
||||
if (!account.is_email_verified) {
|
||||
// ERR_EMAIL_NOT_VERIFIED: Bitte bestätige zuerst deine E-Mail-Addresse
|
||||
const error = new Error('Email not verified');
|
||||
error.code = 'ERR_EMAIL_NOT_VERIFIED';
|
||||
error.email = account.email;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Passwort vergleichen (argon2)
|
||||
const isPasswordValid = await argon2.verify(account.password_hash, password);
|
||||
if (!isPasswordValid) {
|
||||
const error = new Error('Invalied credentials');
|
||||
error.code = 'ERR_INVALID_CREDENTIALS';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// JWT-Tokens geneieren
|
||||
// Das Access-Token speichert die wichtigsten Infos für schnelle Abfragen
|
||||
const tokenPayload = {
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
isAdmin: account.is_admin
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
tokenPayload,
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ accountId: account.id },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
// Refresh-Token in der Datenbank speichern (Vorbereitung für das Single-Session System)
|
||||
await client.query(`
|
||||
INSERT INTO refresh_tokens (account_id, token, expires_at)
|
||||
VALUES ($1, $2, NOW() + INTERVAL '30 days')`,
|
||||
[account.id, refreshToken]
|
||||
);
|
||||
|
||||
// Erfolgreiche Rückgabe der Daten
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: account.id,
|
||||
username: account.username,
|
||||
email: account.email,
|
||||
res_premium: account.res_premium
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prüft ein Refresh-Token und generiert ein neues, aktuelles Access-Token
|
||||
*
|
||||
* @param {string} refreshToken - Das langlebige Refresh-Token des Nutzers.
|
||||
* @returns {object} - Enthält das neue accessToken
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// JWT-Signatur und Ablaufdatum des Refresh-Tokens kryptografisch verifizieren
|
||||
// Wenn das Token manipuliert oder abgelaufen ist, wirft jwt.verify sofort einen Fehler.
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const { accountId } = payload;
|
||||
|
||||
// Sicherheits-Check: Existiert diese Token noch in unserer Datenbank?
|
||||
// Wenn sich ein zweites Gerät eingeloggt hat, wurden die altenm Tokens gelöscht.
|
||||
// Dieser Check schlägt dasnn fehl und blockiert den unerlaubten Zugriff!
|
||||
const tokenResult = await client.query(
|
||||
'SELECT id FROM refresh_tokens WHERE account_id = $1 AND token = $2',
|
||||
[accountId, refreshToken]
|
||||
);
|
||||
|
||||
if (tokenResult.rows.length === 0) {
|
||||
const error = new Error('Refresh token was revoked or does not exist');
|
||||
error.code = 'ERR_UNAUTHORIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Aktuelle Benutzerdaten frosch aus der Datenbank abfragen
|
||||
// Das behebt das "Stale Data" Problem. Falls der Admin Status geändert wurde,
|
||||
// steht er im neuen Token sofort korrekt drin, ohne die DB bei jedem API.Call zu belasten
|
||||
const accountResult = await client.query(
|
||||
'SELECT id, username, email, is_admin, res_premium FROM lobby_accounts WHERE id = $1',
|
||||
[accountId]
|
||||
);
|
||||
|
||||
if (accountResult.rows.length === 0) {
|
||||
const error = new Error('Account no longer exists');
|
||||
error.code = 'ERR_UNAUTHORIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
// Neues kurzlebiges Access-Token erstellen (wieder für 15 min)
|
||||
const tokenPayload = {
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
isAdmin: account.is_admin
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
tokenPayload,
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
user: {
|
||||
id: account.id,
|
||||
username: account.username,
|
||||
email: account.email,
|
||||
res_premium: account.res_premium
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Abfangen von JWT-spezifischen Fehlern (z.B. Token abgelaufen oder Signatur falsch)
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError' || error.name === 'SyntaxError') {
|
||||
const customErr = new Error('Invalid, expired or malformed token');
|
||||
customErr.code = 'ERR_UNAUTHORIZED';
|
||||
throw customErr;
|
||||
}
|
||||
|
||||
// Alle anderen Fehhler (zB Datenbank Fehler) weiterwerfen
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet einen neuen OTP-Code an den Nutzer, falls der alte abgelaufen ist oder nicht ankam.
|
||||
* Vorherige, ungenutzte Codes werdeb zur Sicherheit invalidiert (gelöscht)
|
||||
*
|
||||
* @param {string} email - Die E-Mail Addresse des Nutzers
|
||||
* @returns {boolean} - true, wenn die E-Mail erfolgreich versendet wurde.
|
||||
*/
|
||||
export const resendOTP = async (email) => {
|
||||
const client = await pool.connect();
|
||||
const lowerCaseEmail = email.toLowerCase();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Prüfen, ob der Nutzer überhaupt existiert
|
||||
const accountResult = await client.query(
|
||||
'SELECT id, username, is_email_verified FROM lobby_accounts WHERE email = $1',
|
||||
[lowerCaseEmail]
|
||||
);
|
||||
|
||||
if (accountResult.rows.length === 0) {
|
||||
// Aus Sicherheitsgründen verraten wir dem Frontend nicht zwingend,
|
||||
// dass die E-Mail nicht existieren, aber für unser internes Error-Handling
|
||||
// werfen wir einen spezifischen Fehler.
|
||||
const error = new Error('User nor found');
|
||||
error.code = 'ERR:USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
// Prüfen, ob der Account nicht vielleicht schon verifiziert ist
|
||||
// Wenn ja, macht ein neuer Code keinen Sinn.
|
||||
if (account.is_email_verified) {
|
||||
const error = new Error('Already verified');
|
||||
error.code = 'ERR_ALREADY_VERIFIED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lastOtpResult = await client.query(`
|
||||
SELECT created_at FROM auth_otps WHERE account_id = $1 AND type = 'register' ORDER BY created_at DESC LIMIT 1`,
|
||||
[account.id]
|
||||
);
|
||||
|
||||
if (lastOtpResult.rows.length > 0) {
|
||||
const lastCreatedTime = new Date(lastOtpResult.rows[0].created_at).getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
const timeDifference = currentTime - lastCreatedTime;
|
||||
|
||||
if (timeDifference < 120000) {
|
||||
const error = new Error('Cooldown active');
|
||||
error.code = 'ERR_COOLDOWN_ACTIVE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Zur Sichherheit alle alten, noch aktiven Registierungs-OTPs dieses Nutzers löschen.
|
||||
// So verhindern wir, dass alte Codes in Postfächern noch funktionieren und minimieren
|
||||
// Spam-Risiken.
|
||||
await client.query(
|
||||
`DELETE FROM auth_otps WHERE account_id = $1 AND type = 'register'`,
|
||||
[account.id]
|
||||
);
|
||||
|
||||
// Einen frischen, 6-stelligen Cide generieren
|
||||
const newOtpCode = generateOTP();
|
||||
|
||||
// Den neuen Code in die Datenbank eintragen
|
||||
await client.query(`
|
||||
INSERT INTO auth_otps (account_id, otp_code, type, expires_at)
|
||||
VALUES ($1, $2, 'register', NOW() + INTERVAL '15 minutes')`,
|
||||
[account.id, newOtpCode]
|
||||
);
|
||||
|
||||
// 6. Die neue E-Mail zusammenbauen und versenden
|
||||
const mailHtml = `
|
||||
<h2>Hallo ${account.username}, hier ist dein neuer Code!</h2>
|
||||
<p>Du hast einen neuen Verifizierungscode für Void-Genesis angefordert:</p>
|
||||
<h1 style="color: #00f0ff; letter-spacing: 5px;">${newOtpCode}</h1>
|
||||
<p>Dieser Code ist ab jetzt wieder 15 Minuten lang gültig.</p>
|
||||
`;
|
||||
|
||||
await sendEmail(lowerCaseEmail, account.username, 'Void-Genesis - Dein neue Code', mailHtml);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Startet den Prozess zum Zurücksetzten des Passworts.
|
||||
* Generiert einen speziellen OTP-Code und sendet ihn per E-Mail.
|
||||
*
|
||||
* @param {string} email - Die Email-Adresse des Nutzers
|
||||
* @returns {boolean} - true, wenn die E-Mail erfolgreich versendet wurde.
|
||||
*/
|
||||
export const requestPasswordReset = async (email) => {
|
||||
const client = await pool.connect();
|
||||
const lowerCaseEmail = email.toLowerCase();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const accountResult = await client.query(
|
||||
'SELECT id, username FROM lobby_accounts WHERE email = $1',
|
||||
[lowerCaseEmail]
|
||||
);
|
||||
|
||||
if (accountResult.rows.length === 0) {
|
||||
const error = new Error('User not found');
|
||||
error.code = 'ERR_USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lastOtpResult = await client.query(`
|
||||
SELECT created_at FROM auth_otps WHERE account_id = $1 AND type = 'password_reset' ORDER BY created_at DESC LIMIT 1`,
|
||||
[accountResult.id]
|
||||
);
|
||||
|
||||
if (lastOtpResult.rows.length > 0) {
|
||||
const lastCreatedTime = new Date(lastOtpResult.rows[0].created_at).getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
const timeDifference = currentTime - lastCreatedTime;
|
||||
|
||||
if (timeDifference < 120000) {
|
||||
const error = new Error('Cooldown active');
|
||||
error.code = 'ERR_COOLDOWN_ACTIVE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
await client.query(
|
||||
`DELETE FROM auth_otps WHERE account_id = $1 AND type = 'password_reset'`,
|
||||
[account.id]
|
||||
);
|
||||
|
||||
const resetCode = generateOTP();
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO auth_otps (account_id, otp_code, type, expires_at)
|
||||
VALUES ($1, $2, 'password_reset', NOW() + INTERVAL '15 minutes')`,
|
||||
[account.id, resetCode]
|
||||
);
|
||||
|
||||
const mailHtml = `
|
||||
<h2>Hallo ${account.username},</h2>
|
||||
<p>Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.</p>
|
||||
<p>Dein Code lautet:</p>
|
||||
<h1 style="color: #00f0ff; letter-spacing: 5px;">${resetCode}</h1>
|
||||
<p>Dieser Code ist 15 Minuten lang gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
|
||||
`;
|
||||
|
||||
await sendEmail(lowerCaseEmail, account.username, 'Void-Genesis - Passwort zurücksetzen', mailHtml);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Setzt das neue Passwort, soffern der OTP-Code korrekt und nicht abgelaufen ist
|
||||
*
|
||||
* @param {string} email - Die E-Mail Addresse des Nutzers
|
||||
* @param {string} otpCode - Der 6-stellige Code aus der E-Mail
|
||||
* @param {string} newPassword - das neue Klartext passwort
|
||||
* @returns {boolean} - true wenn das Passwort erfolgreich geändert wurde
|
||||
*/
|
||||
export const resetPassword = async (email, otpCode, newPassword) => {
|
||||
const client = await pool.connect();
|
||||
const lowerCaseEmail = email.toLowerCase();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const accountResult = await client.query(
|
||||
'SELECT id FROM lobby_accounts WHERE email = $1',
|
||||
[lowerCaseEmail]
|
||||
);
|
||||
|
||||
if (accountResult.rows.length === 0) {
|
||||
const error = new Error('User not found');
|
||||
error.code = 'ERR_USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = accountResult.rows[0];
|
||||
|
||||
const otpResult = await client.query(`
|
||||
SELECT id, expires_at FROM auth_otps
|
||||
WHERE account_id = $1 AND otp_code = $2 AND type = 'password_reset'`,
|
||||
[account.id, otpCode]
|
||||
);
|
||||
|
||||
if (otpResult.rows.length === 0) {
|
||||
const error = new Error('Invalid OTP');
|
||||
error.code = 'ERR_INVALID_OTP';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const otpRecord = otpResult.rows[0];
|
||||
|
||||
if (new Date() > new Date(otpRecord.expires_at)) {
|
||||
const error = new Error('OTP expired');
|
||||
error.code = 'ERR_OTP_EXPIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
|
||||
await client.query(
|
||||
'UPDATE lobby_accounts SET password_hash = $1 WHERE id = $2',
|
||||
[passwordHash, account.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
'DELETE FROM refresh_tokens WHERE account_id = $1',
|
||||
[account.id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Sendet eine E-Mail über die Brevo REST API.
|
||||
*
|
||||
* @param {string} toEmail - Die Ziel E-Mail Adresse
|
||||
* @param {string} toName - Der Benutzername des Empfängers
|
||||
* @param {string} subject - Der Betreff der E-Mail
|
||||
* @param {string} htmlContent - Der HTML-Inhalt der E-Mail
|
||||
* @returns {boolean} - true wenn erfolgreich, wirft einen Fehler wenn nicht.
|
||||
*/
|
||||
export const sendEmail = async (toEmail, toName, subject, htmlContent) => {
|
||||
const mailMode = process.env.MAIL_MODE || 'development';
|
||||
|
||||
// Im Development-Modus loggen wir nur, um das API-Limit nicht zu belasten
|
||||
if (mailMode === 'development') {
|
||||
console.log(`\n[DEV-MAIL-SIMULATION] An: ${toEmail}`);
|
||||
console.log(`[DEV-MAIL-SIMULATION] Betreff: ${subject}`);
|
||||
console.log(`[DEV-MAIL-SIMULATION] Inhalt:\n${htmlContent}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Echter Versand über Brevo
|
||||
try {
|
||||
const response = await fetch('https://api.brevo.com/v3/smtp/email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'api-key': process.env.BREVO_API_KEY,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sender: {
|
||||
name: process.env.BREVO_SENDER_NAME,
|
||||
email: process.env.BREVO_SENDER_EMAIL
|
||||
},
|
||||
to: [{
|
||||
email: toEmail,
|
||||
name: toName
|
||||
}],
|
||||
subject: subject,
|
||||
htmlContent: htmlContent
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Brevo API Fehler-Details:', errorData);
|
||||
throw new Error('Fehler bei der Kommunikation mit dem E-Mail-Provider.');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler im email.service:', error);
|
||||
throw error; // Den Fehler werfen wir weiter, damit der Aufrufer ihn behandeln kann.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// === File: lobby-backend/src/services/heartbeat.service.js ===
|
||||
|
||||
import pool from '../db.js';
|
||||
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Heartbeat Service
|
||||
// ==============================================================================
|
||||
// Dieser Service pingt alle registrierten Sektoren regelmäßig an.
|
||||
|
||||
export const pingSectors = async () => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const result = await client.query('SELECT id, name, api_url FROM sectors WHERE api_url IS NOT NULL');
|
||||
const sectors = result.rows;
|
||||
|
||||
for (const sector of sectors) {
|
||||
try {
|
||||
// Wir nutzen einen AbortController, um blockierende Anfragen
|
||||
// nach 3 Sekunden hart abzubrechen (Timeout).
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
// Nativer, sauberer fetch-Aufruf (Dank NODE_OPTIONS in Docker nun bugfrei)
|
||||
const response = await fetch(sector.api_url + '/api/health', {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
// Sektor antwortet korrekt -> Status auf online setzen
|
||||
await client.query(
|
||||
"UPDATE sectors SET status = 'online' WHERE id = $1",
|
||||
[sector.id]
|
||||
);
|
||||
|
||||
// Log-Ausgabe nur für unseren aktiven Entwicklungs-Sektor,
|
||||
// um die Konsole sauber zu halten.
|
||||
if (sector.name === 'Development') {
|
||||
//console.log(`[Heartbeat] ✅ Sektor '${sector.name}' ist online!`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Server erreichbar, liefert aber keinen 2xx Status
|
||||
await setSectorOffline(client, sector.id);
|
||||
}
|
||||
} catch (error) {
|
||||
// Sektor gar nicht erreichbar (Container down, Netzwerkfehler, Timeout)
|
||||
if (sector.name === 'Development') {
|
||||
console.error(`[Heartbeat] ❌ Sektor '${sector.name}' nicht erreichbar:`, error.message);
|
||||
}
|
||||
await setSectorOffline(client, sector.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Heartbeat] Kritischer Fehler bei der Datenbankabfrage:', error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hilfsfunktion, um den Status eines Sektors auf 'offline' zu setzen.
|
||||
*/
|
||||
const setSectorOffline = async (client, sectorId) => {
|
||||
await client.query(
|
||||
"UPDATE sectors SET status = 'offline' WHERE id = $1",
|
||||
[sectorId]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import pool from '../db.js';
|
||||
|
||||
/**
|
||||
* Ruft die Sektorlisten für einen bestimmten Account ab.
|
||||
* Unterteilt das Ergebnis in bereits beigetretene Sektoren ("Meine Sektoren")
|
||||
* und noch verfügbare Sektoren ("Neue Sektoren").
|
||||
*
|
||||
* @param {string} accountId - Die UUID des aktuell eingeloggten Spielers
|
||||
*
|
||||
* @returns {Promise<Object>} - Ein Objekt mit den Arrays 'mySectors' und 'newSectors'
|
||||
*/
|
||||
export const getSectorsList = async (accountId) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const mySectorsResult = await client.query(`
|
||||
SELECT
|
||||
s.id, s.name, s.status, s.speed_fleet, s.speed_economy,
|
||||
las.local_username, las.gender,
|
||||
las.created_at as joined_at,
|
||||
las.updated_at as last_played
|
||||
FROM sectors s
|
||||
JOIN lobby_accounts_sectors las ON s.id = las.sector_id
|
||||
WHERE las.lobby_account_id = $1
|
||||
ORDER BY las.updated_at DESC
|
||||
`, [accountId]);
|
||||
|
||||
const newSectorsResult = await client.query(`
|
||||
SELECT
|
||||
id, name, status, speed_fleet, speed_economy
|
||||
FROM sectors
|
||||
WHERE id NOT IN (
|
||||
SELECT sector_id
|
||||
FROM lobby_accounts_sectors
|
||||
WHERE lobby_account_id = $1
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
`, [accountId]);
|
||||
|
||||
const mapSectorData = (sector) => ({
|
||||
...sector,
|
||||
online_players: Math.floor(Math.random() * 150) + 10,
|
||||
planets_count: (Math.floor(Math.random() * 150) + 10) * 12
|
||||
});
|
||||
|
||||
return {
|
||||
mySectors: mySectorsResult.rows.map(mapSectorData),
|
||||
newSectors: newSectorsResult.rows.map(mapSectorData)
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SectorService] Fehler beim Auslesen der Sektordaten:', error);
|
||||
throw error;
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trägt einen Spieler in einen neuen Sektor ein.
|
||||
* Führt die eigentliche Datenbankoperation durch und wirft spezifische
|
||||
* Fehler-Objekte, falls Constraints (UNIQUE-Regeln) verletzt werden.
|
||||
*
|
||||
* @param {string} accountId - Die UUID des Nutzers
|
||||
* @param {string} sectorId - Die UUID des Sektors
|
||||
* @param {string} localUsername - Der Commander-Name für diesen Sektor
|
||||
* @param {string} gender - Das gewählte Geschlecht ('male' oder 'female')
|
||||
*
|
||||
* @returns {boolean} - true bei Erfolg
|
||||
*/
|
||||
export const joinSector = async (accountId, sectorId, localUsername, gender) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Wir versuchen, den Datensatz direkt einzufügen.
|
||||
await client.query(`
|
||||
INSERT INTO lobby_accounts_sectors (lobby_account_id, sector_id, local_username, gender)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, [accountId, sectorId, localUsername, gender]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
// Fehlercode 23505 = Postgres "unique_violation"
|
||||
if (error.code === '23505') {
|
||||
|
||||
if (error.constraint === 'lobby_accounts_sectors_lobby_account_id_sector_id_key') {
|
||||
const customErr = new Error('Already in sector');
|
||||
customErr.code = 'ERR_ALREADY_IN_SECTOR';
|
||||
throw customErr;
|
||||
}
|
||||
|
||||
if (error.constraint === 'lobby_accounts_sectors_sector_id_local_username_key') {
|
||||
const customErr = new Error('Local username taken');
|
||||
customErr.code = 'ERR_LOCAL_USERNAME_TAKEN';
|
||||
throw customErr;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user