chore: restrukturierung der microservices in dev/lobby und dev/sector-dev (monorepo)
This commit is contained in:
@@ -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