socket-io.js 8.3 KB

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