Ctrl K
ComunidadGuías…

routes/settingsRoutes.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string> = {
    '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;