mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-07 08:34:10 +01:00
202ceb02b5
- Express requests include userNew to start migrating API controllers to new user model
233 lines
7.2 KiB
JavaScript
233 lines
7.2 KiB
JavaScript
const SocketIO = require('socket.io')
|
|
const Logger = require('./Logger')
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes the Socket.IO server and disconnect all clients
|
|
*
|
|
* @param {Function} callback
|
|
*/
|
|
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()
|
|
}
|
|
|
|
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,
|
|
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()
|