socket-io.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import loggerFactory from '~/utils/logger';
  2. import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
  3. const socketIo = require('socket.io');
  4. const expressSession = require('express-session');
  5. const passport = require('passport');
  6. const logger = loggerFactory('growi:service:socket-io');
  7. /**
  8. * Serve socket.io for server-to-client messaging
  9. */
  10. class SocketIoService {
  11. constructor(crowi) {
  12. this.crowi = crowi;
  13. this.configManager = crowi.configManager;
  14. this.guestClients = new Set();
  15. }
  16. get isInitialized() {
  17. return (this.io != null);
  18. }
  19. // Since the Order is important, attachServer() should be async
  20. async attachServer(server) {
  21. this.io = socketIo(server, {
  22. transports: ['websocket'],
  23. });
  24. // create namespace for admin
  25. this.adminNamespace = this.io.of('/admin');
  26. // setup middlewares
  27. // !!CAUTION!! -- ORDER IS IMPORTANT
  28. await this.setupSessionMiddleware();
  29. await this.setupLoginRequiredMiddleware();
  30. await this.setupAdminRequiredMiddleware();
  31. await this.setupCheckConnectionLimitsMiddleware();
  32. await this.setupStoreGuestIdEventHandler();
  33. await this.setupLoginedUserRoomsJoinOnConnection();
  34. await this.setupDefaultSocketJoinRoomsEventHandler();
  35. }
  36. getDefaultSocket() {
  37. if (this.io == null) {
  38. throw new Error('Http server has not attached yet.');
  39. }
  40. return this.io.sockets;
  41. }
  42. getAdminSocket() {
  43. if (this.io == null) {
  44. throw new Error('Http server has not attached yet.');
  45. }
  46. return this.adminNamespace;
  47. }
  48. /**
  49. * use passport session
  50. * @see https://socket.io/docs/v4/middlewares/#Compatibility-with-Express-middleware
  51. */
  52. setupSessionMiddleware() {
  53. const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);
  54. this.io.use(wrap(expressSession(this.crowi.sessionConfig)));
  55. this.io.use(wrap(passport.initialize()));
  56. this.io.use(wrap(passport.session()));
  57. // express and passport session on main socket doesn't shared to child namespace socket
  58. // need to define the session for specific namespace
  59. this.getAdminSocket().use(wrap(expressSession(this.crowi.sessionConfig)));
  60. this.getAdminSocket().use(wrap(passport.initialize()));
  61. this.getAdminSocket().use(wrap(passport.session()));
  62. }
  63. /**
  64. * use loginRequired middleware
  65. */
  66. setupLoginRequiredMiddleware() {
  67. const loginRequired = require('../middlewares/login-required')(this.crowi, true, (req, res, next) => {
  68. next(new Error('Login is required to connect.'));
  69. });
  70. // convert Connect/Express middleware to Socket.io middleware
  71. this.io.use((socket, next) => {
  72. loginRequired(socket.request, {}, next);
  73. });
  74. }
  75. /**
  76. * use adminRequired middleware
  77. */
  78. setupAdminRequiredMiddleware() {
  79. const adminRequired = require('../middlewares/admin-required')(this.crowi, (req, res, next) => {
  80. next(new Error('Admin priviledge is required to connect.'));
  81. });
  82. // convert Connect/Express middleware to Socket.io middleware
  83. this.getAdminSocket().use((socket, next) => {
  84. adminRequired(socket.request, {}, next);
  85. });
  86. }
  87. /**
  88. * use checkConnectionLimits middleware
  89. */
  90. setupCheckConnectionLimitsMiddleware() {
  91. this.getAdminSocket().use(this.checkConnectionLimitsForAdmin.bind(this));
  92. this.getDefaultSocket().use(this.checkConnectionLimitsForGuest.bind(this));
  93. this.getDefaultSocket().use(this.checkConnectionLimits.bind(this));
  94. }
  95. setupStoreGuestIdEventHandler() {
  96. this.io.on('connection', (socket) => {
  97. if (socket.request.user == null) {
  98. this.guestClients.add(socket.id);
  99. socket.on('disconnect', () => {
  100. this.guestClients.delete(socket.id);
  101. });
  102. }
  103. });
  104. }
  105. setupLoginedUserRoomsJoinOnConnection() {
  106. this.io.on('connection', (socket) => {
  107. const user = socket.request.user;
  108. if (user == null) {
  109. logger.debug('Socket io: An anonymous user has connected');
  110. return;
  111. }
  112. socket.join(getRoomNameWithId(RoomPrefix.USER, user._id));
  113. });
  114. }
  115. setupDefaultSocketJoinRoomsEventHandler() {
  116. this.io.on('connection', (socket) => {
  117. // set event handlers for joining rooms
  118. socket.on('join:page', ({ pageId }) => {
  119. socket.join(getRoomNameWithId(RoomPrefix.PAGE, pageId));
  120. });
  121. });
  122. }
  123. async checkConnectionLimitsForAdmin(socket, next) {
  124. const namespaceName = socket.nsp.name;
  125. if (namespaceName === '/admin') {
  126. const clients = await this.getAdminSocket().allSockets();
  127. const clientsCount = clients.length;
  128. logger.debug('Current count of clients for \'/admin\':', clientsCount);
  129. const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForAdmin');
  130. if (limit <= clientsCount) {
  131. const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
  132. logger.warn(msg);
  133. next(new Error(msg));
  134. return;
  135. }
  136. }
  137. next();
  138. }
  139. async checkConnectionLimitsForGuest(socket, next) {
  140. if (socket.request.user == null) {
  141. const clientsCount = this.guestClients.size;
  142. logger.debug('Current count of clients for guests:', clientsCount);
  143. const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForGuest');
  144. if (limit <= clientsCount) {
  145. const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
  146. logger.warn(msg);
  147. next(new Error(msg));
  148. return;
  149. }
  150. }
  151. next();
  152. }
  153. /**
  154. * @see https://socket.io/docs/server-api/#socket-client
  155. */
  156. async checkConnectionLimits(socket, next) {
  157. // exclude admin
  158. const namespaceName = socket.nsp.name;
  159. if (namespaceName === '/admin') {
  160. next();
  161. }
  162. const clients = await this.getDefaultSocket().allSockets();
  163. const clientsCount = clients.length;
  164. logger.debug('Current count of clients for \'/\':', clientsCount);
  165. const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimit');
  166. if (limit <= clientsCount) {
  167. const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
  168. logger.warn(msg);
  169. next(new Error(msg));
  170. return;
  171. }
  172. next();
  173. }
  174. }
  175. module.exports = SocketIoService;