All checks were successful
Build & Push Docker Image / build (push) Successful in 6m35s
674 lines
26 KiB
JavaScript
674 lines
26 KiB
JavaScript
import { Router } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import jwt from 'jsonwebtoken';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { rateLimit } from 'express-rate-limit';
|
|
import { RedisStore } from 'rate-limit-redis';
|
|
import { getDb } from '../config/database.js';
|
|
import redis from '../config/redis.js';
|
|
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
|
|
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
|
|
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
|
|
import { log } from '../config/logger.js';
|
|
|
|
if (!process.env.JWT_SECRET) {
|
|
log.auth.error('FATAL: JWT_SECRET environment variable is not set.');
|
|
process.exit(1);
|
|
}
|
|
const JWT_SECRET = process.env.JWT_SECRET;
|
|
|
|
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
|
function makeRedisStore(prefix) {
|
|
try {
|
|
return new RedisStore({
|
|
sendCommand: (...args) => redis.call(...args),
|
|
prefix,
|
|
});
|
|
} catch {
|
|
return undefined; // falls back to in-memory if Redis unavailable
|
|
}
|
|
}
|
|
|
|
// ── Validation helpers ─────────────────────────────────────────────────────
|
|
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
|
|
|
// Simple format check for theme/language IDs (actual validation happens on the frontend)
|
|
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
|
|
|
|
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
|
|
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
|
|
|
|
const MIN_PASSWORD_LENGTH = 8;
|
|
|
|
// ── Rate Limiters ────────────────────────────────────────────────────────────
|
|
const loginLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 20,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
|
|
store: makeRedisStore('rl:login:'),
|
|
});
|
|
|
|
const registerLimiter = rateLimit({
|
|
windowMs: 60 * 60 * 1000, // 1 hour
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many registration attempts. Please try again later.' },
|
|
store: makeRedisStore('rl:register:'),
|
|
});
|
|
|
|
const profileLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 30,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many profile update attempts. Please try again later.' },
|
|
store: makeRedisStore('rl:profile:'),
|
|
});
|
|
|
|
const passwordLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many password change attempts. Please try again later.' },
|
|
store: makeRedisStore('rl:password:'),
|
|
});
|
|
|
|
const avatarLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 20,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many avatar upload attempts. Please try again later.' },
|
|
store: makeRedisStore('rl:avatar:'),
|
|
});
|
|
|
|
// S1: rate limit resend-verification to prevent SMTP abuse
|
|
const resendVerificationLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 5,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many requests. Please try again later.' },
|
|
store: makeRedisStore('rl:resend:'),
|
|
});
|
|
|
|
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', registerLimiter, async (req, res) => {
|
|
try {
|
|
const { username, display_name, email, password, invite_token } = req.body;
|
|
|
|
// Check registration mode
|
|
const db = getDb();
|
|
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
|
|
const registrationMode = regModeSetting?.value || 'open';
|
|
|
|
let validatedInvite = null;
|
|
if (registrationMode === 'invite') {
|
|
if (!invite_token) {
|
|
return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' });
|
|
}
|
|
// Validate the invite token
|
|
validatedInvite = await db.get(
|
|
'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
|
|
[invite_token]
|
|
);
|
|
if (!validatedInvite) {
|
|
return res.status(403).json({ error: 'Invalid or expired invitation link.' });
|
|
}
|
|
}
|
|
|
|
if (!username || !display_name || !email || !password) {
|
|
return res.status(400).json({ error: 'All fields are required' });
|
|
}
|
|
|
|
// L3: display_name length limit (consistent with profile update)
|
|
if (display_name.length > 100) {
|
|
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
|
|
}
|
|
|
|
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 - (3-30 chars)' });
|
|
}
|
|
|
|
// M1: email format
|
|
if (!EMAIL_RE.test(email)) {
|
|
return res.status(400).json({ error: 'Invalid email address' });
|
|
}
|
|
|
|
// M4: minimum password length
|
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
|
}
|
|
|
|
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 = await bcrypt.hash(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]
|
|
);
|
|
|
|
// Mark invite as used if applicable
|
|
if (validatedInvite) {
|
|
const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
|
if (newUser) {
|
|
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]);
|
|
}
|
|
}
|
|
|
|
// Build verification URL
|
|
const baseUrl = getBaseUrl(req);
|
|
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, 'en');
|
|
} catch (mailErr) {
|
|
log.auth.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]
|
|
);
|
|
|
|
// Mark invite as used if applicable
|
|
if (validatedInvite) {
|
|
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]);
|
|
}
|
|
|
|
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) {
|
|
log.auth.error(`Register error: ${err.message}`);
|
|
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) {
|
|
log.auth.error(`Verify email error: ${err.message}`);
|
|
res.status(500).json({ error: 'Verification failed' });
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/resend-verification
|
|
router.post('/resend-verification', resendVerificationLimiter, 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, language, email_verified, verification_resend_at 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.' });
|
|
}
|
|
|
|
// Server-side 60s rate limit
|
|
if (user.verification_resend_at) {
|
|
const secondsAgo = (Date.now() - new Date(user.verification_resend_at).getTime()) / 1000;
|
|
if (secondsAgo < 60) {
|
|
const waitSeconds = Math.ceil(60 - secondsAgo);
|
|
return res.status(429).json({ error: `Please wait ${waitSeconds} seconds before requesting another email.`, waitSeconds });
|
|
}
|
|
}
|
|
|
|
const verificationToken = uuidv4();
|
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
const now = new Date().toISOString();
|
|
|
|
await db.run(
|
|
'UPDATE users SET verification_token = ?, verification_token_expires = ?, verification_resend_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
[verificationToken, expires, now, user.id]
|
|
);
|
|
|
|
const baseUrl = getBaseUrl(req);
|
|
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, user.language || 'en');
|
|
} catch (mailErr) {
|
|
log.auth.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) {
|
|
log.auth.error(`Resend verification error: ${err.message}`);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/login
|
|
router.post('/login', loginLimiter, async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'Email and password are required' });
|
|
}
|
|
|
|
// M1: basic email format check - invalid format can never match a real account
|
|
if (!EMAIL_RE.test(email)) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
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, verification_token, verification_token_expires, verification_resend_at, ...safeUser } = user;
|
|
|
|
res.json({ token, user: safeUser });
|
|
} catch (err) {
|
|
log.auth.error(`Login error: ${err.message}`);
|
|
res.status(500).json({ error: 'Login failed' });
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
|
|
router.post('/logout', authenticateToken, async (req, res) => {
|
|
try {
|
|
const authHeader = req.headers.authorization;
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
const decoded = jwt.decode(token);
|
|
|
|
if (decoded?.jti && decoded?.exp) {
|
|
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
|
if (ttl > 0) {
|
|
try {
|
|
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1');
|
|
} catch (redisErr) {
|
|
log.auth.warn(`Redis blacklist write failed: ${redisErr.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── RP-Initiated Logout for OIDC/Keycloak users ──────────────────────
|
|
let keycloakLogoutUrl = null;
|
|
if (req.user.oauth_provider === 'oidc') {
|
|
try {
|
|
const config = await getOAuthConfig();
|
|
if (config) {
|
|
const oidc = await discoverOIDC(config.issuer);
|
|
if (oidc.end_session_endpoint) {
|
|
const idToken = await redis.get(`oidc:id_token:${req.user.id}`);
|
|
await redis.del(`oidc:id_token:${req.user.id}`);
|
|
const baseUrl = getBaseUrl(req);
|
|
const params = new URLSearchParams({
|
|
post_logout_redirect_uri: `${baseUrl}/`,
|
|
client_id: config.clientId,
|
|
});
|
|
if (idToken) params.set('id_token_hint', idToken);
|
|
keycloakLogoutUrl = `${oidc.end_session_endpoint}?${params.toString()}`;
|
|
}
|
|
}
|
|
} catch (oidcErr) {
|
|
log.auth.warn(`Could not build Keycloak logout URL: ${oidcErr.message}`);
|
|
}
|
|
}
|
|
|
|
res.json({ message: 'Logged out successfully', keycloakLogoutUrl });
|
|
} catch (err) {
|
|
log.auth.error(`Logout error: ${err.message}`);
|
|
res.status(500).json({ error: 'Logout failed' });
|
|
}
|
|
});
|
|
|
|
// GET /api/auth/me
|
|
router.get('/me', authenticateToken, (req, res) => {
|
|
res.json({ user: req.user });
|
|
});
|
|
|
|
// PUT /api/auth/profile
|
|
router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
|
|
try {
|
|
const { name, display_name, email, theme, language, avatar_color } = req.body;
|
|
const db = getDb();
|
|
|
|
// M1: validate new email format
|
|
if (email && !EMAIL_RE.test(email)) {
|
|
return res.status(400).json({ error: 'Invalid email address' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
}
|
|
|
|
// M2: display_name length limit
|
|
if (display_name !== undefined && display_name !== null && display_name.length > 100) {
|
|
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
|
|
}
|
|
|
|
// Theme and language: basic format validation (frontend handles actual ID matching)
|
|
if (theme !== undefined && theme !== null && (typeof theme !== 'string' || !SAFE_ID_RE.test(theme))) {
|
|
return res.status(400).json({ error: 'Invalid theme' });
|
|
}
|
|
if (language !== undefined && language !== null && (typeof language !== 'string' || !SAFE_ID_RE.test(language))) {
|
|
return res.status(400).json({ error: 'Invalid language' });
|
|
}
|
|
|
|
// L5: validate avatar_color format/length
|
|
if (avatar_color !== undefined && avatar_color !== null) {
|
|
if (typeof avatar_color !== 'string' || !SAFE_COLOR_RE.test(avatar_color)) {
|
|
return res.status(400).json({ error: 'Invalid avatar color' });
|
|
}
|
|
}
|
|
|
|
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 - (3-30 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) {
|
|
log.auth.error(`Profile update error: ${err.message}`);
|
|
res.status(500).json({ error: 'Profile could not be updated' });
|
|
}
|
|
});
|
|
|
|
// PUT /api/auth/password
|
|
router.put('/password', authenticateToken, passwordLimiter, async (req, res) => {
|
|
try {
|
|
const { currentPassword, newPassword } = req.body;
|
|
|
|
// M6: guard against missing/non-string body values
|
|
if (!currentPassword || !newPassword) {
|
|
return res.status(400).json({ error: 'currentPassword and newPassword are required' });
|
|
}
|
|
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
|
return res.status(400).json({ error: 'Invalid input' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
// M4: minimum password length
|
|
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
|
}
|
|
|
|
const hash = await bcrypt.hash(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) {
|
|
log.auth.error(`Password change error: ${err.message}`);
|
|
res.status(500).json({ error: 'Password could not be changed' });
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/avatar - Upload avatar image
|
|
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
|
|
try {
|
|
// Validate file content by checking magic bytes (file signatures)
|
|
const contentType = req.headers['content-type'];
|
|
if (!contentType || !contentType.startsWith('image/')) {
|
|
return res.status(400).json({ error: 'Only image files are allowed' });
|
|
}
|
|
|
|
// M15: stream-level size limit - abort as soon as 2 MB is exceeded
|
|
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
|
|
const buffer = await new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
let totalSize = 0;
|
|
req.on('data', chunk => {
|
|
totalSize += chunk.length;
|
|
if (totalSize > MAX_AVATAR_SIZE) {
|
|
req.destroy();
|
|
return reject(new Error('LIMIT_EXCEEDED'));
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
req.on('error', reject);
|
|
}).catch(err => {
|
|
if (err.message === 'LIMIT_EXCEEDED') return null;
|
|
throw err;
|
|
});
|
|
|
|
if (!buffer) {
|
|
return res.status(400).json({ error: 'Image must not exceed 2MB' });
|
|
}
|
|
|
|
// Validate magic bytes to prevent Content-Type spoofing
|
|
const magicBytes = buffer.slice(0, 8);
|
|
const isJPEG = magicBytes[0] === 0xFF && magicBytes[1] === 0xD8 && magicBytes[2] === 0xFF;
|
|
const isPNG = magicBytes[0] === 0x89 && magicBytes[1] === 0x50 && magicBytes[2] === 0x4E && magicBytes[3] === 0x47;
|
|
const isGIF = magicBytes[0] === 0x47 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46;
|
|
const isWEBP = magicBytes[0] === 0x52 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46 && magicBytes[3] === 0x46
|
|
&& buffer.length > 11 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
|
|
if (!isJPEG && !isPNG && !isGIF && !isWEBP) {
|
|
return res.status(400).json({ error: 'File content does not match a supported image format (JPEG, PNG, GIF, WebP)' });
|
|
}
|
|
|
|
const ext = isPNG ? 'png' : isGIF ? 'gif' : isWEBP ? '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) {
|
|
// S8: defense-in-depth path traversal check on DB-stored filename
|
|
const oldPath = path.resolve(uploadsDir, current.avatar_image);
|
|
if (oldPath.startsWith(uploadsDir + path.sep) && 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) {
|
|
log.auth.error(`Avatar upload error: ${err.message}`);
|
|
res.status(500).json({ error: 'Avatar could not be uploaded' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/auth/avatar - Remove avatar image
|
|
router.delete('/avatar', authenticateToken, avatarLimiter, 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) {
|
|
// S8: defense-in-depth path traversal check on DB-stored filename
|
|
const oldPath = path.resolve(uploadsDir, current.avatar_image);
|
|
if (oldPath.startsWith(uploadsDir + path.sep) && 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) {
|
|
log.auth.error(`Avatar delete error: ${err.message}`);
|
|
res.status(500).json({ error: 'Avatar could not be removed' });
|
|
}
|
|
});
|
|
|
|
// Escape XML special characters to prevent XSS in SVG text/attribute contexts
|
|
function escapeXml(str) {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// 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();
|
|
|
|
// C1 fix: validate color against a strict allowlist before embedding in SVG attribute
|
|
const rawColor = req.query.color || '';
|
|
const color = SAFE_COLOR_RE.test(rawColor) ? rawColor : generateColorFromName(name);
|
|
|
|
// C2 fix: XML-escape initials before embedding in SVG text node
|
|
const rawInitials = name
|
|
.split(' ')
|
|
.map(n => n[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2) || '?';
|
|
const initials = escapeXml(rawInitials);
|
|
|
|
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="${escapeXml(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) => {
|
|
// H1 fix: resolve the path and ensure it stays inside uploadsDir (prevent path traversal)
|
|
const filepath = path.resolve(uploadsDir, req.params.filename);
|
|
if (!filepath.startsWith(uploadsDir + path.sep)) {
|
|
return res.status(400).json({ error: 'Invalid 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;
|