import express from 'express'; import mongoose from 'mongoose'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; import bcrypt from 'bcrypt'; import User from '../models/User'; import Collection from '../models/Collection'; import { requireAuth } from '../middleware/authMiddleware'; import { viewRegistry } from '../core/viewRegistry'; import { applyHomeCollection, HOME_PAGES, isHomePage } from '../utils/homePage'; const router = express.Router(); const AVATARS_DIR = path.join(__dirname, '../public/uploads/avatars'); const DEFAULT_AVATAR = '/ressources/no-pp.jpg'; // Removes a user's previously uploaded avatar file from disk, if any (never touches the default image). const removeAvatarFile = (avatarPath?: string | null) => { if (!avatarPath || avatarPath.includes('no-pp.jpg')) return; const absolutePath = path.join(__dirname, '../public', avatarPath); if (fs.existsSync(absolutePath)) fs.unlinkSync(absolutePath); }; const EXT_BY_CONTENT_TYPE: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' }; // Multer configuration (image uploads) const storage = multer.diskStorage({ destination: (req, file, cb) => { const dir = path.join(__dirname, '../public/uploads/avatars'); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (req, file, cb) => { // Keep a timestamp to avoid browser cache and include user ID for clarity const ext = path.extname(file.originalname); const userId = (req as any).user ? (req as any).user._id : 'unknown'; // req.user is available via requireAuth cb(null, `avatar-${userId}-${Date.now()}${ext}`); } }); // Note: Multer runs before route handlers so accessing res.locals.user can be tricky. // We include the user id in the filename (or use a wrapper) to associate uploads. const upload = multer({ storage: storage, fileFilter: (req, file, cb) => { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (allowedTypes.includes(file.mimetype)) cb(null, true); else cb(new Error('Format non supporté (JPG, PNG, GIF, WEBP uniquement).')); }, limits: { fileSize: 5 * 1024 * 1024 }, }); // Routes // Render settings page router.get('/', requireAuth, (req, res) => { res.render('settings', { user: res.locals.user, homePages: HOME_PAGES, // Every registered view, not the ones available on this page: the selector is // about a page the user is not on, and a combination that does not apply (the // shelf on a wishlist) resolves back to the grid when they get there. collectionViews: viewRegistry.getAll() }); }); // Check whether a username is available router.post('/check-username', requireAuth, async (req, res) => { const { username } = req.body; try { // If user checks their current username, consider it available if (username === res.locals.user.username) { return res.json({ success: true, message: req.t('messages.username_current') }); } const userExists = await User.findOne({ username: username }); if (userExists) { return res.status(400).json({ success: false, message: req.t('errors.username_taken') }); } res.json({ success: true, message: req.t('messages.username_available') }); } catch (error) { res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Update username router.post('/update-username', requireAuth, async (req, res) => { const { username } = req.body; try { const userExists = await User.findOne({ username: username }); if (userExists && userExists._id.toString() !== res.locals.user._id.toString()) { return res.status(400).json({ success: false, message: req.t('errors.username_taken') }); } await User.findByIdAndUpdate(res.locals.user._id, { username: username }); res.redirect('/settings'); } catch (error) { res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Upload avatar router.post('/upload-avatar', requireAuth, upload.single('avatar'), async (req, res) => { // Multer error handling if (!req.file) return res.status(400).json({ success: false, message: req.t('messages.avatar_upload_error') }); try { const userId = res.locals.user._id; // Retrieve previous avatar to remove it from disk const currentUser = await User.findById(userId); removeAvatarFile(currentUser?.img); // Update DB with new path const newAvatarPath = `/uploads/avatars/${req.file.filename}`; await User.findByIdAndUpdate(userId, { img: newAvatarPath }); res.json({ success: true, message: req.t('messages.avatar_updated'), avatarPath: newAvatarPath }); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Import avatar from Gravatar (opt-in: only triggered by explicit user action, never automatically). router.post('/import-gravatar', requireAuth, async (req, res) => { try { const userId = res.locals.user._id; const currentUser = await User.findById(userId); if (!currentUser) return res.status(404).json({ success: false, message: req.t('messages.generic_error') }); const hash = crypto.createHash('sha256').update(currentUser.email.trim().toLowerCase()).digest('hex'); const gravatarUrl = `https://www.gravatar.com/avatar/${hash}?s=256&d=404`; const gravatarRes = await fetch(gravatarUrl); if (gravatarRes.status === 404) { return res.status(404).json({ success: false, message: req.t('messages.gravatar_not_found') }); } if (!gravatarRes.ok) { return res.status(502).json({ success: false, message: req.t('messages.generic_error') }); } const contentType = gravatarRes.headers.get('content-type') || 'image/jpeg'; const ext = EXT_BY_CONTENT_TYPE[contentType] || '.jpg'; const buffer = Buffer.from(await gravatarRes.arrayBuffer()); if (!fs.existsSync(AVATARS_DIR)) fs.mkdirSync(AVATARS_DIR, { recursive: true }); const filename = `avatar-${userId}-${Date.now()}${ext}`; fs.writeFileSync(path.join(AVATARS_DIR, filename), buffer); removeAvatarFile(currentUser.img); const newAvatarPath = `/uploads/avatars/${filename}`; await User.findByIdAndUpdate(userId, { img: newAvatarPath }); res.json({ success: true, message: req.t('messages.avatar_updated'), avatarPath: newAvatarPath }); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Remove the current avatar and fall back to the default image. router.post('/remove-avatar', requireAuth, async (req, res) => { try { const userId = res.locals.user._id; const currentUser = await User.findById(userId); removeAvatarFile(currentUser?.img); await User.findByIdAndUpdate(userId, { img: DEFAULT_AVATAR }); res.json({ success: true, message: req.t('messages.avatar_updated'), avatarPath: DEFAULT_AVATAR }); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Update Password router.post('/update-password', requireAuth, async (req, res) => { const { currentPassword, newPassword } = req.body; try { // Verify current password const user = await User.findById(res.locals.user._id); if (!user) { res.status(500).json({ success: false, message: "User not found" }); return; } // SSO-only accounts (provisioned through the IdP) have no local password // to change; they authenticate exclusively through their provider. if (!user.password) { return res.status(400).json({ success: false, message: req.t('errors.sso_no_local_password') }); } const isMatch = await bcrypt.compare(currentPassword, user.password); if (!isMatch) { return res.status(400).json({ success: false, message: req.t('errors.current_password_incorrect') }); } const hashedPassword = await bcrypt.hash(newPassword, 10); await User.findByIdAndUpdate(res.locals.user._id, { password: hashedPassword, lastChange: Date.now() }); res.json({ success: true, message: req.t('messages.password_updated') }); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Update Theme router.post('/update-theme', requireAuth, async (req, res) => { const { theme } = req.body; if (!['light', 'dark'].includes(theme)) { return res.status(400).json({ success: false, message: req.t('errors.invalid_theme') }); } try { await User.findByIdAndUpdate(res.locals.user._id, { theme: theme }); res.json({ success: true, message: req.t('messages.theme_updated') }); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Update Language router.post('/update-language', requireAuth, async (req, res) => { const { language } = req.body; const userId = res.locals.user._id; try { // Update language in DB await User.findByIdAndUpdate(userId, { language }); // Update i18n language for current session await req.i18n.changeLanguage(language); // Redirect back or to settings page const backURL = req.header('Referer') || '/settings'; res.redirect(backURL); } catch (err) { console.error(err); res.status(500).send(req.t('errors.lang_change_error')); } }); // Update where opening the app lands: which collection, which page, and the view that // page opens in. One form, so a partial submission never leaves half a preference set. router.post('/update-home', requireAuth, async (req: any, res) => { const { homePage, homeView } = req.body; const userId = res.locals.user._id; try { // An empty value means "wherever I left off". Anything else has to be a // collection this user actually belongs to, or they would be sent, every launch, // to a collection the middleware immediately has to heal away from. let homeCollectionId = null; if (req.body.homeCollectionId) { const member = await Collection.findOne({ _id: mongoose.Types.ObjectId.isValid(req.body.homeCollectionId) ? req.body.homeCollectionId : null, 'members.user': userId }).select('_id'); if (!member) { return res.redirect('/settings'); } homeCollectionId = member._id; } await User.findByIdAndUpdate(userId, { homeCollectionId, homePage: isHomePage(homePage) ? homePage : 'dashboard', homeView: viewRegistry.get(homeView) ? homeView : '' }); // Move there now instead of waiting for the next launch: picking a home // collection and watching the header keep the old one reads as a setting that // did not save. await applyHomeCollection(req, homeCollectionId); res.redirect('/settings'); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); // Unlink the currently linked SSO (OIDC) identity, if any. router.post('/oidc/unlink', requireAuth, async (req, res) => { try { await User.findByIdAndUpdate(res.locals.user._id, { $unset: { oidc: 1 } }); res.json({ success: true, message: req.t('messages.oidc_unlinked') }); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); router.post('/update-currency', requireAuth, async (req, res) => { const { currency } = req.body; const userId = res.locals.user._id; if (!['EUR', 'USD', 'GBP'].includes(currency)) { return res.status(400).send('Devise non supportée'); } try { await User.findByIdAndUpdate(userId, { currency }); res.redirect('/settings'); } catch (error) { console.error(error); res.status(500).json({ success: false, message: req.t('messages.generic_error') }); } }); export = router;