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.
667 lines
19 KiB
667 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);
|
|
|
|
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;
|