socket-io.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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 { configManager } from '../config-manager';
  11. import { RoomPrefix, getRoomNameWithId } from './helper';
  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. export class SocketIoService {
  18. crowi: Crowi;
  19. guestClients: Set<string>;
  20. io: Server;
  21. adminNamespace: Namespace;
  22. constructor(crowi: Crowi) {
  23. this.crowi = crowi;
  24. this.guestClients = new Set();
  25. }
  26. get isInitialized(): boolean {
  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(): void {
  64. this.io.engine.use(expressSession(this.crowi.sessionConfig));
  65. this.io.engine.use(passport.initialize());
  66. this.io.engine.use(passport.session());
  67. }
  68. /**
  69. * use loginRequired middleware
  70. */
  71. setupLoginRequiredMiddleware() {
  72. const loginRequired = require('../../middlewares/login-required')(this.crowi, true, (req, res, next) => {
  73. next(new Error('Login is required to connect.'));
  74. });
  75. // convert Connect/Express middleware to Socket.io middleware
  76. this.io.use((socket, next) => {
  77. loginRequired(socket.request, {}, next);
  78. });
  79. }
  80. /**
  81. * use adminRequired middleware
  82. */
  83. setupAdminRequiredMiddleware() {
  84. const adminRequired = require('../../middlewares/admin-required')(this.crowi, (req, res, next) => {
  85. next(new Error('Admin priviledge is required to connect.'));
  86. });
  87. // convert Connect/Express middleware to Socket.io middleware
  88. this.getAdminSocket().use((socket, next) => {
  89. adminRequired(socket.request, {}, next);
  90. });
  91. }
  92. /**
  93. * use checkConnectionLimits middleware
  94. */
  95. setupCheckConnectionLimitsMiddleware() {
  96. this.getAdminSocket().use(this.checkConnectionLimitsForAdmin.bind(this));
  97. this.getDefaultSocket().use(this.checkConnectionLimitsForGuest.bind(this));
  98. this.getDefaultSocket().use(this.checkConnectionLimits.bind(this));
  99. }
  100. setupStoreGuestIdEventHandler() {
  101. this.io.on('connection', (socket) => {
  102. if ((socket.request as RequestWithUser).user == null) {
  103. this.guestClients.add(socket.id);
  104. socket.on('disconnect', () => {
  105. this.guestClients.delete(socket.id);
  106. });
  107. }
  108. });
  109. }
  110. setupLoginedUserRoomsJoinOnConnection() {
  111. this.io.on('connection', (socket) => {
  112. const user = (socket.request as RequestWithUser).user;
  113. if (user == null) {
  114. logger.debug('Socket io: An anonymous user has connected');
  115. return;
  116. }
  117. socket.join(getRoomNameWithId(RoomPrefix.USER, user._id));
  118. });
  119. }
  120. setupDefaultSocketJoinRoomsEventHandler() {
  121. this.io.on('connection', (socket) => {
  122. // set event handlers for joining rooms
  123. socket.on(SocketEventName.JoinPage, ({ pageId }) => {
  124. socket.join(getRoomNameWithId(RoomPrefix.PAGE, pageId));
  125. });
  126. });
  127. }
  128. setupDefaultSocketLeaveRoomsEventHandler() {
  129. this.io.on('connection', (socket) => {
  130. socket.on(SocketEventName.LeavePage, ({ pageId }) => {
  131. socket.leave(getRoomNameWithId(RoomPrefix.PAGE, pageId));
  132. });
  133. });
  134. }
  135. async checkConnectionLimitsForAdmin(socket, next) {
  136. const namespaceName = socket.nsp.name;
  137. if (namespaceName === '/admin') {
  138. const clients = await this.getAdminSocket().fetchSockets();
  139. const clientsCount = clients.length;
  140. logger.debug('Current count of clients for \'/admin\':', clientsCount);
  141. const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForAdmin');
  142. if (limit <= clientsCount) {
  143. const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
  144. logger.warn(msg);
  145. next(new Error(msg));
  146. return;
  147. }
  148. }
  149. next();
  150. }
  151. async checkConnectionLimitsForGuest(socket, next) {
  152. if (socket.request.user == null) {
  153. const clientsCount = this.guestClients.size;
  154. logger.debug('Current count of clients for guests:', clientsCount);
  155. const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForGuest');
  156. if (limit <= clientsCount) {
  157. const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
  158. logger.warn(msg);
  159. next(new Error(msg));
  160. return;
  161. }
  162. }
  163. next();
  164. }
  165. /**
  166. * @see https://socket.io/docs/server-api/#socket-client
  167. */
  168. async checkConnectionLimits(socket, next) {
  169. // exclude admin
  170. const namespaceName = socket.nsp.name;
  171. if (namespaceName === '/admin') {
  172. next();
  173. }
  174. const clients = await this.getDefaultSocket().fetchSockets();
  175. const clientsCount = clients.length;
  176. logger.debug('Current count of clients for \'/\':', clientsCount);
  177. const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimit');
  178. if (limit <= clientsCount) {
  179. const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
  180. logger.warn(msg);
  181. next(new Error(msg));
  182. return;
  183. }
  184. next();
  185. }
  186. }