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 ;