2022-11-24 15:53:58 -06:00
const SocketIO = require ( 'socket.io' )
const Logger = require ( './Logger' )
2023-07-04 18:14:44 -05:00
const Database = require ( './Database' )
2025-07-06 16:43:03 -05:00
const TokenManager = require ( './auth/TokenManager' )
2022-11-24 15:53:58 -06:00
2024-08-10 15:46:04 -05:00
/ * *
* @ typedef SocketClient
* @ property { string } id socket id
* @ property { SocketIO . Socket } socket
* @ property { number } connected _at
* @ property { import ( './models/User' ) } user
* /
2022-11-24 15:53:58 -06:00
class SocketAuthority {
constructor ( ) {
this . Server = null
2024-11-29 04:13:00 +02:00
this . socketIoServers = [ ]
2022-11-24 15:53:58 -06:00
2024-08-10 15:46:04 -05:00
/** @type {Object.<string, SocketClient>} */
2022-11-24 15:53:58 -06:00
this . clients = { }
}
2023-08-12 16:11:58 -05:00
/ * *
* returns an array of User . toJSONForPublic with ` connections ` for the # of socket connections
* a user can have many socket connections
* @ returns { object [ ] }
* /
2022-11-24 15:53:58 -06:00
getUsersOnline ( ) {
const onlineUsersMap = { }
2024-08-10 15:46:04 -05:00
Object . values ( this . clients )
. filter ( ( c ) => c . user )
. forEach ( ( client ) => {
if ( onlineUsersMap [ client . user . id ] ) {
onlineUsersMap [ client . user . id ] . connections ++
} else {
onlineUsersMap [ client . user . id ] = {
... client . user . toJSONForPublic ( this . Server . playbackSessionManager . sessions ) ,
connections : 1
}
2022-11-24 15:53:58 -06:00
}
2024-08-10 15:46:04 -05:00
} )
2022-11-24 15:53:58 -06:00
return Object . values ( onlineUsersMap )
}
getClientsForUser ( userId ) {
2024-08-10 15:46:04 -05:00
return Object . values ( this . clients ) . filter ( ( c ) => c . user ? . id === userId )
2022-11-24 15:53:58 -06:00
}
2023-08-26 16:33:27 -05:00
/ * *
* Emits event to all authorized clients
2024-08-10 15:46:04 -05:00
* @ param { string } evt
* @ param { any } data
2023-08-26 16:33:27 -05:00
* @ param { Function } [ filter ] optional filter function to only send event to specific users
* /
2022-11-30 17:32:59 -06:00
emitter ( evt , data , filter = null ) {
2022-11-24 15:53:58 -06:00
for ( const socketId in this . clients ) {
2022-11-24 16:35:26 -06:00
if ( this . clients [ socketId ] . user ) {
2022-11-30 17:32:59 -06:00
if ( filter && ! filter ( this . clients [ socketId ] . user ) ) continue
2022-11-24 16:35:26 -06:00
this . clients [ socketId ] . socket . emit ( evt , data )
}
2022-11-24 15:53:58 -06:00
}
}
2022-11-24 16:35:26 -06:00
// Emits event to all clients for a specific user
clientEmitter ( userId , evt , data ) {
const clients = this . getClientsForUser ( userId )
2022-11-24 15:53:58 -06:00
if ( ! clients . length ) {
2023-08-01 16:34:01 -05:00
return Logger . debug ( ` [SocketAuthority] clientEmitter - no clients found for user ${ userId } ` )
2022-11-24 15:53:58 -06:00
}
clients . forEach ( ( client ) => {
if ( client . socket ) {
2022-11-24 16:35:26 -06:00
client . socket . emit ( evt , data )
2022-11-24 15:53:58 -06:00
}
} )
}
2022-11-24 16:35:26 -06:00
// Emits event to all admin user clients
adminEmitter ( evt , data ) {
for ( const socketId in this . clients ) {
2024-08-10 15:46:04 -05:00
if ( this . clients [ socketId ] . user ? . isAdminOrUp ) {
2022-11-24 16:35:26 -06:00
this . clients [ socketId ] . socket . emit ( evt , data )
}
}
}
2025-04-12 17:39:51 -05:00
/ * *
* Emits event with library item to all clients that can access the library item
* Note : Emits toOldJSONExpanded ( )
*
* @ param { string } evt
* @ param { import ( './models/LibraryItem' ) } libraryItem
* /
libraryItemEmitter ( evt , libraryItem ) {
for ( const socketId in this . clients ) {
if ( this . clients [ socketId ] . user ? . checkCanAccessLibraryItem ( libraryItem ) ) {
this . clients [ socketId ] . socket . emit ( evt , libraryItem . toOldJSONExpanded ( ) )
}
}
}
/ * *
* Emits event with library items to all clients that can access the library items
* Note : Emits toOldJSONExpanded ( )
*
* @ param { string } evt
* @ param { import ( './models/LibraryItem' ) [ ] } libraryItems
* /
libraryItemsEmitter ( evt , libraryItems ) {
for ( const socketId in this . clients ) {
if ( this . clients [ socketId ] . user ) {
const libraryItemsAccessibleToUser = libraryItems . filter ( ( li ) => this . clients [ socketId ] . user . checkCanAccessLibraryItem ( li ) )
if ( libraryItemsAccessibleToUser . length ) {
this . clients [ socketId ] . socket . emit (
evt ,
libraryItemsAccessibleToUser . map ( ( li ) => li . toOldJSONExpanded ( ) )
)
}
}
}
}
2023-12-28 16:32:21 -06:00
/ * *
* Closes the Socket . IO server and disconnect all clients
2024-08-10 15:46:04 -05:00
*
* @ param { Function } callback
2023-12-28 16:32:21 -06:00
* /
2024-11-29 04:13:00 +02:00
async close ( ) {
Logger . info ( '[SocketAuthority] closing...' )
const closePromises = this . socketIoServers . map ( ( io ) => {
return new Promise ( ( resolve ) => {
Logger . info ( ` [SocketAuthority] Closing Socket.IO server: ${ io . path } ` )
io . close ( ( ) => {
Logger . info ( ` [SocketAuthority] Socket.IO server closed: ${ io . path } ` )
resolve ( )
} )
} )
} )
await Promise . all ( closePromises )
Logger . info ( '[SocketAuthority] closed' )
this . socketIoServers = [ ]
2023-12-27 15:33:33 +02:00
}
2022-11-24 15:53:58 -06:00
initialize ( Server ) {
this . Server = Server
2024-11-29 04:13:00 +02:00
const socketIoOptions = {
2022-11-24 15:53:58 -06:00
cors : {
origin : '*' ,
2024-08-10 15:46:04 -05:00
methods : [ 'GET' , 'POST' ]
2022-11-24 15:53:58 -06:00
}
2024-11-29 04:13:00 +02:00
}
2022-11-24 15:53:58 -06:00
2024-11-29 04:13:00 +02:00
const ioServer = new SocketIO . Server ( Server . server , socketIoOptions )
ioServer . path = '/socket.io'
this . socketIoServers . push ( ioServer )
2022-11-24 15:53:58 -06:00
2024-11-29 04:13:00 +02:00
if ( global . RouterBasePath ) {
// open a separate socket.io server for the router base path, keeping the original server open for legacy clients
const ioBasePath = ` ${ global . RouterBasePath } /socket.io `
const ioBasePathServer = new SocketIO . Server ( Server . server , { ... socketIoOptions , path : ioBasePath } )
ioBasePathServer . path = ioBasePath
this . socketIoServers . push ( ioBasePathServer )
}
2022-11-24 15:53:58 -06:00
2024-11-29 04:13:00 +02:00
this . socketIoServers . forEach ( ( io ) => {
io . on ( 'connection' , ( socket ) => {
this . clients [ socket . id ] = {
id : socket . id ,
socket ,
connected _at : Date . now ( )
}
socket . sheepClient = this . clients [ socket . id ]
2022-11-24 15:53:58 -06:00
2024-11-29 04:13:00 +02:00
Logger . info ( ` [SocketAuthority] Socket Connected to ${ io . path } ` , socket . id )
2022-11-24 15:53:58 -06:00
2024-11-29 04:13:00 +02:00
// Required for associating a User with a socket
socket . on ( 'auth' , ( token ) => this . authenticateSocket ( socket , token ) )
2022-11-24 15:53:58 -06:00
2024-11-29 04:13:00 +02:00
// Scanning
socket . on ( 'cancel_scan' , ( libraryId ) => this . cancelScan ( libraryId ) )
2022-11-24 15:53:58 -06:00
2024-11-29 04:13:00 +02:00
// Logs
socket . on ( 'set_log_listener' , ( level ) => Logger . addSocketListener ( socket , level ) )
socket . on ( 'remove_log_listener' , ( ) => Logger . removeSocketListener ( socket . id ) )
2022-11-24 16:35:26 -06:00
2024-11-29 04:13:00 +02:00
// Sent automatically from socket.io clients
socket . on ( 'disconnect' , ( reason ) => {
Logger . removeSocketListener ( socket . id )
const _client = this . clients [ socket . id ]
if ( ! _client ) {
Logger . warn ( ` [SocketAuthority] Socket ${ socket . id } disconnect, no client (Reason: ${ reason } ) ` )
} else if ( ! _client . user ) {
Logger . info ( ` [SocketAuthority] Unauth socket ${ socket . id } disconnected (Reason: ${ reason } ) ` )
delete this . clients [ socket . id ]
} else {
Logger . debug ( '[SocketAuthority] User Offline ' + _client . user . username )
this . adminEmitter ( 'user_offline' , _client . user . toJSONForPublic ( this . Server . playbackSessionManager . sessions ) )
const disconnectTime = Date . now ( ) - _client . connected _at
Logger . info ( ` [SocketAuthority] Socket ${ socket . id } disconnected from client " ${ _client . user . username } " after ${ disconnectTime } ms (Reason: ${ reason } ) ` )
delete this . clients [ socket . id ]
}
} )
//
// Events for testing
//
socket . on ( 'message_all_users' , ( payload ) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this . clients [ socket . id ] || { }
if ( client . user ? . isAdminOrUp ) {
this . emitter ( 'admin_message' , payload . message || '' )
} else {
Logger . error ( ` [SocketAuthority] Non-admin user sent the message_all_users event ` )
}
} )
socket . on ( 'ping' , ( ) => {
const client = this . clients [ socket . id ] || { }
const user = client . user || { }
Logger . debug ( ` [SocketAuthority] Received ping from socket ${ user . username || 'No User' } ` )
socket . emit ( 'pong' )
} )
2022-11-24 16:35:26 -06:00
} )
2022-11-24 15:53:58 -06:00
} )
}
2023-11-22 19:00:11 +02:00
/ * *
* When setting up a socket connection the user needs to be associated with a socket id
* for this the client will send a 'auth' event that includes the users API token
2024-08-10 15:46:04 -05:00
*
2025-07-06 11:07:01 -05:00
* Sends event 'init' to the socket . For admins this contains an array of users online .
* For failed authentication it sends event 'auth_failed' with a message
*
2024-08-10 15:46:04 -05:00
* @ param { SocketIO . Socket } socket
2023-11-22 19:00:11 +02:00
* @ param { string } token JWT
* /
2022-11-24 15:53:58 -06:00
async authenticateSocket ( socket , token ) {
2023-11-22 19:00:11 +02:00
// we don't use passport to authenticate the jwt we get over the socket connection.
// it's easier to directly verify/decode it.
2025-07-06 16:43:03 -05:00
// TODO: Support API keys for web socket connections
const token _data = TokenManager . validateAccessToken ( token )
2023-11-22 19:00:11 +02:00
if ( ! token _data ? . userId ) {
// Token invalid
Logger . error ( 'Cannot validate socket - invalid token' )
2025-07-06 11:07:01 -05:00
return socket . emit ( 'auth_failed' , { message : 'Invalid token' } )
2023-11-22 19:00:11 +02:00
}
2024-08-10 15:46:04 -05:00
2023-11-22 19:00:11 +02:00
// get the user via the id from the decoded jwt.
const user = await Database . userModel . getUserByIdOrOldId ( token _data . userId )
2022-11-24 15:53:58 -06:00
if ( ! user ) {
2023-11-22 19:00:11 +02:00
// user not found
2022-11-24 15:53:58 -06:00
Logger . error ( 'Cannot validate socket - invalid token' )
2025-07-06 11:07:01 -05:00
return socket . emit ( 'auth_failed' , { message : 'Invalid token' } )
}
if ( ! user . isActive ) {
Logger . error ( 'Cannot validate socket - user is not active' )
return socket . emit ( 'auth_failed' , { message : 'Invalid user' } )
2022-11-24 15:53:58 -06:00
}
2023-11-22 19:00:11 +02:00
2022-11-24 15:53:58 -06:00
const client = this . clients [ socket . id ]
2023-08-01 16:34:01 -05:00
if ( ! client ) {
Logger . error ( ` [SocketAuthority] Socket for user ${ user . username } has no client ` )
return
}
2022-11-24 15:53:58 -06:00
if ( client . user !== undefined ) {
2025-07-06 11:07:01 -05:00
if ( client . user . id === user . id ) {
// Allow re-authentication of a socket to the same user
Logger . info ( ` [SocketAuthority] Authenticating socket already associated to user " ${ client . user . username } " ` )
} else {
// Allow re-authentication of a socket to a different user but shouldn't happen
Logger . warn ( ` [SocketAuthority] Authenticating socket to user " ${ user . username } ", but is already associated with a different user " ${ client . user . username } " ` )
}
} else {
Logger . debug ( ` [SocketAuthority] Authenticating socket to user " ${ user . username } " ` )
2022-11-24 15:53:58 -06:00
}
client . user = user
2023-08-12 16:11:58 -05:00
this . adminEmitter ( 'user_online' , client . user . toJSONForPublic ( this . Server . playbackSessionManager . sessions ) )
2022-11-24 15:53:58 -06:00
2023-11-24 14:27:32 -06:00
// Update user lastSeen without firing sequelize bulk update hooks
2022-11-24 15:53:58 -06:00
user . lastSeen = Date . now ( )
2024-08-10 15:46:04 -05:00
await user . save ( { hooks : false } )
2022-11-24 15:53:58 -06:00
const initialPayload = {
userId : client . user . id ,
2023-10-21 12:56:35 -05:00
username : client . user . username
2022-11-24 15:53:58 -06:00
}
if ( user . isAdminOrUp ) {
initialPayload . usersOnline = this . getUsersOnline ( )
}
client . socket . emit ( 'init' , initialPayload )
}
cancelScan ( id ) {
2023-08-01 16:34:01 -05:00
Logger . debug ( '[SocketAuthority] Cancel scan' , id )
2023-09-04 11:50:55 -05:00
this . Server . cancelLibraryScan ( id )
2022-11-24 15:53:58 -06:00
}
}
2024-08-10 15:46:04 -05:00
module . exports = new SocketAuthority ( )