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' )
2025-10-02 13:30:03 +03:00
const CoverSearchManager = require ( './managers/CoverSearchManager' )
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
2025-10-02 13:30:03 +03:00
// Cover search streaming
socket . on ( 'search_covers' , ( payload ) => this . handleCoverSearch ( socket , payload ) )
socket . on ( 'cancel_cover_search' , ( requestId ) => this . handleCancelCoverSearch ( socket , requestId ) )
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 } ) ` )
2025-10-02 13:30:03 +03:00
// Cancel any active cover searches for this socket
this . cancelSocketCoverSearches ( socket . id )
2024-11-29 04:13:00 +02:00
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
}
2025-10-02 13:30:03 +03:00
/ * *
* Handle cover search request via WebSocket
* @ param { SocketIO . Socket } socket
* @ param { Object } payload
* /
async handleCoverSearch ( socket , payload ) {
const client = this . clients [ socket . id ]
if ( ! client ? . user ) {
Logger . error ( '[SocketAuthority] Unauthorized cover search request' )
socket . emit ( 'cover_search_error' , {
requestId : payload . requestId ,
error : 'Unauthorized'
} )
return
}
const { requestId , title , author , provider , podcast } = payload
if ( ! requestId || ! title ) {
Logger . error ( '[SocketAuthority] Invalid cover search request' )
socket . emit ( 'cover_search_error' , {
requestId ,
error : 'Invalid request parameters'
} )
return
}
Logger . info ( ` [SocketAuthority] User ${ client . user . username } initiated cover search ${ requestId } ` )
// Callback for streaming results to client
const onResult = ( result ) => {
socket . emit ( 'cover_search_result' , {
requestId ,
provider : result . provider ,
covers : result . covers ,
total : result . total
} )
}
// Callback when search completes
const onComplete = ( ) => {
Logger . info ( ` [SocketAuthority] Cover search ${ requestId } completed ` )
socket . emit ( 'cover_search_complete' , { requestId } )
}
// Callback for provider errors
const onError = ( provider , errorMessage ) => {
socket . emit ( 'cover_search_provider_error' , {
requestId ,
provider ,
error : errorMessage
} )
}
// Start the search
CoverSearchManager . startSearch ( requestId , { title , author , provider , podcast } , onResult , onComplete , onError ) . catch ( ( error ) => {
Logger . error ( ` [SocketAuthority] Cover search ${ requestId } failed: ` , error )
socket . emit ( 'cover_search_error' , {
requestId ,
error : error . message
} )
} )
}
/ * *
* Handle cancel cover search request
* @ param { SocketIO . Socket } socket
* @ param { string } requestId
* /
handleCancelCoverSearch ( socket , requestId ) {
const client = this . clients [ socket . id ]
if ( ! client ? . user ) {
Logger . error ( '[SocketAuthority] Unauthorized cancel cover search request' )
return
}
Logger . info ( ` [SocketAuthority] User ${ client . user . username } cancelled cover search ${ requestId } ` )
const cancelled = CoverSearchManager . cancelSearch ( requestId )
if ( cancelled ) {
socket . emit ( 'cover_search_cancelled' , { requestId } )
}
}
/ * *
* Cancel all cover searches associated with a socket ( called on disconnect )
* @ param { string } socketId
* /
cancelSocketCoverSearches ( socketId ) {
// Get all active search request IDs and cancel those that might belong to this socket
// Since we don't track socket-to-request mapping, we log this for debugging
// The client will handle reconnection gracefully
Logger . debug ( ` [SocketAuthority] Socket ${ socketId } disconnected, any active searches will timeout ` )
}
2022-11-24 15:53:58 -06:00
}
2024-08-10 15:46:04 -05:00
module . exports = new SocketAuthority ( )