Files
redlight/server/routes/auth.js
Michelle 8be973a166
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Add mail verification and use .env insteads of environment in compose
2026-02-24 20:35:08 +01:00

364 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js';
import { authenticateToken, generateToken } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
// Ensure uploads directory exists
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const router = Router();
// POST /api/auth/register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
}
const hash = bcrypt.hashSync(password, 12);
// If SMTP is configured, require email verification
if (isMailerConfigured()) {
const verificationToken = uuidv4();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
await db.run(
'INSERT INTO users (name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, 0, ?, ?)',
[name, email.toLowerCase(), hash, verificationToken, expires]
);
// Build verification URL
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
// Load app name from branding settings
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
await sendVerificationEmail(email.toLowerCase(), name, verifyUrl, appName);
return res.status(201).json({ needsVerification: true, message: 'Verifizierungs-E-Mail wurde gesendet' });
}
// No SMTP configured register and login immediately (legacy behaviour)
const result = await db.run(
'INSERT INTO users (name, email, password_hash, email_verified) VALUES (?, ?, ?, 1)',
[name, email.toLowerCase(), hash]
);
const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ token, user });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Registrierung fehlgeschlagen' });
}
});
// GET /api/auth/verify-email?token=...
router.get('/verify-email', async (req, res) => {
try {
const { token } = req.query;
if (!token) {
return res.status(400).json({ error: 'Token fehlt' });
}
const db = getDb();
const user = await db.get(
'SELECT id, verification_token_expires FROM users WHERE verification_token = ? AND email_verified = 0',
[token]
);
if (!user) {
return res.status(400).json({ error: 'Ungültiger oder bereits verwendeter Token' });
}
if (new Date(user.verification_token_expires) < new Date()) {
return res.status(400).json({ error: 'Token ist abgelaufen. Bitte registriere dich erneut.' });
}
await db.run(
'UPDATE users SET email_verified = 1, verification_token = NULL, verification_token_expires = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[user.id]
);
res.json({ verified: true, message: 'E-Mail erfolgreich verifiziert' });
} catch (err) {
console.error('Verify email error:', err);
res.status(500).json({ error: 'Verifizierung fehlgeschlagen' });
}
});
// POST /api/auth/resend-verification
router.post('/resend-verification', async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: 'E-Mail ist erforderlich' });
}
if (!isMailerConfigured()) {
return res.status(400).json({ error: 'SMTP ist nicht konfiguriert' });
}
const db = getDb();
const user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) {
// Don't reveal whether account exists
return res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
}
const verificationToken = uuidv4();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
await db.run(
'UPDATE users SET verification_token = ?, verification_token_expires = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[verificationToken, expires, user.id]
);
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
await sendVerificationEmail(email.toLowerCase(), user.name, verifyUrl, appName);
res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
} catch (err) {
console.error('Resend verification error:', err);
res.status(500).json({ error: 'E-Mail konnte nicht gesendet werden' });
}
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
if (!user.email_verified && isMailerConfigured()) {
return res.status(403).json({ error: 'E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.', needsVerification: true });
}
const token = generateToken(user.id);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Anmeldung fehlgeschlagen' });
}
});
// GET /api/auth/me
router.get('/me', authenticateToken, (req, res) => {
res.json({ user: req.user });
});
// PUT /api/auth/profile
router.put('/profile', authenticateToken, async (req, res) => {
try {
const { name, email, theme, language, avatar_color } = req.body;
const db = getDb();
if (email && email !== req.user.email) {
const existing = await db.get('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
}
}
await db.run(`
UPDATE users SET
name = COALESCE(?, name),
email = COALESCE(?, email),
theme = COALESCE(?, theme),
language = COALESCE(?, language),
avatar_color = COALESCE(?, avatar_color),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Profile update error:', err);
res.status(500).json({ error: 'Profil konnte nicht aktualisiert werden' });
}
});
// PUT /api/auth/password
router.put('/password', authenticateToken, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
const db = getDb();
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'Neues Passwort muss mindestens 6 Zeichen lang sein' });
}
const hash = bcrypt.hashSync(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
res.json({ message: 'Passwort erfolgreich geändert' });
} catch (err) {
console.error('Password change error:', err);
res.status(500).json({ error: 'Passwort konnte nicht geändert werden' });
}
});
// POST /api/auth/avatar - Upload avatar image
router.post('/avatar', authenticateToken, async (req, res) => {
try {
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
// Validate content type
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
return res.status(400).json({ error: 'Nur Bilddateien sind erlaubt' });
}
// Max 2MB
if (buffer.length > 2 * 1024 * 1024) {
return res.status(400).json({ error: 'Bild darf maximal 2MB groß sein' });
}
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg';
const filename = `${req.user.id}_${Date.now()}.${ext}`;
const filepath = path.join(uploadsDir, filename);
// Remove old avatar if exists
const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
fs.writeFileSync(filepath, buffer);
await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar upload error:', err);
res.status(500).json({ error: 'Avatar konnte nicht hochgeladen werden' });
}
});
// DELETE /api/auth/avatar - Remove avatar image
router.delete('/avatar', authenticateToken, async (req, res) => {
try {
const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar delete error:', err);
res.status(500).json({ error: 'Avatar konnte nicht entfernt werden' });
}
});
// GET /api/auth/avatar/initials/:name - Generate SVG avatar from initials (public, BBB fetches this)
router.get('/avatar/initials/:name', (req, res) => {
const name = decodeURIComponent(req.params.name).trim();
const color = req.query.color || generateColorFromName(name);
const initials = name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2) || '?';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="64" fill="${color}"/>
<text x="64" y="64" dy=".35em" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="52" font-weight="bold">${initials}</text>
</svg>`;
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(svg);
});
function generateColorFromName(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 55%, 45%)`;
}
// GET /api/auth/avatar/:filename - Serve avatar image
router.get('/avatar/:filename', (req, res) => {
const filepath = path.join(uploadsDir, req.params.filename);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ error: 'Avatar nicht gefunden' });
}
const ext = path.extname(filepath).slice(1);
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
res.setHeader('Content-Type', mimeMap[ext] || 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=86400');
fs.createReadStream(filepath).pipe(res);
});
export default router;