import { Router, Response } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import sharp from 'sharp'; import { config } from '../config'; import { getDatabase } from '../database'; import { authenticate, AuthRequest } from '../middleware/auth'; import { authLimiter } from '../middleware/rateLimiter'; const router = Router(); const changePasswordSchema = z.object({ currentPassword: z.string().optional(), newPassword: z.string().min(8).max(128), verificationCode: z.string().optional(), }); const usernameSchema = z.object({ username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/), }); const uploadsDir = path.resolve(__dirname, '../../../uploads/avatars'); const storage = multer.memoryStorage(); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed')); } }, }); function parseUserAgent(ua: string): string { if (!ua) return 'Unknown'; if (ua.includes('Chrome') && ua.includes('Windows')) return 'Chrome (Windows)'; if (ua.includes('Chrome') && ua.includes('Mac')) return 'Chrome (macOS)'; if (ua.includes('Firefox') && ua.includes('Windows')) return 'Firefox (Windows)'; if (ua.includes('Firefox') && ua.includes('Mac')) return 'Firefox (macOS)'; if (ua.includes('Safari') && !ua.includes('Chrome')) return 'Safari (macOS)'; if (ua.includes('Edg')) return 'Edge (Windows)'; if (ua.includes('Mobile') || ua.includes('Android')) return 'Mobile Browser'; return 'Other'; } function isGoogleOnlyUser(passwordHash: string): boolean { return passwordHash === 'google-auth'; } router.post('/change-password', authenticate, authLimiter, async (req: AuthRequest, res: Response) => { try { const { currentPassword, newPassword, verificationCode } = changePasswordSchema.parse(req.body); const db = getDatabase(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId!) as any; if (isGoogleOnlyUser(user.password_hash)) { if (!verificationCode) { // Step 1: send verification code const code = Math.random().toString(36).slice(2, 8).toUpperCase(); const expiresAt = Date.now() + 15 * 60 * 1000; db.prepare('UPDATE users SET verification_token = ?, reset_token_expires = ? WHERE id = ?') .run(code, expiresAt, req.userId); // In production, send this via email. For now, log it. console.log(`[settings] Password-set verification code for ${user.email}: ${code}`); res.json({ step: 'verify', message: 'Verification code sent to your email' }); return; } const stored = db.prepare('SELECT verification_token, reset_token_expires FROM users WHERE id = ?') .get(req.userId!) as any; if (!stored.verification_token || stored.reset_token_expires < Date.now()) { res.status(400).json({ error: 'Verification code expired or not requested' }); return; } if (stored.verification_token !== verificationCode.toUpperCase()) { res.status(400).json({ error: 'Invalid verification code' }); return; } const passwordHash = await bcrypt.hash(newPassword, 10); db.prepare('UPDATE users SET password_hash = ?, verification_token = NULL, reset_token_expires = NULL, updated_at = ? WHERE id = ?') .run(passwordHash, Date.now(), req.userId); res.json({ message: 'Password set successfully' }); } else { if (!currentPassword) { res.status(400).json({ error: 'Current password is required' }); return; } const valid = await bcrypt.compare(currentPassword, user.password_hash); if (!valid) { res.status(401).json({ error: 'Current password is incorrect' }); return; } const passwordHash = await bcrypt.hash(newPassword, 10); db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?') .run(passwordHash, Date.now(), req.userId); res.json({ message: 'Password changed successfully' }); } } catch (error: any) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Invalid input', details: error.errors }); return; } console.error('Change password error:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.put('/username', authenticate, async (req: AuthRequest, res: Response) => { try { const { username } = usernameSchema.parse(req.body); const db = getDatabase(); const existing = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.userId); if (existing) { res.status(409).json({ error: 'Username already taken' }); return; } db.prepare('UPDATE users SET username = ?, updated_at = ? WHERE id = ?') .run(username, Date.now(), req.userId); res.json({ username }); } catch (error: any) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Invalid input', details: error.errors }); return; } console.error('Change username error:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.post('/upload-avatar', authenticate, upload.single('avatar'), async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ error: 'No file uploaded' }); return; } if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } const ext = path.extname(req.file.originalname) || '.jpg'; const filename = `${req.userId}-${uuidv4().slice(0, 8)}${ext}`; const filepath = path.join(uploadsDir, filename); await sharp(req.file.buffer) .resize(256, 256, { fit: 'cover', position: 'centre' }) .jpeg({ quality: 90 }) .toFile(filepath); const db = getDatabase(); const avatarPath = `/uploads/avatars/${filename}`; db.prepare('UPDATE users SET avatar_path = ?, updated_at = ? WHERE id = ?') .run(avatarPath, Date.now(), req.userId); res.json({ avatarPath }); } catch (error: any) { console.error('Upload avatar error:', error); res.status(500).json({ error: 'Failed to upload avatar' }); } }); router.get('/sessions', authenticate, (req: AuthRequest, res: Response) => { const db = getDatabase(); const authHeader = req.headers.authorization!; const currentToken = authHeader.substring(7); const sessions = db.prepare(` SELECT id, device_info, ip, user_agent, last_active, created_at, expires_at FROM sessions WHERE user_id = ? ORDER BY last_active DESC, created_at DESC `).all(req.userId!) as any[]; const result = sessions.map((s: any) => ({ id: s.id, deviceInfo: s.device_info || '', ip: s.ip || '', userAgent: s.user_agent || '', lastActive: s.last_active || s.created_at, createdAt: s.created_at, expiresAt: s.expires_at, isCurrent: s.token === currentToken, })); res.json({ sessions: result }); }); router.get('/sessions/:sessionId', authenticate, (req: AuthRequest, res: Response) => { const db = getDatabase(); const session = db.prepare(` SELECT id, device_info, ip, user_agent, last_active, created_at, expires_at, token FROM sessions WHERE id = ? AND user_id = ? `).get(req.params.sessionId, req.userId!) as any; if (!session) { res.status(404).json({ error: 'Session not found' }); return; } const authHeader = req.headers.authorization!; const currentToken = authHeader.substring(7); res.json({ id: session.id, deviceInfo: session.device_info || '', ip: session.ip || '', userAgent: session.user_agent || '', lastActive: session.last_active || session.created_at, createdAt: session.created_at, expiresAt: session.expires_at, isCurrent: session.token === currentToken, }); }); router.delete('/sessions/:sessionId', authenticate, (req: AuthRequest, res: Response) => { const db = getDatabase(); const session = db.prepare('SELECT token FROM sessions WHERE id = ? AND user_id = ?') .get(req.params.sessionId, req.userId!) as any; if (!session) { res.status(404).json({ error: 'Session not found' }); return; } const authHeader = req.headers.authorization!; const currentToken = authHeader.substring(7); if (session.token === currentToken) { res.status(400).json({ error: 'Cannot delete current session. Use logout instead.' }); return; } db.prepare('DELETE FROM sessions WHERE id = ?').run(req.params.sessionId); res.json({ message: 'Session terminated' }); }); export default router;