You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
overseerr/server/routes/user/index.ts

674 lines
19 KiB

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<string, never>,
Partial<User>[],
{ 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>{
...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, {
/**
* Break-up into groups of 1000 requests to be removed at a time.
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
* https://typeorm.io/repository-api#additional-options
*/
chunk: user.requests.length / 1000,
});
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 }, 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 = Number(req.query.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.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId,
})),
});
}
);
export default router;