socket-io.js 6.4 KB

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