Router.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. const metrics = require('@overleaf/metrics')
  2. const logger = require('@overleaf/logger')
  3. const settings = require('@overleaf/settings')
  4. const WebsocketController = require('./WebsocketController')
  5. const HttpController = require('./HttpController')
  6. const HttpApiController = require('./HttpApiController')
  7. const WebsocketAddressManager = require('./WebsocketAddressManager')
  8. const bodyParser = require('body-parser')
  9. const base64id = require('base64id')
  10. const { UnexpectedArgumentsError } = require('./Errors')
  11. const { z, zz } = require('@overleaf/validation-tools')
  12. const { isZodErrorLike } = require('zod-validation-error')
  13. const HOSTNAME = require('node:os').hostname()
  14. const SERVER_PING_INTERVAL = 15000
  15. const SERVER_PING_LATENCY_THRESHOLD = 5000
  16. const joinDocSchema = z.object({
  17. doc_id: zz.objectId(),
  18. fromVersion: z.number().int().optional(),
  19. options: z.object(),
  20. })
  21. const applyOtUpdateSchema = z.object({
  22. doc_id: zz.objectId(),
  23. update: z.object(),
  24. })
  25. let Router
  26. module.exports = Router = {
  27. _handleError(callback, error, client, method, attrs) {
  28. attrs = attrs || {}
  29. for (const key of ['project_id', 'user_id']) {
  30. attrs[key] = attrs[key] || client.ol_context[key]
  31. }
  32. attrs.client_id = client.id
  33. attrs.err = error
  34. attrs.method = method
  35. if (isZodErrorLike(error)) {
  36. logger.info(attrs, 'validation error')
  37. let message = 'invalid'
  38. try {
  39. message = error.issues[0].message
  40. } catch (e) {
  41. // ignore unexpected errors
  42. logger.warn({ error, e }, 'unexpected validation error')
  43. }
  44. const serializedError = { message }
  45. metrics.inc('validation-error', 1, {
  46. status: method,
  47. })
  48. callback(serializedError)
  49. } else if (error.name === 'CodedError') {
  50. logger.warn(attrs, error.message)
  51. const serializedError = { message: error.message, code: error.info.code }
  52. callback(serializedError)
  53. } else if (error.message === 'unexpected arguments') {
  54. // the payload might be very large; put it on level debug
  55. logger.debug(attrs, 'unexpected arguments')
  56. metrics.inc('unexpected-arguments', 1, { status: method })
  57. const serializedError = { message: error.message }
  58. callback(serializedError)
  59. } else if (error.message === 'no project_id found on client') {
  60. logger.debug(attrs, error.message)
  61. const serializedError = { message: error.message }
  62. callback(serializedError)
  63. } else if (
  64. [
  65. 'not authorized',
  66. 'joinLeaveEpoch mismatch',
  67. 'doc updater could not load requested ops',
  68. 'no project_id found on client',
  69. 'cannot join multiple projects',
  70. ].includes(error.message)
  71. ) {
  72. logger.warn(attrs, error.message)
  73. const serializedError = { message: error.message }
  74. callback(serializedError)
  75. } else {
  76. logger.error(attrs, `server side error in ${method}`)
  77. // Don't return raw error to prevent leaking server side info
  78. const serializedError = {
  79. message: 'Something went wrong in real-time service',
  80. }
  81. callback(serializedError)
  82. }
  83. if (attrs.disconnect) {
  84. setTimeout(function () {
  85. client.disconnect()
  86. }, 100)
  87. }
  88. },
  89. _handleInvalidArguments(client, method, args) {
  90. const error = new UnexpectedArgumentsError()
  91. let callback = args[args.length - 1]
  92. if (typeof callback !== 'function') {
  93. callback = function () {}
  94. }
  95. const attrs = { arguments: args }
  96. Router._handleError(callback, error, client, method, attrs)
  97. },
  98. configure(app, io, session) {
  99. app.set('io', io)
  100. if (settings.behindProxy) {
  101. app.set('trust proxy', settings.trustedProxyIps)
  102. }
  103. const websocketAddressManager = new WebsocketAddressManager(
  104. settings.behindProxy,
  105. settings.trustedProxyIps
  106. )
  107. app.get('/clients', HttpController.getConnectedClients)
  108. app.get('/clients/:client_id', HttpController.getConnectedClient)
  109. app.post(
  110. '/project/:project_id/message/:message',
  111. bodyParser.json({ limit: '5mb' }),
  112. HttpApiController.sendMessage
  113. )
  114. app.get(
  115. '/project/:projectId/count-connected-clients',
  116. HttpApiController.countConnectedClients
  117. )
  118. app.post('/drain', HttpApiController.startDrain)
  119. app.post(
  120. '/client/:client_id/disconnect',
  121. HttpApiController.disconnectClient
  122. )
  123. session.on('connection', function (error, client, session) {
  124. // init client context, we may access it in Router._handleError before
  125. // setting any values
  126. client.ol_context = {}
  127. // bail out from joinDoc when a parallel joinDoc or leaveDoc is running
  128. client.joinLeaveEpoch = 0
  129. if (client) {
  130. client.on('error', function (err) {
  131. logger.err(
  132. { clientErr: err, publicId: client.publicId, clientId: client.id },
  133. 'socket.io client error'
  134. )
  135. if (client.connected) {
  136. client.emit('reconnectGracefully')
  137. client.disconnect()
  138. }
  139. })
  140. }
  141. if (settings.shutDownInProgress) {
  142. client.emit('connectionRejected', { message: 'retry' })
  143. client.disconnect()
  144. return
  145. }
  146. if (
  147. client &&
  148. error &&
  149. error.message.match(/could not look up session by key/)
  150. ) {
  151. logger.warn(
  152. { err: error, client: !!client, session: !!session },
  153. 'invalid session'
  154. )
  155. // tell the client to reauthenticate if it has an invalid session key
  156. client.emit('connectionRejected', { message: 'invalid session' })
  157. client.disconnect()
  158. return
  159. }
  160. if (error) {
  161. logger.err(
  162. { err: error, client: !!client, session: !!session },
  163. 'error when client connected'
  164. )
  165. if (client) {
  166. client.emit('connectionRejected', { message: 'error' })
  167. }
  168. if (client) {
  169. client.disconnect()
  170. }
  171. return
  172. }
  173. const useServerPing =
  174. !!client.handshake?.query?.esh &&
  175. !!client.handshake?.query?.ssp &&
  176. // No server ping with long-polling transports.
  177. client.transport === 'websocket'
  178. const isDebugging = !!client.handshake?.query?.debugging
  179. const projectId = client.handshake?.query?.projectId
  180. if (isDebugging) {
  181. client.connectedAt = Date.now()
  182. client.isDebugging = true
  183. }
  184. if (!isDebugging) {
  185. try {
  186. zz.objectId().parse(projectId)
  187. } catch (error) {
  188. metrics.inc('socket-io.connection', 1, {
  189. status: client.transport,
  190. method: projectId ? 'bad-project-id' : 'missing-project-id',
  191. })
  192. client.emit('connectionRejected', {
  193. message: 'missing/bad ?projectId=... query flag on handshake',
  194. })
  195. client.disconnect()
  196. return
  197. }
  198. }
  199. // The client.id is security sensitive. Generate a publicId for sending to other clients.
  200. client.publicId = 'P.' + base64id.generateId()
  201. client.remoteIp = websocketAddressManager.getRemoteIp(client.handshake)
  202. const headers = client.handshake && client.handshake.headers
  203. client.userAgent = headers && headers['user-agent']
  204. metrics.inc('socket-io.connection', 1, {
  205. status: client.transport,
  206. method: 'auto-join-project',
  207. })
  208. metrics.gauge('socket-io.clients', io.sockets.clients().length)
  209. let user
  210. if (session && session.passport && session.passport.user) {
  211. ;({ user } = session.passport)
  212. } else if (session && session.user) {
  213. ;({ user } = session)
  214. } else {
  215. const anonymousAccessToken = session?.anonTokenAccess?.[projectId]
  216. user = { _id: 'anonymous-user', anonymousAccessToken }
  217. }
  218. const info = {
  219. userId: user._id,
  220. projectId,
  221. transport: client.transport,
  222. publicId: client.publicId,
  223. clientId: client.id,
  224. isDebugging,
  225. }
  226. if (isDebugging) {
  227. logger.info(info, 'client connected')
  228. } else {
  229. logger.debug(info, 'client connected')
  230. }
  231. const connectionDetails = {
  232. userId: user._id,
  233. projectId,
  234. remoteIp: client.remoteIp,
  235. publicId: client.publicId,
  236. clientId: client.id,
  237. }
  238. let pingTimestamp
  239. let pingId = -1
  240. let pongId = -1
  241. const pingTimer = useServerPing
  242. ? setInterval(function () {
  243. if (pongId !== pingId) {
  244. logger.warn(
  245. {
  246. ...connectionDetails,
  247. pingId,
  248. pongId,
  249. lastPingTimestamp: pingTimestamp,
  250. },
  251. 'no client response to last ping'
  252. )
  253. }
  254. pingTimestamp = Date.now()
  255. client.emit(
  256. 'serverPing',
  257. ++pingId,
  258. pingTimestamp,
  259. client.transport,
  260. client.id
  261. )
  262. }, SERVER_PING_INTERVAL)
  263. : null
  264. client.on(
  265. 'clientPong',
  266. function (
  267. receivedPingId,
  268. sentTimestamp,
  269. serverTransport,
  270. serverSessionId,
  271. clientTransport,
  272. clientSessionId
  273. ) {
  274. pongId = receivedPingId
  275. const receivedTimestamp = Date.now()
  276. if (
  277. receivedPingId !== pingId ||
  278. (serverSessionId && serverSessionId !== clientSessionId)
  279. ) {
  280. logger.warn(
  281. {
  282. ...connectionDetails,
  283. receivedPingId,
  284. pingId,
  285. sentTimestamp,
  286. receivedTimestamp,
  287. latency: receivedTimestamp - sentTimestamp,
  288. lastPingTimestamp: pingTimestamp,
  289. serverTransport,
  290. serverSessionId,
  291. clientTransport,
  292. clientSessionId,
  293. },
  294. 'received pong with wrong counter'
  295. )
  296. } else if (
  297. receivedTimestamp - sentTimestamp >
  298. SERVER_PING_LATENCY_THRESHOLD
  299. ) {
  300. logger.warn(
  301. {
  302. ...connectionDetails,
  303. receivedPingId,
  304. pingId,
  305. sentTimestamp,
  306. receivedTimestamp,
  307. latency: receivedTimestamp - sentTimestamp,
  308. lastPingTimestamp: pingTimestamp,
  309. },
  310. 'received pong with high latency'
  311. )
  312. }
  313. }
  314. )
  315. if (settings.exposeHostname) {
  316. client.on('debug.getHostname', function (callback) {
  317. if (typeof callback !== 'function') {
  318. return Router._handleInvalidArguments(
  319. client,
  320. 'debug.getHostname',
  321. arguments
  322. )
  323. }
  324. callback(HOSTNAME)
  325. })
  326. }
  327. client.on('debug', (data, callback) => {
  328. if (typeof callback !== 'function') {
  329. return Router._handleInvalidArguments(client, 'debug', arguments)
  330. }
  331. logger.info(
  332. { publicId: client.publicId, clientId: client.id },
  333. 'received debug message'
  334. )
  335. const response = {
  336. serverTime: Date.now(),
  337. data,
  338. client: {
  339. publicId: client.publicId,
  340. remoteIp: client.remoteIp,
  341. userAgent: client.userAgent,
  342. connected: !client.disconnected,
  343. connectedAt: client.connectedAt,
  344. },
  345. server: {
  346. hostname: settings.exposeHostname ? HOSTNAME : undefined,
  347. },
  348. }
  349. callback(response)
  350. })
  351. const joinProject = function (callback) {
  352. WebsocketController.joinProject(
  353. client,
  354. user,
  355. projectId,
  356. function (err, ...args) {
  357. if (err) {
  358. Router._handleError(callback, err, client, 'joinProject', {
  359. project_id: projectId,
  360. user_id: user._id,
  361. })
  362. } else {
  363. callback(null, ...args)
  364. }
  365. }
  366. )
  367. }
  368. client.on('disconnect', function () {
  369. metrics.inc('socket-io.disconnect', 1, { status: client.transport })
  370. metrics.gauge('socket-io.clients', io.sockets.clients().length)
  371. if (client.isDebugging) {
  372. const duration = Date.now() - client.connectedAt
  373. metrics.timing('socket-io.debugging.duration', duration)
  374. logger.info(
  375. { duration, publicId: client.publicId, clientId: client.id },
  376. 'debug client disconnected'
  377. )
  378. } else {
  379. clearInterval(pingTimer)
  380. }
  381. WebsocketController.leaveProject(io, client, function (err) {
  382. if (err) {
  383. Router._handleError(function () {}, err, client, 'leaveProject')
  384. }
  385. })
  386. })
  387. // Variadic. The possible arguments:
  388. // doc_id, callback
  389. // doc_id, fromVersion, callback
  390. // doc_id, options, callback
  391. // doc_id, fromVersion, options, callback
  392. client.on('joinDoc', function (docId, fromVersion, options, callback) {
  393. if (typeof fromVersion === 'function' && !options) {
  394. callback = fromVersion
  395. fromVersion = -1
  396. options = {}
  397. } else if (
  398. typeof fromVersion === 'number' &&
  399. typeof options === 'function'
  400. ) {
  401. callback = options
  402. options = {}
  403. } else if (
  404. typeof fromVersion === 'object' &&
  405. typeof options === 'function'
  406. ) {
  407. callback = options
  408. options = fromVersion
  409. fromVersion = -1
  410. } else if (
  411. typeof fromVersion === 'number' &&
  412. typeof options === 'object' &&
  413. typeof callback === 'function'
  414. ) {
  415. // Called with 4 args, things are as expected
  416. } else {
  417. return Router._handleInvalidArguments(client, 'joinDoc', arguments)
  418. }
  419. try {
  420. joinDocSchema.parse({ doc_id: docId, fromVersion, options })
  421. } catch (error) {
  422. return Router._handleError(callback, error, client, 'joinDoc', {
  423. disconnect: 1,
  424. })
  425. }
  426. WebsocketController.joinDoc(
  427. client,
  428. docId,
  429. fromVersion,
  430. options,
  431. function (err, ...args) {
  432. if (err) {
  433. Router._handleError(callback, err, client, 'joinDoc', {
  434. doc_id: docId,
  435. fromVersion,
  436. })
  437. } else {
  438. callback(null, ...args)
  439. }
  440. }
  441. )
  442. })
  443. client.on('leaveDoc', function (docId, callback) {
  444. if (typeof callback !== 'function') {
  445. return Router._handleInvalidArguments(client, 'leaveDoc', arguments)
  446. }
  447. try {
  448. zz.objectId().parse(docId)
  449. } catch (error) {
  450. return Router._handleError(callback, error, client, 'joinDoc', {
  451. disconnect: 1,
  452. })
  453. }
  454. WebsocketController.leaveDoc(client, docId, function (err, ...args) {
  455. if (err) {
  456. Router._handleError(callback, err, client, 'leaveDoc', {
  457. doc_id: docId,
  458. })
  459. } else {
  460. callback(null, ...args)
  461. }
  462. })
  463. })
  464. client.on('clientTracking.getConnectedUsers', function (callback) {
  465. if (typeof callback !== 'function') {
  466. return Router._handleInvalidArguments(
  467. client,
  468. 'clientTracking.getConnectedUsers',
  469. arguments
  470. )
  471. }
  472. WebsocketController.getConnectedUsers(client, function (err, users) {
  473. if (err) {
  474. Router._handleError(
  475. callback,
  476. err,
  477. client,
  478. 'clientTracking.getConnectedUsers'
  479. )
  480. } else {
  481. callback(null, users)
  482. }
  483. })
  484. })
  485. client.on(
  486. 'clientTracking.updatePosition',
  487. function (cursorData, callback) {
  488. if (!callback) {
  489. callback = function () {
  490. // NOTE: The frontend does not pass any callback to socket.io.
  491. // Any error is already logged via Router._handleError.
  492. }
  493. }
  494. if (typeof callback !== 'function') {
  495. return Router._handleInvalidArguments(
  496. client,
  497. 'clientTracking.updatePosition',
  498. arguments
  499. )
  500. }
  501. WebsocketController.updateClientPosition(
  502. client,
  503. cursorData,
  504. function (err) {
  505. if (err) {
  506. Router._handleError(
  507. callback,
  508. err,
  509. client,
  510. 'clientTracking.updatePosition'
  511. )
  512. } else {
  513. callback()
  514. }
  515. }
  516. )
  517. }
  518. )
  519. client.on('applyOtUpdate', function (docId, update, callback) {
  520. if (typeof callback !== 'function') {
  521. return Router._handleInvalidArguments(
  522. client,
  523. 'applyOtUpdate',
  524. arguments
  525. )
  526. }
  527. try {
  528. applyOtUpdateSchema.parse({ doc_id: docId, update })
  529. } catch (error) {
  530. return Router._handleError(callback, error, client, 'applyOtUpdate', {
  531. disconnect: 1,
  532. })
  533. }
  534. WebsocketController.applyOtUpdate(
  535. client,
  536. docId,
  537. update,
  538. function (err) {
  539. if (err) {
  540. Router._handleError(callback, err, client, 'applyOtUpdate', {
  541. doc_id: docId,
  542. })
  543. } else {
  544. callback()
  545. }
  546. }
  547. )
  548. })
  549. if (!isDebugging) {
  550. joinProject((err, project, permissionsLevel, protocolVersion) => {
  551. if (err) {
  552. client.emit('connectionRejected', err)
  553. client.disconnect()
  554. return
  555. }
  556. client.emit('joinProjectResponse', {
  557. publicId: client.publicId,
  558. project,
  559. permissionsLevel,
  560. protocolVersion,
  561. })
  562. })
  563. }
  564. })
  565. },
  566. }