socket-io.ts 7.1 KB

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