DocumentUpdaterHandler.js 16 KB

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