import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { MediaType } from '@server/constants/media'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { QuotaResponse, UserRequestsResponse, UserResultsResponse, UserWatchDataResponse, } from '@server/interfaces/api/userInterfaces'; import { hasPermission, Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; import { findIndex, sortBy } from 'lodash'; import { In } from 'typeorm'; import userSettingsRoutes from './usersettings'; const router = Router(); router.get('/', async (req, res, next) => { try { const pageSize = req.query.take ? Number(req.query.take) : 10; const skip = req.query.skip ? Number(req.query.skip) : 0; let query = getRepository(User).createQueryBuilder('user'); switch (req.query.sort) { case 'updated': query = query.orderBy('user.updatedAt', 'DESC'); break; case 'displayname': query = query.orderBy( "(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)", 'ASC' ); break; case 'requests': query = query .addSelect((subQuery) => { return subQuery .select('COUNT(request.id)', 'requestCount') .from(MediaRequest, 'request') .where('request.requestedBy.id = user.id'); }, 'requestCount') .orderBy('requestCount', 'DESC'); break; default: query = query.orderBy('user.id', 'ASC'); break; } const [users, userCount] = await query .take(pageSize) .skip(skip) .getManyAndCount(); return res.status(200).json({ pageInfo: { pages: Math.ceil(userCount / pageSize), pageSize, results: userCount, page: Math.ceil(skip / pageSize) + 1, }, results: User.filterMany( users, req.user?.hasPermission(Permission.MANAGE_USERS) ), } as UserResultsResponse); } catch (e) { next({ status: 500, message: e.message }); } }); router.post( '/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const settings = getSettings(); const body = req.body; const userRepository = getRepository(User); const existingUser = await userRepository .createQueryBuilder('user') .where('user.email = :email', { email: body.email.toLowerCase(), }) .getOne(); if (existingUser) { return next({ status: 409, message: 'User already exists with submitted email.', errors: ['USER_EXISTS'], }); } const passedExplicitPassword = body.password && body.password.length > 0; const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); if ( !passedExplicitPassword && !settings.notifications.agents.email.enabled ) { throw new Error('Email notifications must be enabled'); } const user = new User({ avatar: body.avatar ?? avatar, username: body.username, email: body.email, password: body.password, permissions: settings.main.defaultPermissions, plexToken: '', userType: UserType.LOCAL, }); if (passedExplicitPassword) { await user?.setPassword(body.password); } else { await user?.generatePassword(); } await userRepository.save(user); return res.status(201).json(user.filter()); } catch (e) { next({ status: 500, message: e.message }); } } ); router.post< never, unknown, { endpoint: string; p256dh: string; auth: string; } >('/registerPushSubscription', async (req, res, next) => { try { const userPushSubRepository = getRepository(UserPushSubscription); const existingSubs = await userPushSubRepository.find({ where: { auth: req.body.auth }, }); if (existingSubs.length > 0) { logger.debug( 'User push subscription already exists. Skipping registration.', { label: 'API' } ); return res.status(204).send(); } const userPushSubscription = new UserPushSubscription({ auth: req.body.auth, endpoint: req.body.endpoint, p256dh: req.body.p256dh, user: req.user, }); userPushSubRepository.save(userPushSubscription); return res.status(204).send(); } catch (e) { logger.error('Failed to register user push subscription', { label: 'API', }); next({ status: 500, message: 'Failed to register subscription.' }); } }); router.get<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); const user = await userRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); return res .status(200) .json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS))); } catch (e) { next({ status: 404, message: 'User not found.' }); } }); router.use('/:id/settings', userSettingsRoutes); router.get<{ id: string }, UserRequestsResponse>( '/:id/requests', async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; try { const user = await getRepository(User).findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } if ( user.id !== req.user?.id && !req.user?.hasPermission( [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], { type: 'or' } ) ) { return next({ status: 403, message: "You do not have permission to view this user's requests.", }); } const [requests, requestCount] = await getRepository(MediaRequest) .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media') .leftJoinAndSelect('request.seasons', 'seasons') .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') .leftJoinAndSelect('request.requestedBy', 'requestedBy') .andWhere('requestedBy.id = :id', { id: user.id, }) .orderBy('request.id', 'DESC') .take(pageSize) .skip(skip) .getManyAndCount(); return res.status(200).json({ pageInfo: { pages: Math.ceil(requestCount / pageSize), pageSize, results: requestCount, page: Math.ceil(skip / pageSize) + 1, }, results: requests, }); } catch (e) { next({ status: 500, message: e.message }); } } ); export const canMakePermissionsChange = ( permissions: number, user?: User ): boolean => // Only let the owner grant admin privileges !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1); router.put< Record, Partial[], { ids: string[]; permissions: number } >('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const isOwner = req.user?.id === 1; if (!canMakePermissionsChange(req.body.permissions, req.user)) { return next({ status: 403, message: 'You do not have permission to grant this level of access', }); } const userRepository = getRepository(User); const users: User[] = await userRepository.find({ where: { id: In( isOwner ? req.body.ids : req.body.ids.filter((id) => Number(id) !== 1) ), }, }); const updatedUsers = await Promise.all( users.map(async (user) => { return userRepository.save({ ...user, ...{ permissions: req.body.permissions }, }); }) ); return res.status(200).json(updatedUsers); } catch (e) { next({ status: 500, message: e.message }); } }); router.put<{ id: string }>( '/:id', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const userRepository = getRepository(User); const user = await userRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); // Only let the owner user modify themselves if (user.id === 1 && req.user?.id !== 1) { return next({ status: 403, message: 'You do not have permission to modify this user', }); } if (!canMakePermissionsChange(req.body.permissions, req.user)) { return next({ status: 403, message: 'You do not have permission to grant this level of access', }); } Object.assign(user, { username: req.body.username, permissions: req.body.permissions, }); await userRepository.save(user); return res.status(200).json(user.filter()); } catch (e) { next({ status: 404, message: 'User not found.' }); } } ); router.delete<{ id: string }>( '/:id', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const userRepository = getRepository(User); const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, relations: { requests: true }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } if (user.id === 1) { return next({ status: 405, message: 'This account cannot be deleted.', }); } if (user.hasPermission(Permission.ADMIN) && req.user?.id !== 1) { return next({ status: 405, message: 'You cannot delete users with administrative privileges.', }); } const requestRepository = getRepository(MediaRequest); /** * Requests are usually deleted through a cascade constraint. Those however, do * not trigger the removal event so listeners to not run and the parent Media * will not be updated back to unknown for titles that were still pending. So * we manually remove all requests from the user here so the parent media's * properly reflect the change. */ await requestRepository.remove(user.requests); await userRepository.delete(user.id); return res.status(200).json(user.filter()); } catch (e) { logger.error('Something went wrong deleting a user', { label: 'API', userId: req.params.id, errorMessage: e.message, }); return next({ status: 500, message: 'Something went wrong deleting the user', }); } } ); router.post( '/import-from-plex', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { plexIds: string[] } | undefined; // taken from auth.ts const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); const plexUsersResponse = await mainPlexTv.getUsers(); const createdUsers: User[] = []; for (const rawUser of plexUsersResponse.MediaContainer.User) { const account = rawUser.$; if (account.email) { const user = await userRepository .createQueryBuilder('user') .where('user.plexId = :id', { id: account.id }) .orWhere('user.email = :email', { email: account.email.toLowerCase(), }) .getOne(); if (user) { // Update the user's avatar with their Plex thumbnail, in case it changed user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; // In case the user was previously a local account if (user.userType === UserType.LOCAL) { user.userType = UserType.PLEX; user.plexId = parseInt(account.id); } await userRepository.save(user); } else if (!body || body.plexIds.includes(account.id)) { if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { const newUser = new User({ plexUsername: account.username, email: account.email, permissions: settings.main.defaultPermissions, plexId: parseInt(account.id), plexToken: '', avatar: account.thumb, userType: UserType.PLEX, }); await userRepository.save(newUser); createdUsers.push(newUser); } } } } return res.status(201).json(User.filterMany(createdUsers)); } catch (e) { next({ status: 500, message: e.message }); } } ); router.get<{ id: string }, QuotaResponse>( '/:id/quota', async (req, res, next) => { try { const userRepository = getRepository(User); if ( Number(req.params.id) !== req.user?.id && !req.user?.hasPermission( [Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS], { type: 'and' } ) ) { return next({ status: 403, message: "You do not have permission to view this user's request limits.", }); } const user = await userRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); const quotas = await user.getQuota(); return res.status(200).json(quotas); } catch (e) { next({ status: 404, message: e.message }); } } ); router.get<{ id: string }, UserWatchDataResponse>( '/:id/watch_data', async (req, res, next) => { if ( Number(req.params.id) !== req.user?.id && !req.user?.hasPermission(Permission.ADMIN) ) { return next({ status: 403, message: "You do not have permission to view this user's recently watched media.", }); } const settings = getSettings().tautulli; if (!settings.hostname || !settings.port || !settings.apiKey) { return next({ status: 404, message: 'Tautulli API not configured.', }); } try { const user = await getRepository(User).findOneOrFail({ where: { id: Number(req.params.id) }, select: { id: true, plexId: true }, }); const tautulli = new TautulliAPI(settings); const watchStats = await tautulli.getUserWatchStats(user); const watchHistory = await tautulli.getUserWatchHistory(user); const recentlyWatched = sortBy( await getRepository(Media).find({ where: [ { mediaType: MediaType.MOVIE, ratingKey: In( watchHistory .filter((record) => record.media_type === 'movie') .map((record) => record.rating_key) ), }, { mediaType: MediaType.MOVIE, ratingKey4k: In( watchHistory .filter((record) => record.media_type === 'movie') .map((record) => record.rating_key) ), }, { mediaType: MediaType.TV, ratingKey: In( watchHistory .filter((record) => record.media_type === 'episode') .map((record) => record.grandparent_rating_key) ), }, { mediaType: MediaType.TV, ratingKey4k: In( watchHistory .filter((record) => record.media_type === 'episode') .map((record) => record.grandparent_rating_key) ), }, ], }), [ (media) => findIndex( watchHistory, (record) => (!!media.ratingKey && parseInt(media.ratingKey) === (record.media_type === 'movie' ? record.rating_key : record.grandparent_rating_key)) || (!!media.ratingKey4k && parseInt(media.ratingKey4k) === (record.media_type === 'movie' ? record.rating_key : record.grandparent_rating_key)) ), ] ); return res.status(200).json({ recentlyWatched, playCount: watchStats.total_plays, }); } catch (e) { logger.error('Something went wrong fetching user watch data', { label: 'API', errorMessage: e.message, userId: req.params.id, }); next({ status: 500, message: 'Failed to fetch user watch data.', }); } } ); router.get<{ id: string; page?: number }, WatchlistResponse>( '/:id/watchlist', async (req, res, next) => { if ( Number(req.params.id) !== req.user?.id && !req.user?.hasPermission( [Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW], { type: 'or', } ) ) { return next({ status: 403, message: "You do not have permission to view this user's Plex Watchlist.", }); } const itemsPerPage = 20; const page = req.params.page ?? 1; const offset = (page - 1) * itemsPerPage; const user = await getRepository(User).findOneOrFail({ where: { id: Number(req.params.id) }, select: { id: true, plexToken: true }, }); if (!user?.plexToken) { // We will just return an empty array if the user has no Plex token return res.json({ page: 1, totalPages: 1, totalResults: 0, results: [], }); } const plexTV = new PlexTvAPI(user.plexToken); const watchlist = await plexTV.getWatchlist({ offset }); return res.json({ page, totalPages: Math.ceil(watchlist.size / itemsPerPage), totalResults: watchlist.size, results: watchlist.items.map((item) => ({ ratingKey: item.ratingKey, title: item.title, mediaType: item.type === 'show' ? 'tv' : 'movie', tmdbId: item.tmdbId, })), }); } ); export default router;