socket-io.js 8.0 KB

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