const express = require('express'); const bcrypt = require('bcryptjs'); const multer = require('multer'); const path = require('path'); const router = express.Router(); const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db'); const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const avatarStorage = multer.diskStorage({ destination: '/app/uploads/avatars', filename: (req, file, cb) => { const ext = path.extname(file.originalname); cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`); } }); const uploadAvatar = multer({ storage: avatarStorage, limits: { fileSize: 2 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) cb(null, true); else cb(new Error('Images only')); } }); // Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc. function resolveUniqueName(db, baseName, excludeId = null) { const existing = db.prepare( "SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)" ).all(excludeId ?? -1, baseName, `${baseName} (%)`); if (existing.length === 0) return baseName; let max = 0; for (const u of existing) { const m = u.name.match(/\((\d+)\)$/); if (m) max = Math.max(max, parseInt(m[1])); else max = Math.max(max, 0); } return `${baseName} (${max + 1})`; } function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function getDefaultPassword(db) { return process.env.USER_PASS || 'user@1234'; } // List users (admin) router.get('/', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const users = db.prepare(` SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, allow_dm, created_at, last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC `).all(); res.json({ users }); }); // Search users (public-ish for mentions/add-member) router.get('/search', authMiddleware, (req, res) => { const { q, groupId } = req.query; const db = getDb(); let users; if (groupId) { const group = db.prepare('SELECT type, is_direct FROM groups WHERE id = ?').get(parseInt(groupId)); if (group && (group.type === 'private' || group.is_direct)) { // Private group or direct message — only show members of this group users = db.prepare(` SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag, u.allow_dm FROM users u JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ? WHERE u.status = 'active' AND u.id != ? AND (u.name LIKE ? OR u.display_name LIKE ?) LIMIT 10 `).all(parseInt(groupId), req.user.id, `%${q}%`, `%${q}%`); } else { // Public group — all active users users = db.prepare(` SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?) LIMIT 10 `).all(req.user.id, `%${q}%`, `%${q}%`); } } else { users = db.prepare(` SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?) LIMIT 10 `).all(`%${q}%`, `%${q}%`); } res.json({ users }); }); // Check if a display name is already taken (excludes self) router.get('/check-display-name', authMiddleware, (req, res) => { const { name } = req.query; if (!name) return res.json({ taken: false }); const db = getDb(); const conflict = db.prepare( "SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'" ).get(name, req.user.id); res.json({ taken: !!conflict }); }); // Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names router.post('/', authMiddleware, adminMiddleware, (req, res) => { const { name, email, password, role } = req.body; if (!name || !email) return res.status(400).json({ error: 'Name and email required' }); if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' }); const db = getDb(); const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email); if (exists) return res.status(400).json({ error: 'Email already in use' }); const resolvedName = resolveUniqueName(db, name.trim()); const pw = (password || '').trim() || getDefaultPassword(db); const hash = bcrypt.hashSync(pw, 10); const result = db.prepare(` INSERT INTO users (name, email, password, role, status, must_change_password) VALUES (?, ?, ?, ?, 'active', 1) `).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member'); addUserToPublicGroups(result.lastInsertRowid); // Admin users are automatically added to the Support group if (role === 'admin') { const supportGroupId = getOrCreateSupportGroup(); if (supportGroupId) { db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, result.lastInsertRowid); } } const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid); res.json({ user }); }); // Bulk create users router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => { const { users } = req.body; const db = getDb(); const results = { created: [], skipped: [] }; const seenEmails = new Set(); const defaultPw = getDefaultPassword(db); const insertUser = db.prepare(` INSERT INTO users (name, email, password, role, status, must_change_password) VALUES (?, ?, ?, ?, 'active', 1) `); for (const u of users) { const email = (u.email || '').trim().toLowerCase(); const name = (u.name || '').trim(); if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; } if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; } if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; } seenEmails.add(email); const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email); if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; } try { const resolvedName = resolveUniqueName(db, name); const pw = (u.password || '').trim() || defaultPw; const hash = bcrypt.hashSync(pw, 10); const newRole = u.role === 'admin' ? 'admin' : 'member'; const r = insertUser.run(resolvedName, email, hash, newRole); addUserToPublicGroups(r.lastInsertRowid); if (newRole === 'admin') { const supportGroupId = getOrCreateSupportGroup(); if (supportGroupId) { db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid); } } results.created.push(email); } catch (e) { results.skipped.push({ email, reason: e.message }); } } res.json(results); }); // Update user name (admin only — req 5) router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => { const { name } = req.body; if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' }); const db = getDb(); const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'User not found' }); // Pass the target's own id so their current name is excluded from the duplicate check const resolvedName = resolveUniqueName(db, name.trim(), req.params.id); db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id); res.json({ success: true, name: resolvedName }); }); // Update user role (admin) router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => { const { role } = req.body; const db = getDb(); const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' }); if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id); // If promoted to admin, ensure they're in the Support group if (role === 'admin') { const supportGroupId = getOrCreateSupportGroup(); if (supportGroupId) { db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id); } } res.json({ success: true }); }); // Reset user password (admin) router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => { const { password } = req.body; if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' }); const db = getDb(); const hash = bcrypt.hashSync(password, 10); db.prepare("UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE id = ?").run(hash, req.params.id); res.json({ success: true }); }); // Suspend user (admin) router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' }); db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id); res.json({ success: true }); }); // Activate user (admin) router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); db.prepare("UPDATE users SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(req.params.id); res.json({ success: true }); }); // Delete user (admin) router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' }); db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id); res.json({ success: true }); }); // Update own profile — display name must be unique (req 6) router.patch('/me/profile', authMiddleware, (req, res) => { const { displayName, aboutMe, hideAdminTag, allowDm } = req.body; const db = getDb(); if (displayName) { const conflict = db.prepare( "SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'" ).get(displayName, req.user.id); if (conflict) return res.status(400).json({ error: 'Display name already in use' }); } db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, allow_dm = ?, updated_at = datetime('now') WHERE id = ?") .run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, allowDm === false ? 0 : 1, req.user.id); const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag, allow_dm FROM users WHERE id = ?').get(req.user.id); res.json({ user }); }); // Upload avatar — resize if needed, skip compression for files under 500 KB router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); try { const sharp = require('sharp'); const filePath = req.file.path; const fileSizeBytes = req.file.size; const FIVE_HUNDRED_KB = 500 * 1024; const MAX_DIM = 256; // max width/height in pixels const image = sharp(filePath); const meta = await image.metadata(); const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM); if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) { // Small enough and already correctly sized — serve as-is } else { // Resize (and compress only if over 500 KB) const outPath = filePath.replace(/(\.[^.]+)$/, '_p$1'); let pipeline = sharp(filePath).resize(MAX_DIM, MAX_DIM, { fit: 'cover', withoutEnlargement: true }); if (fileSizeBytes >= FIVE_HUNDRED_KB) { // Compress: use webp for best size/quality ratio pipeline = pipeline.webp({ quality: 82 }); await pipeline.toFile(outPath + '.webp'); const fs = require('fs'); fs.unlinkSync(filePath); fs.renameSync(outPath + '.webp', filePath.replace(/\.[^.]+$/, '.webp')); const newPath = filePath.replace(/\.[^.]+$/, '.webp'); const newFilename = path.basename(newPath); const db = getDb(); const avatarUrl = `/uploads/avatars/${newFilename}`; db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id); return res.json({ avatarUrl }); } else { // Under 500 KB but needs resize — resize only, keep original format await pipeline.toFile(outPath); const fs = require('fs'); fs.unlinkSync(filePath); fs.renameSync(outPath, filePath); } } const avatarUrl = `/uploads/avatars/${req.file.filename}`; const db = getDb(); db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id); res.json({ avatarUrl }); } catch (err) { console.error('Avatar processing error:', err); // Fall back to serving unprocessed file const avatarUrl = `/uploads/avatars/${req.file.filename}`; const db = getDb(); db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id); res.json({ avatarUrl }); } }); module.exports = router;