Spaces:
Sleeping
Sleeping
| 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; | |