Files
redlight/server/routes/auth.js
Michelle 4d6a09c3fd
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Improve email verification error handling in registration and resend verification endpoints
2026-02-27 17:21:01 +01:00

396 lines
15 KiB
JavaScript
Raw 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 { username, display_name, email, password } = req.body;
if (!username || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
}
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters long' });
}
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(409).json({ error: 'Email is already in use' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
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, display_name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, ?, 0, ?, ?)',
[username, display_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 {}
}
try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName);
} catch (mailErr) {
console.error('Verification mail failed:', mailErr.message);
// Account is created but email failed — user can resend from login page
return res.status(201).json({ needsVerification: true, emailFailed: true, message: 'Account created but verification email could not be sent. Please try resending.' });
}
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
}
// No SMTP configured register and login immediately (legacy behaviour)
const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[username, display_name, email.toLowerCase(), hash]
);
const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ token, user });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Registration failed' });
}
});
// 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 is missing' });
}
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: 'Invalid or already used token' });
}
if (new Date(user.verification_token_expires) < new Date()) {
return res.status(400).json({ error: 'Token has expired. Please register again.' });
}
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: 'Email verified successfully' });
} catch (err) {
console.error('Verify email error:', err);
res.status(500).json({ error: 'Verification failed' });
}
});
// 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: 'Email is required' });
}
if (!isMailerConfigured()) {
return res.status(400).json({ error: 'SMTP is not configured' });
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) {
// Don't reveal whether account exists
return res.json({ message: 'If an account exists, a new email has been sent.' });
}
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 {}
}
try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName);
} catch (mailErr) {
console.error('Resend verification mail failed:', mailErr.message);
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
}
res.json({ message: 'If an account exists, a new email has been sent.' });
} catch (err) {
console.error('Resend verification error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// 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: 'Email and password are required' });
}
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: 'Invalid credentials' });
}
if (!user.email_verified && isMailerConfigured()) {
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', 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: 'Login failed' });
}
});
// 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, display_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: 'Email is already in use' });
}
}
if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
}
await db.run(`
UPDATE users SET
name = COALESCE(?, name),
display_name = COALESCE(?, display_name),
email = COALESCE(?, email),
theme = COALESCE(?, theme),
language = COALESCE(?, language),
avatar_color = COALESCE(?, avatar_color),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [name, display_name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Profile update error:', err);
res.status(500).json({ error: 'Profile could not be updated' });
}
});
// 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: 'Current password is incorrect' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'New password must be at least 6 characters long' });
}
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: 'Password changed successfully' });
} catch (err) {
console.error('Password change error:', err);
res.status(500).json({ error: 'Password could not be changed' });
}
});
// 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: 'Only image files are allowed' });
}
// Max 2MB
if (buffer.length > 2 * 1024 * 1024) {
return res.status(400).json({ error: 'Image must not exceed 2MB' });
}
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, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified 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 could not be uploaded' });
}
});
// 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, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified 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 could not be removed' });
}
});
// 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 not found' });
}
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;