All checks were successful
Build & Push Docker Image / build (push) Successful in 1m9s
364 lines
13 KiB
JavaScript
364 lines
13 KiB
JavaScript
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: 'All fields are required' });
|
||
}
|
||
|
||
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 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: 'Verification email has been sent' });
|
||
}
|
||
|
||
// 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: '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, 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 {}
|
||
}
|
||
|
||
await sendVerificationEmail(email.toLowerCase(), user.name, verifyUrl, appName);
|
||
|
||
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: 'Email could not be sent' });
|
||
}
|
||
});
|
||
|
||
// 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, 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' });
|
||
}
|
||
}
|
||
|
||
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: '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, 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 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, 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 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;
|