RealBlocks / server /src /routes /settings.ts
Sebebeb's picture
Added Settings
db82489
Raw
History Blame Contribute Delete
8.86 kB
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;