audiobookshelf/server/SocketAuthority.js

233 lines
7.2 KiB
JavaScript
Raw Normal View History

const SocketIO = require('socket.io')
const Logger = require('./Logger')
2023-07-05 01:14:44 +02:00
const Database = require('./Database')
const Auth = require('./Auth')
/**
* @typedef SocketClient
* @property {string} id socket id
* @property {SocketIO.Socket} socket
* @property {number} connected_at
* @property {import('./models/User')} user
*/
class SocketAuthority {
constructor() {
this.Server = null
this.io = null
/** @type {Object.<string, SocketClient>} */
this.clients = {}
}
/**
* returns an array of User.toJSONForPublic with `connections` for the # of socket connections
* a user can have many socket connections
* @returns {object[]}
*/
getUsersOnline() {
const onlineUsersMap = {}
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
}
}
})
return Object.values(onlineUsersMap)
}
getClientsForUser(userId) {
return Object.values(this.clients).filter((c) => c.user?.id === userId)
}
/**
* Emits event to all authorized clients
* @param {string} evt
* @param {any} data
* @param {Function} [filter] optional filter function to only send event to specific users
*/
emitter(evt, data, filter = null) {
for (const socketId in this.clients) {
if (this.clients[socketId].user) {
if (filter && !filter(this.clients[socketId].user)) continue
this.clients[socketId].socket.emit(evt, data)
}
}
}
// Emits event to all clients for a specific user
clientEmitter(userId, evt, data) {
const clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.debug(`[SocketAuthority] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
client.socket.emit(evt, data)
}
})
}
// Emits event to all admin user clients
adminEmitter(evt, data) {
for (const socketId in this.clients) {
if (this.clients[socketId].user?.isAdminOrUp) {
this.clients[socketId].socket.emit(evt, data)
}
}
}
2023-12-28 23:32:21 +01:00
/**
* Closes the Socket.IO server and disconnect all clients
*
* @param {Function} callback
2023-12-28 23:32:21 +01:00
*/
2023-12-27 14:33:33 +01:00
close(callback) {
Logger.info('[SocketAuthority] Shutting down')
// This will close all open socket connections, and also close the underlying http server
if (this.io) this.io.close(callback)
else callback()
2023-12-27 14:33:33 +01:00
}
initialize(Server) {
this.Server = Server
this.io = new SocketIO.Server(this.Server.server, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[SocketAuthority] Socket Connected', socket.id)
// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// 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')
})
})
}
/**
* 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
*
* @param {SocketIO.Socket} socket
* @param {string} token JWT
*/
async authenticateSocket(socket, token) {
// we don't use passport to authenticate the jwt we get over the socket connection.
// it's easier to directly verify/decode it.
const token_data = Auth.validateAccessToken(token)
if (!token_data?.userId) {
// Token invalid
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
// get the user via the id from the decoded jwt.
const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
if (!user) {
// user not found
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
const client = this.clients[socket.id]
if (!client) {
Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)
return
}
if (client.user !== undefined) {
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
}
client.user = user
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen without firing sequelize bulk update hooks
user.lastSeen = Date.now()
await user.save({ hooks: false })
const initialPayload = {
userId: client.user.id,
2023-10-21 19:56:35 +02:00
username: client.user.username
}
if (user.isAdminOrUp) {
initialPayload.usersOnline = this.getUsersOnline()
}
client.socket.emit('init', initialPayload)
}
cancelScan(id) {
Logger.debug('[SocketAuthority] Cancel scan', id)
this.Server.cancelLibraryScan(id)
}
}
module.exports = new SocketAuthority()