DocumentUpdaterHandler.js 14 KB

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