import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType, ServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { checkAvatarChanged } from '@server/routes/avatarproxy'; import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import axios from 'axios'; import * as EmailValidator from 'email-validator'; import { Router } from 'express'; import net from 'net'; const authRoutes = Router(); authRoutes.get('/me', isAuthenticated(), async (req, res) => { const userRepository = getRepository(User); if (!req.user) { return res.status(500).json({ status: 500, error: 'Please sign in.', }); } const user = await userRepository.findOneOrFail({ where: { id: req.user.id }, }); // check if email is required in settings and if user has an valid email const settings = await getSettings(); if ( settings.notifications.agents.email.options.userEmailRequired && !EmailValidator.validate(user.email) ) { user.warnings.push('userEmailRequired'); logger.warn(`User ${user.username} has no valid email address`); } return res.status(200).json(user); }); authRoutes.post('/plex', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { authToken?: string }; if (!body.authToken) { return next({ status: 500, message: 'Authentication token required.', }); } if ( settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && (settings.main.mediaServerLogin === false || settings.main.mediaServerType != MediaServerType.PLEX) ) { return res.status(500).json({ error: 'Plex login is disabled' }); } try { // First we need to use this auth token to get the user's email from plex.tv const plextv = new PlexTvAPI(body.authToken); const account = await plextv.getUser(); // Next let's see if the user already exists let user = await userRepository .createQueryBuilder('user') .where('user.plexId = :id', { id: account.id }) .orWhere('user.email = :email', { email: account.email.toLowerCase(), }) .getOne(); if (!user && !(await userRepository.count())) { user = new User({ email: account.email, plexUsername: account.username, plexId: account.id, plexToken: account.authToken, permissions: Permission.ADMIN, avatar: account.thumb, userType: UserType.PLEX, }); settings.main.mediaServerType = MediaServerType.PLEX; await settings.save(); startJobs(); await userRepository.save(user); } else { const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true, plexId: true, email: true }, where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); if (!account.id) { logger.error('Plex ID was missing from Plex.tv response', { label: 'API', ip: req.ip, email: account.email, plexUsername: account.username, }); return next({ status: 500, message: 'Something went wrong. Try again.', }); } if ( account.id === mainUser.plexId || (account.email === mainUser.email && !mainUser.plexId) || (await mainPlexTv.checkUserAccess(account.id)) ) { if (user) { if (!user.plexId) { logger.info( 'Found matching Plex user; updating user with Plex data', { label: 'API', ip: req.ip, email: user.email, userId: user.id, plexId: account.id, plexUsername: account.username, } ); } user.plexToken = body.authToken; user.plexId = account.id; user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; user.userType = UserType.PLEX; await userRepository.save(user); } else if (!settings.main.newPlexLogin) { logger.warn( 'Failed sign-in attempt by unimported Plex user with access to the media server', { label: 'API', ip: req.ip, email: account.email, plexId: account.id, plexUsername: account.username, } ); return next({ status: 403, message: 'Access denied.', }); } else { logger.info( 'Sign-in attempt from Plex user with access to the media server; creating new Seerr user', { label: 'API', ip: req.ip, email: account.email, plexId: account.id, plexUsername: account.username, } ); user = new User({ email: account.email, plexUsername: account.username, plexId: account.id, plexToken: account.authToken, permissions: settings.main.defaultPermissions, avatar: account.thumb, userType: UserType.PLEX, }); await userRepository.save(user); } } else { logger.warn( 'Failed sign-in attempt by Plex user without access to the media server', { label: 'API', ip: req.ip, email: account.email, plexId: account.id, plexUsername: account.username, } ); return next({ status: 403, message: 'Access denied.', }); } } // Set logged in session if (req.session) { req.session.userId = user.id; } return res.status(200).json(user?.filter() ?? {}); } catch (e) { logger.error('Something went wrong authenticating with Plex account', { label: 'API', errorMessage: e.message, ip: req.ip, }); return next({ status: 500, message: 'Unable to authenticate.', }); } }); function getUserAvatarUrl(user: User): string { return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`; } authRoutes.post('/jellyfin', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { username?: string; password?: string; hostname?: string; port?: number; urlBase?: string; useSsl?: boolean; email?: string; serverType?: number; }; //Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured if ( // media server not configured, allow login for setup settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && (settings.main.mediaServerLogin === false || // media server is neither jellyfin or emby (settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.EMBY && settings.jellyfin.ip !== '')) ) { return res.status(500).json({ error: 'Jellyfin login is disabled' }); } if (!body.username) { return res.status(500).json({ error: 'You must provide an username' }); } else if (settings.jellyfin.ip !== '' && body.hostname) { return res .status(500) .json({ error: 'Jellyfin hostname already configured' }); } else if (settings.jellyfin.ip === '' && !body.hostname) { return res.status(500).json({ error: 'No hostname provided.' }); } try { const hostname = settings.jellyfin.ip !== '' ? getHostname() : getHostname({ useSsl: body.useSsl, ip: body.hostname, port: body.port, urlBase: body.urlBase, }); // Try to find deviceId that corresponds to jellyfin user, else generate a new one let user = await userRepository.findOne({ where: { jellyfinUsername: body.username }, select: { id: true, jellyfinDeviceId: true }, }); let deviceId = 'BOT_seerr'; if (user && user.id === 1) { // Admin is always BOT_seerr deviceId = 'BOT_seerr'; } else if (user && user.jellyfinDeviceId) { deviceId = user.jellyfinDeviceId; } else if (body.username) { deviceId = Buffer.from(`BOT_seerr_${body.username}`).toString('base64'); } // First we need to attempt to log the user in to jellyfin const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); const ip = req.ip; let clientIp; if (ip) { if (net.isIPv4(ip)) { clientIp = ip; } else if (net.isIPv6(ip)) { clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; } } const account = await jellyfinserver.login( body.username, body.password, clientIp ); // Next let's see if the user already exists user = await userRepository.findOne({ where: { jellyfinUserId: account.User.Id }, }); const missingAdminUser = !user && !(await userRepository.count()); if ( missingAdminUser || settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ) { // Check if user is admin on jellyfin if (account.User.Policy.IsAdministrator === false) { throw new ApiError(403, ApiErrorCode.NotAdmin); } if ( body.serverType !== MediaServerType.JELLYFIN && body.serverType !== MediaServerType.EMBY ) { throw new ApiError(500, ApiErrorCode.NoAdminUser); } settings.main.mediaServerType = body.serverType; if (missingAdminUser) { logger.info( 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Seerr', { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); // User doesn't exist, and there are no users in the database, we'll create the user // with admin permissions user = new User({ id: 1, email: body.email || account.User.Name, jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, userType: body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); user.avatar = getUserAvatarUrl(user); await userRepository.save(user); } else { logger.info( 'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Seerr', { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); // User alread exist but settings.json is not configured, we'll edit the admin user user = await userRepository.findOne({ where: { id: 1 }, }); if (!user) { throw new Error('Unable to find admin user to edit'); } user.email = body.email || account.User.Name; user.jellyfinUsername = account.User.Name; user.jellyfinUserId = account.User.Id; user.jellyfinDeviceId = deviceId; user.jellyfinAuthToken = account.AccessToken; user.permissions = Permission.ADMIN; user.avatar = getUserAvatarUrl(user); user.userType = body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY; await userRepository.save(user); } // Create an API key on Jellyfin from this admin user const jellyfinClient = new JellyfinAPI( hostname, account.AccessToken, deviceId ); const apiKey = await jellyfinClient.createApiToken('Seerr'); const serverName = await jellyfinserver.getServerName(); settings.jellyfin.name = serverName; settings.jellyfin.serverId = account.User.ServerId; settings.jellyfin.ip = body.hostname ?? ''; settings.jellyfin.port = body.port ?? 8096; settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.apiKey = apiKey; await settings.save(); startJobs(); } // User already exists, let's update their information else if (account.User.Id === user?.jellyfinUserId) { logger.info( `Found matching ${ settings.main.mediaServerType === MediaServerType.JELLYFIN ? ServerType.JELLYFIN : ServerType.EMBY } user; updating user with ${ settings.main.mediaServerType === MediaServerType.JELLYFIN ? ServerType.JELLYFIN : ServerType.EMBY }`, { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); user.avatar = getUserAvatarUrl(user); user.jellyfinUsername = account.User.Name; if (user.username === account.User.Name) { user.username = ''; } await userRepository.save(user); } else if (!settings.main.newPlexLogin) { logger.warn( 'Failed sign-in attempt by unimported Jellyfin user with access to the media server', { label: 'API', ip: req.ip, jellyfinUserId: account.User.Id, jellyfinUsername: account.User.Name, } ); return next({ status: 403, message: 'Access denied.', }); } else if (!user) { logger.info( 'Sign-in attempt from Jellyfin user with access to the media server; creating new Seerr user', { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); user = new User({ email: body.email, jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); user.avatar = getUserAvatarUrl(user); //initialize Jellyfin/Emby users with local login const passedExplicitPassword = body.password && body.password.length > 0; if (passedExplicitPassword) { await user.setPassword(body.password ?? ''); } await userRepository.save(user); } if (user && user.jellyfinUserId) { try { const { changed } = await checkAvatarChanged(user); if (changed) { user.avatar = getUserAvatarUrl(user); await userRepository.save(user); logger.debug('Avatar updated during login', { userId: user.id, jellyfinUserId: user.jellyfinUserId, }); } } catch (error) { logger.error('Error handling avatar during login', { label: 'Auth', errorMessage: error.message, }); } } // Set logged in session if (req.session) { req.session.userId = user?.id; } return res.status(200).json(user?.filter() ?? {}); } catch (e) { switch (e.errorCode) { case ApiErrorCode.InvalidUrl: logger.error( `The provided ${ settings.main.mediaServerType === MediaServerType.JELLYFIN ? ServerType.JELLYFIN : ServerType.EMBY } is invalid or the server is not reachable.`, { label: 'Auth', error: e.errorCode, status: e.statusCode, hostname: getHostname({ useSsl: body.useSsl, ip: body.hostname, port: body.port, urlBase: body.urlBase, }), } ); return next({ status: e.statusCode, message: e.errorCode, }); case ApiErrorCode.InvalidCredentials: logger.warn( 'Failed login attempt from user with incorrect Jellyfin credentials', { label: 'Auth', account: { ip: req.ip, email: body.username, password: '__REDACTED__', }, } ); return next({ status: e.statusCode, message: e.errorCode, }); case ApiErrorCode.NotAdmin: logger.warn( 'Failed login attempt from user without admin permissions', { label: 'Auth', account: { ip: req.ip, email: body.username, }, } ); return next({ status: e.statusCode, message: e.errorCode, }); case ApiErrorCode.NoAdminUser: logger.warn( 'Failed login attempt from user without admin permissions and no admin user exists', { label: 'Auth', account: { ip: req.ip, email: body.username, }, } ); return next({ status: e.statusCode, message: e.errorCode, }); default: logger.error(e.message, { label: 'Auth' }); return next({ status: 500, message: 'Something went wrong.', }); } } }); authRoutes.post('/local', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { email?: string; password?: string }; if (!settings.main.localLogin) { return res.status(500).json({ error: 'Password sign-in is disabled.' }); } else if (!body.email || !body.password) { return res.status(500).json({ error: 'You must provide both an email address and a password.', }); } try { const user = await userRepository .createQueryBuilder('user') .select(['user.id', 'user.email', 'user.password', 'user.plexId']) .where('user.email = :email', { email: body.email.toLowerCase() }) .getOne(); if (!user || !(await user.passwordMatch(body.password))) { logger.warn('Failed sign-in attempt using invalid Seerr password', { label: 'API', ip: req.ip, email: body.email, userId: user?.id, }); return next({ status: 403, message: 'Access denied.', }); } const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true, plexId: true }, where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); if (!user.plexId) { try { const plexUsersResponse = await mainPlexTv.getUsers(); const account = plexUsersResponse.MediaContainer.User.find( (account) => account.$.email && account.$.email.toLowerCase() === user.email.toLowerCase() )?.$; if ( account && (await mainPlexTv.checkUserAccess(parseInt(account.id))) ) { logger.info( 'Found matching Plex user; updating user with Plex data', { label: 'API', ip: req.ip, email: body.email, userId: user.id, plexId: account.id, plexUsername: account.username, } ); user.plexId = parseInt(account.id); user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; user.userType = UserType.PLEX; await userRepository.save(user); } } catch (e) { logger.error('Something went wrong fetching Plex users', { label: 'API', errorMessage: e.message, }); } } if ( user.plexId && user.plexId !== mainUser.plexId && !(await mainPlexTv.checkUserAccess(user.plexId)) ) { logger.warn( 'Failed sign-in attempt from Plex user without access to the media server', { label: 'API', account: { ip: req.ip, email: body.email, userId: user.id, plexId: user.plexId, }, } ); return next({ status: 403, message: 'Access denied.', }); } // Set logged in session if (user && req.session) { req.session.userId = user.id; } return res.status(200).json(user?.filter() ?? {}); } catch (e) { logger.error('Something went wrong authenticating with Seerr password', { label: 'API', errorMessage: e.message, ip: req.ip, email: body.email, }); return next({ status: 500, message: 'Unable to authenticate.', }); } }); authRoutes.post('/logout', async (req, res, next) => { try { const userId = req.session?.userId; if (!userId) { return res.status(200).json({ status: 'ok' }); } const settings = getSettings(); const isJellyfinOrEmby = settings.main.mediaServerType === MediaServerType.JELLYFIN || settings.main.mediaServerType === MediaServerType.EMBY; if (isJellyfinOrEmby) { const user = await getRepository(User) .createQueryBuilder('user') .addSelect(['user.jellyfinUserId', 'user.jellyfinDeviceId']) .where('user.id = :id', { id: userId }) .getOne(); if (user?.jellyfinUserId && user.jellyfinDeviceId) { try { const baseUrl = getHostname(); try { await axios.delete(`${baseUrl}/Devices`, { params: { Id: user.jellyfinDeviceId }, headers: { 'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="seerr", Version="${getAppVersion()}", Token="${ settings.jellyfin.apiKey }"`, }, }); } catch (error) { logger.error('Failed to delete Jellyfin device', { label: 'Auth', error: error instanceof Error ? error.message : 'Unknown error', userId: user.id, jellyfinUserId: user.jellyfinUserId, }); } } catch (error) { logger.error('Failed to delete Jellyfin device', { label: 'Auth', error: error instanceof Error ? error.message : 'Unknown error', userId: user.id, jellyfinUserId: user.jellyfinUserId, }); } } } req.session?.destroy((err: Error | null) => { if (err) { logger.error('Failed to destroy session', { label: 'Auth', error: err.message, userId, }); return next({ status: 500, message: 'Failed to destroy session.' }); } logger.info('Successfully logged out user', { label: 'Auth', userId, }); res.status(200).json({ status: 'ok' }); }); } catch (error) { logger.error('Error during logout process', { label: 'Auth', error: error instanceof Error ? error.message : 'Unknown error', userId: req.session?.userId, }); next({ status: 500, message: 'Error during logout process.' }); } }); authRoutes.post('/reset-password', async (req, res, next) => { const userRepository = getRepository(User); const body = req.body as { email?: string }; if (!body.email) { return next({ status: 500, message: 'Email address required.', }); } const user = await userRepository .createQueryBuilder('user') .where('user.email = :email', { email: body.email.toLowerCase() }) .getOne(); if (user) { await user.resetPassword(); userRepository.save(user); logger.info('Successfully sent password reset link', { label: 'API', ip: req.ip, email: body.email, }); } else { logger.error('Something went wrong sending password reset link', { label: 'API', ip: req.ip, email: body.email, }); } return res.status(200).json({ status: 'ok' }); }); authRoutes.post('/reset-password/:guid', async (req, res, next) => { const userRepository = getRepository(User); if (!req.body.password || req.body.password?.length < 8) { logger.warn('Failed password reset attempt using invalid new password', { label: 'API', ip: req.ip, guid: req.params.guid, }); return next({ status: 500, message: 'Password must be at least 8 characters long.', }); } const user = await userRepository.findOne({ where: { resetPasswordGuid: req.params.guid }, }); if (!user) { logger.warn('Failed password reset attempt using invalid recovery link', { label: 'API', ip: req.ip, guid: req.params.guid, }); return next({ status: 500, message: 'Invalid password reset link.', }); } if ( !user.recoveryLinkExpirationDate || user.recoveryLinkExpirationDate <= new Date() ) { logger.warn('Failed password reset attempt using expired recovery link', { label: 'API', ip: req.ip, guid: req.params.guid, email: user.email, }); return next({ status: 500, message: 'Invalid password reset link.', }); } user.recoveryLinkExpirationDate = null; await user.setPassword(req.body.password); userRepository.save(user); logger.info('Successfully reset password', { label: 'API', ip: req.ip, guid: req.params.guid, email: user.email, }); return res.status(200).json({ status: 'ok' }); }); export default authRoutes;