DocumentUpdaterHandler.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. const request = require('request').defaults({ timeout: 30 * 1000 })
  2. const OError = require('@overleaf/o-error')
  3. const settings = require('@overleaf/settings')
  4. const _ = require('lodash')
  5. const async = require('async')
  6. const logger = require('@overleaf/logger')
  7. const metrics = require('@overleaf/metrics')
  8. const { promisify, callbackify } = require('util')
  9. const { promisifyMultiResult } = require('@overleaf/promise-utils')
  10. const ProjectGetter = require('../Project/ProjectGetter')
  11. const Modules = require('../../infrastructure/Modules')
  12. function getProjectLastUpdatedAt(projectId, callback) {
  13. _makeRequest(
  14. {
  15. path: `/project/${projectId}/last_updated_at`,
  16. method: 'GET',
  17. json: true,
  18. },
  19. projectId,
  20. 'project.redis.last_updated_at',
  21. (err, body) => {
  22. if (err || !body?.lastUpdatedAt) return callback(err, null)
  23. callback(null, new Date(body.lastUpdatedAt))
  24. }
  25. )
  26. }
  27. /**
  28. * @param {string} projectId
  29. */
  30. function flushProjectToMongo(projectId, callback) {
  31. _makeRequest(
  32. {
  33. path: `/project/${projectId}/flush`,
  34. method: 'POST',
  35. },
  36. projectId,
  37. 'flushing.mongo.project',
  38. callback
  39. )
  40. }
  41. function flushMultipleProjectsToMongo(projectIds, callback) {
  42. const jobs = projectIds.map(projectId => callback => {
  43. flushProjectToMongo(projectId, callback)
  44. })
  45. async.series(jobs, callback)
  46. }
  47. /**
  48. * @param {string} projectId
  49. */
  50. function flushProjectToMongoAndDelete(projectId, callback) {
  51. _makeRequest(
  52. {
  53. path: `/project/${projectId}`,
  54. method: 'DELETE',
  55. },
  56. projectId,
  57. 'flushing.mongo.project',
  58. callback
  59. )
  60. }
  61. /**
  62. * @param {string} projectId
  63. * @param {string} docId
  64. * @param {Callback} callback
  65. */
  66. function flushDocToMongo(projectId, docId, callback) {
  67. _makeRequest(
  68. {
  69. path: `/project/${projectId}/doc/${docId}/flush`,
  70. method: 'POST',
  71. },
  72. projectId,
  73. 'flushing.mongo.doc',
  74. callback
  75. )
  76. }
  77. function deleteDoc(projectId, docId, ignoreFlushErrors, callback) {
  78. if (typeof ignoreFlushErrors === 'function') {
  79. callback = ignoreFlushErrors
  80. ignoreFlushErrors = false
  81. }
  82. let path = `/project/${projectId}/doc/${docId}`
  83. if (ignoreFlushErrors) {
  84. path += '?ignore_flush_errors=true'
  85. }
  86. const method = 'DELETE'
  87. _makeRequest(
  88. {
  89. path,
  90. method,
  91. },
  92. projectId,
  93. 'delete.mongo.doc',
  94. callback
  95. )
  96. }
  97. function getComment(projectId, docId, commentId, callback) {
  98. _makeRequest(
  99. {
  100. path: `/project/${projectId}/doc/${docId}/comment/${commentId}`,
  101. json: true,
  102. },
  103. projectId,
  104. 'get-comment',
  105. function (error, comment) {
  106. if (error) {
  107. return callback(error)
  108. }
  109. callback(null, comment)
  110. }
  111. )
  112. }
  113. function getDocument(projectId, docId, fromVersion, callback) {
  114. _makeRequest(
  115. {
  116. path: `/project/${projectId}/doc/${docId}?fromVersion=${fromVersion}`,
  117. json: true,
  118. },
  119. projectId,
  120. 'get-document',
  121. function (error, doc) {
  122. if (error) {
  123. return callback(error)
  124. }
  125. callback(null, doc.lines, doc.version, doc.ranges, doc.ops)
  126. }
  127. )
  128. }
  129. /**
  130. * Get a document with its history ranges
  131. * @param {string} projectId
  132. * @param {string} docId
  133. * @param {Callback} callback
  134. */
  135. function getDocumentWithHistoryRanges(projectId, docId, callback) {
  136. _makeRequest(
  137. {
  138. path: `/project/${projectId}/doc/${docId}?historyRanges=true`,
  139. json: true,
  140. },
  141. projectId,
  142. 'get-document-with-history-ranges',
  143. function (error, doc) {
  144. if (error) {
  145. return callback(error)
  146. }
  147. callback(null, doc)
  148. }
  149. )
  150. }
  151. function setDocument(projectId, docId, userId, docLines, source, callback) {
  152. _makeRequest(
  153. {
  154. path: `/project/${projectId}/doc/${docId}`,
  155. method: 'POST',
  156. json: {
  157. lines: docLines,
  158. source,
  159. user_id: userId,
  160. },
  161. },
  162. projectId,
  163. 'set-document',
  164. callback
  165. )
  166. }
  167. function appendToDocument(projectId, docId, userId, lines, source, callback) {
  168. _makeRequest(
  169. {
  170. path: `/project/${projectId}/doc/${docId}/append`,
  171. method: 'POST',
  172. json: {
  173. lines,
  174. source,
  175. user_id: userId,
  176. },
  177. },
  178. projectId,
  179. 'append-to-document',
  180. callback
  181. )
  182. }
  183. function getProjectDocsIfMatch(projectId, projectStateHash, callback) {
  184. // If the project state hasn't changed, we can get all the latest
  185. // docs from redis via the docupdater. Otherwise we will need to
  186. // fall back to getting them from mongo.
  187. const timer = new metrics.Timer('get-project-docs')
  188. const url = `${settings.apis.documentupdater.url}/project/${projectId}/get_and_flush_if_old?state=${projectStateHash}`
  189. request.post(url, function (error, res, body) {
  190. timer.done()
  191. if (error) {
  192. OError.tag(error, 'error getting project docs from doc updater', {
  193. url,
  194. projectId,
  195. })
  196. return callback(error)
  197. }
  198. if (res.statusCode === 409) {
  199. // HTTP response code "409 Conflict"
  200. // Docupdater has checked the projectStateHash and found that
  201. // it has changed. This means that the docs currently in redis
  202. // aren't the only change to the project and the full set of
  203. // docs/files should be retreived from docstore/filestore
  204. // instead.
  205. callback()
  206. } else if (res.statusCode >= 200 && res.statusCode < 300) {
  207. let docs
  208. try {
  209. docs = JSON.parse(body)
  210. } catch (error1) {
  211. return callback(OError.tag(error1))
  212. }
  213. callback(null, docs)
  214. } else {
  215. callback(
  216. new OError(
  217. `doc updater returned a non-success status code: ${res.statusCode}`,
  218. {
  219. projectId,
  220. url,
  221. }
  222. )
  223. )
  224. }
  225. })
  226. }
  227. function clearProjectState(projectId, callback) {
  228. _makeRequest(
  229. {
  230. path: `/project/${projectId}/clearState`,
  231. method: 'POST',
  232. },
  233. projectId,
  234. 'clear-project-state',
  235. callback
  236. )
  237. }
  238. /**
  239. * @param {string} projectId
  240. * @param {string} docId
  241. * @param {string[]} changeIds
  242. * @param {Callback} callback
  243. */
  244. async function acceptChanges(projectId, docId, changeIds) {
  245. await _makeRequestAsync(
  246. {
  247. path: `/project/${projectId}/doc/${docId}/change/accept`,
  248. json: { change_ids: changeIds },
  249. method: 'POST',
  250. },
  251. projectId,
  252. 'accept-changes'
  253. )
  254. await Modules.promises.hooks.fire('changesAccepted', projectId, docId)
  255. }
  256. /**
  257. * @param {string} projectId
  258. * @param {string} docId
  259. * @param {string[]} changeIds
  260. * @param {Callback} callback
  261. */
  262. function rejectChanges(projectId, docId, changeIds, userId, callback) {
  263. _makeRequest(
  264. {
  265. path: `/project/${projectId}/doc/${docId}/change/reject`,
  266. json: { change_ids: changeIds, user_id: userId },
  267. method: 'POST',
  268. },
  269. projectId,
  270. 'reject-changes',
  271. callback
  272. )
  273. }
  274. /**
  275. * @param {string} projectId
  276. * @param {string} docId
  277. * @param {string} threadId
  278. * @param {string} userId
  279. * @param {Callback} callback
  280. */
  281. function resolveThread(projectId, docId, threadId, userId, callback) {
  282. _makeRequest(
  283. {
  284. path: `/project/${projectId}/doc/${docId}/comment/${threadId}/resolve`,
  285. method: 'POST',
  286. json: {
  287. user_id: userId,
  288. },
  289. },
  290. projectId,
  291. 'resolve-thread',
  292. callback
  293. )
  294. }
  295. /**
  296. * @param {string} projectId
  297. * @param {string} docId
  298. * @param {string} threadId
  299. * @param {string} userId
  300. * @param {Callback} callback
  301. */
  302. function reopenThread(projectId, docId, threadId, userId, callback) {
  303. _makeRequest(
  304. {
  305. path: `/project/${projectId}/doc/${docId}/comment/${threadId}/reopen`,
  306. method: 'POST',
  307. json: {
  308. user_id: userId,
  309. },
  310. },
  311. projectId,
  312. 'reopen-thread',
  313. callback
  314. )
  315. }
  316. function deleteThread(projectId, docId, threadId, userId, callback) {
  317. _makeRequest(
  318. {
  319. path: `/project/${projectId}/doc/${docId}/comment/${threadId}`,
  320. method: 'DELETE',
  321. json: {
  322. user_id: userId,
  323. },
  324. },
  325. projectId,
  326. 'delete-thread',
  327. callback
  328. )
  329. }
  330. function resyncProjectHistory(
  331. projectId,
  332. projectHistoryId,
  333. docs,
  334. files,
  335. opts,
  336. callback
  337. ) {
  338. docs = docs.map(doc => ({
  339. doc: doc.doc._id,
  340. path: doc.path,
  341. }))
  342. // Files without a hash likely do not have a blob. Abort.
  343. for (const { file } of files) {
  344. if (!file.hash) {
  345. return callback(
  346. new OError('found file with missing hash', { projectId, file })
  347. )
  348. }
  349. }
  350. files = files.map(file => ({
  351. file: file.file._id,
  352. path: file.path,
  353. _hash: file.file.hash,
  354. createdBlob: true,
  355. metadata: buildFileMetadataForHistory(file.file),
  356. }))
  357. const body = { docs, files, projectHistoryId }
  358. if (opts.historyRangesMigration) {
  359. body.historyRangesMigration = opts.historyRangesMigration
  360. }
  361. if (opts.resyncProjectStructureOnly) {
  362. body.resyncProjectStructureOnly = opts.resyncProjectStructureOnly
  363. }
  364. _makeRequest(
  365. {
  366. path: `/project/${projectId}/history/resync`,
  367. json: body,
  368. method: 'POST',
  369. timeout: 6 * 60 * 1000, // allow 6 minutes for resync
  370. },
  371. projectId,
  372. 'resync-project-history',
  373. callback
  374. )
  375. }
  376. /**
  377. * Block a project from being loaded in docupdater
  378. *
  379. * @param {string} projectId
  380. * @param {Callback} callback
  381. */
  382. function blockProject(projectId, callback) {
  383. _makeRequest(
  384. { path: `/project/${projectId}/block`, method: 'POST', json: true },
  385. projectId,
  386. 'block-project',
  387. (err, body) => {
  388. if (err) {
  389. return callback(err)
  390. }
  391. callback(null, body.blocked)
  392. }
  393. )
  394. }
  395. /**
  396. * Unblock a previously blocked project
  397. *
  398. * @param {string} projectId
  399. * @param {Callback} callback
  400. */
  401. function unblockProject(projectId, callback) {
  402. _makeRequest(
  403. { path: `/project/${projectId}/unblock`, method: 'POST', json: true },
  404. projectId,
  405. 'unblock-project',
  406. (err, body) => {
  407. if (err) {
  408. return callback(err)
  409. }
  410. callback(null, body.wasBlocked)
  411. }
  412. )
  413. }
  414. function updateProjectStructure(
  415. projectId,
  416. projectHistoryId,
  417. userId,
  418. changes,
  419. source,
  420. callback
  421. ) {
  422. if (
  423. settings.apis.project_history == null ||
  424. !settings.apis.project_history.sendProjectStructureOps
  425. ) {
  426. return callback()
  427. }
  428. ProjectGetter.getProjectWithoutLock(
  429. projectId,
  430. { overleaf: true },
  431. (err, project) => {
  432. if (err) {
  433. return callback(err)
  434. }
  435. const historyRangesSupport = _.get(
  436. project,
  437. 'overleaf.history.rangesSupportEnabled',
  438. false
  439. )
  440. const {
  441. deletes: docDeletes,
  442. adds: docAdds,
  443. renames: docRenames,
  444. } = _getUpdates(
  445. 'doc',
  446. changes.oldDocs,
  447. changes.newDocs,
  448. historyRangesSupport
  449. )
  450. for (const newEntity of changes.newFiles || []) {
  451. if (!newEntity.file.hash) {
  452. // Files without a hash likely do not have a blob. Abort.
  453. return callback(
  454. new OError('found file with missing hash', { newEntity })
  455. )
  456. }
  457. }
  458. const {
  459. deletes: fileDeletes,
  460. adds: fileAdds,
  461. renames: fileRenames,
  462. } = _getUpdates(
  463. 'file',
  464. changes.oldFiles,
  465. changes.newFiles,
  466. historyRangesSupport
  467. )
  468. const updates = [].concat(
  469. docDeletes,
  470. fileDeletes,
  471. docAdds,
  472. fileAdds,
  473. docRenames,
  474. fileRenames
  475. )
  476. const projectVersion =
  477. changes && changes.newProject && changes.newProject.version
  478. if (updates.length < 1) {
  479. return callback()
  480. }
  481. if (projectVersion == null) {
  482. logger.warn(
  483. { projectId, changes, projectVersion },
  484. 'did not receive project version in changes'
  485. )
  486. return callback(new Error('did not receive project version in changes'))
  487. }
  488. _makeRequest(
  489. {
  490. path: `/project/${projectId}`,
  491. json: {
  492. updates,
  493. userId,
  494. version: projectVersion,
  495. projectHistoryId,
  496. source,
  497. },
  498. method: 'POST',
  499. },
  500. projectId,
  501. 'update-project-structure',
  502. callback
  503. )
  504. }
  505. )
  506. }
  507. function _makeRequest(options, projectId, metricsKey, callback) {
  508. const timer = new metrics.Timer(metricsKey)
  509. request(
  510. {
  511. url: `${settings.apis.documentupdater.url}${options.path}`,
  512. json: options.json,
  513. method: options.method || 'GET',
  514. timeout: options.timeout || 30 * 1000,
  515. },
  516. function (error, res, body) {
  517. timer.done()
  518. if (error) {
  519. logger.warn(
  520. { error, projectId },
  521. 'error making request to document updater'
  522. )
  523. callback(error)
  524. } else if (res.statusCode >= 200 && res.statusCode < 300) {
  525. callback(null, body)
  526. } else {
  527. error = new Error(
  528. `document updater returned a failure status code: ${res.statusCode}`
  529. )
  530. logger.warn(
  531. { error, projectId },
  532. `document updater returned failure status code: ${res.statusCode}`
  533. )
  534. callback(error)
  535. }
  536. }
  537. )
  538. }
  539. const _makeRequestAsync = promisify(_makeRequest)
  540. function _getUpdates(
  541. entityType,
  542. oldEntities,
  543. newEntities,
  544. historyRangesSupport
  545. ) {
  546. if (!oldEntities) {
  547. oldEntities = []
  548. }
  549. if (!newEntities) {
  550. newEntities = []
  551. }
  552. const deletes = []
  553. const adds = []
  554. const renames = []
  555. const oldEntitiesHash = _.keyBy(oldEntities, entity =>
  556. entity[entityType]._id.toString()
  557. )
  558. const newEntitiesHash = _.keyBy(newEntities, entity =>
  559. entity[entityType]._id.toString()
  560. )
  561. // Send deletes before adds (and renames) to keep a 1:1 mapping between
  562. // paths and ids
  563. //
  564. // When a file is replaced, we first delete the old file and then add the
  565. // new file. If the 'add' operation is sent to project history before the
  566. // 'delete' then we would have two files with the same path at that point
  567. // in time.
  568. for (const id in oldEntitiesHash) {
  569. const oldEntity = oldEntitiesHash[id]
  570. const newEntity = newEntitiesHash[id]
  571. if (newEntity == null) {
  572. // entity deleted
  573. deletes.push({
  574. type: `rename-${entityType}`,
  575. id,
  576. pathname: oldEntity.path,
  577. newPathname: '',
  578. })
  579. }
  580. }
  581. for (const id in newEntitiesHash) {
  582. const newEntity = newEntitiesHash[id]
  583. const oldEntity = oldEntitiesHash[id]
  584. if (oldEntity == null) {
  585. // entity added
  586. adds.push({
  587. type: `add-${entityType}`,
  588. id,
  589. pathname: newEntity.path,
  590. docLines: newEntity.docLines,
  591. ranges: newEntity.ranges,
  592. historyRangesSupport,
  593. hash: newEntity.file?.hash,
  594. metadata: buildFileMetadataForHistory(newEntity.file),
  595. createdBlob: true,
  596. })
  597. } else if (newEntity.path !== oldEntity.path) {
  598. // entity renamed
  599. renames.push({
  600. type: `rename-${entityType}`,
  601. id,
  602. pathname: oldEntity.path,
  603. newPathname: newEntity.path,
  604. })
  605. }
  606. }
  607. return { deletes, adds, renames }
  608. }
  609. function buildFileMetadataForHistory(file) {
  610. if (!file?.linkedFileData) return undefined
  611. const metadata = {
  612. // Files do not have a created at timestamp in the history.
  613. // For cloned projects, the importedAt timestamp needs to remain untouched.
  614. // Record the timestamp in the metadata blob to keep everything self-contained.
  615. importedAt: file.created,
  616. ...file.linkedFileData,
  617. }
  618. if (metadata.provider === 'project_output_file') {
  619. // The build-id and clsi-server-id are only used for downloading file.
  620. // Omit them from history as they are not useful in the future.
  621. delete metadata.build_id
  622. delete metadata.clsiServerId
  623. }
  624. return metadata
  625. }
  626. module.exports = {
  627. flushProjectToMongo,
  628. flushMultipleProjectsToMongo,
  629. flushProjectToMongoAndDelete,
  630. flushDocToMongo,
  631. deleteDoc,
  632. getComment,
  633. getDocument,
  634. getProjectLastUpdatedAt,
  635. setDocument,
  636. appendToDocument,
  637. getProjectDocsIfMatch,
  638. clearProjectState,
  639. acceptChanges: callbackify(acceptChanges),
  640. rejectChanges,
  641. resolveThread,
  642. reopenThread,
  643. deleteThread,
  644. resyncProjectHistory,
  645. blockProject,
  646. unblockProject,
  647. updateProjectStructure,
  648. getDocumentWithHistoryRanges,
  649. promises: {
  650. flushProjectToMongo: promisify(flushProjectToMongo),
  651. flushMultipleProjectsToMongo: promisify(flushMultipleProjectsToMongo),
  652. flushProjectToMongoAndDelete: promisify(flushProjectToMongoAndDelete),
  653. flushDocToMongo: promisify(flushDocToMongo),
  654. deleteDoc: promisify(deleteDoc),
  655. getComment: promisify(getComment),
  656. getDocument: promisifyMultiResult(getDocument, [
  657. 'lines',
  658. 'version',
  659. 'ranges',
  660. 'ops',
  661. ]),
  662. setDocument: promisify(setDocument),
  663. getProjectDocsIfMatch: promisify(getProjectDocsIfMatch),
  664. getProjectLastUpdatedAt: promisify(getProjectLastUpdatedAt),
  665. clearProjectState: promisify(clearProjectState),
  666. acceptChanges,
  667. rejectChanges: promisify(rejectChanges),
  668. resolveThread: promisify(resolveThread),
  669. reopenThread: promisify(reopenThread),
  670. deleteThread: promisify(deleteThread),
  671. resyncProjectHistory: promisify(resyncProjectHistory),
  672. blockProject: promisify(blockProject),
  673. unblockProject: promisify(unblockProject),
  674. updateProjectStructure: promisify(updateProjectStructure),
  675. appendToDocument: promisify(appendToDocument),
  676. getDocumentWithHistoryRanges: promisify(getDocumentWithHistoryRanges),
  677. },
  678. }