DocumentUpdaterHandler.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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('underscore')
  5. const async = require('async')
  6. const logger = require('@overleaf/logger')
  7. const metrics = require('@overleaf/metrics')
  8. const { promisify } = require('util')
  9. module.exports = {
  10. flushProjectToMongo,
  11. flushMultipleProjectsToMongo,
  12. flushProjectToMongoAndDelete,
  13. flushDocToMongo,
  14. deleteDoc,
  15. getDocument,
  16. setDocument,
  17. getProjectDocsIfMatch,
  18. clearProjectState,
  19. acceptChanges,
  20. deleteThread,
  21. resyncProjectHistory,
  22. updateProjectStructure,
  23. promises: {
  24. flushProjectToMongo: promisify(flushProjectToMongo),
  25. flushMultipleProjectsToMongo: promisify(flushMultipleProjectsToMongo),
  26. flushProjectToMongoAndDelete: promisify(flushProjectToMongoAndDelete),
  27. flushDocToMongo: promisify(flushDocToMongo),
  28. deleteDoc: promisify(deleteDoc),
  29. getDocument: promisify(getDocument),
  30. setDocument: promisify(setDocument),
  31. getProjectDocsIfMatch: promisify(getProjectDocsIfMatch),
  32. clearProjectState: promisify(clearProjectState),
  33. acceptChanges: promisify(acceptChanges),
  34. deleteThread: promisify(deleteThread),
  35. resyncProjectHistory: promisify(resyncProjectHistory),
  36. updateProjectStructure: promisify(updateProjectStructure),
  37. },
  38. }
  39. function flushProjectToMongo(projectId, callback) {
  40. _makeRequest(
  41. {
  42. path: `/project/${projectId}/flush`,
  43. method: 'POST',
  44. },
  45. projectId,
  46. 'flushing.mongo.project',
  47. callback
  48. )
  49. }
  50. function flushMultipleProjectsToMongo(projectIds, callback) {
  51. const jobs = projectIds.map(projectId => callback => {
  52. flushProjectToMongo(projectId, callback)
  53. })
  54. async.series(jobs, callback)
  55. }
  56. function flushProjectToMongoAndDelete(projectId, callback) {
  57. _makeRequest(
  58. {
  59. path: `/project/${projectId}`,
  60. method: 'DELETE',
  61. },
  62. projectId,
  63. 'flushing.mongo.project',
  64. callback
  65. )
  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 getDocument(projectId, docId, fromVersion, callback) {
  99. _makeRequest(
  100. {
  101. path: `/project/${projectId}/doc/${docId}?fromVersion=${fromVersion}`,
  102. json: true,
  103. },
  104. projectId,
  105. 'get-document',
  106. function (error, doc) {
  107. if (error) {
  108. return callback(error)
  109. }
  110. callback(null, doc.lines, doc.version, doc.ranges, doc.ops)
  111. }
  112. )
  113. }
  114. function setDocument(projectId, docId, userId, docLines, source, callback) {
  115. _makeRequest(
  116. {
  117. path: `/project/${projectId}/doc/${docId}`,
  118. method: 'POST',
  119. json: {
  120. lines: docLines,
  121. source,
  122. user_id: userId,
  123. },
  124. },
  125. projectId,
  126. 'set-document',
  127. callback
  128. )
  129. }
  130. function getProjectDocsIfMatch(projectId, projectStateHash, callback) {
  131. // If the project state hasn't changed, we can get all the latest
  132. // docs from redis via the docupdater. Otherwise we will need to
  133. // fall back to getting them from mongo.
  134. const timer = new metrics.Timer('get-project-docs')
  135. const url = `${settings.apis.documentupdater.url}/project/${projectId}/get_and_flush_if_old?state=${projectStateHash}`
  136. request.post(url, function (error, res, body) {
  137. timer.done()
  138. if (error) {
  139. OError.tag(error, 'error getting project docs from doc updater', {
  140. url,
  141. projectId,
  142. })
  143. return callback(error)
  144. }
  145. if (res.statusCode === 409) {
  146. // HTTP response code "409 Conflict"
  147. // Docupdater has checked the projectStateHash and found that
  148. // it has changed. This means that the docs currently in redis
  149. // aren't the only change to the project and the full set of
  150. // docs/files should be retreived from docstore/filestore
  151. // instead.
  152. callback()
  153. } else if (res.statusCode >= 200 && res.statusCode < 300) {
  154. let docs
  155. try {
  156. docs = JSON.parse(body)
  157. } catch (error1) {
  158. return callback(OError.tag(error1))
  159. }
  160. callback(null, docs)
  161. } else {
  162. callback(
  163. new OError(
  164. `doc updater returned a non-success status code: ${res.statusCode}`,
  165. {
  166. projectId,
  167. url,
  168. }
  169. )
  170. )
  171. }
  172. })
  173. }
  174. function clearProjectState(projectId, callback) {
  175. _makeRequest(
  176. {
  177. path: `/project/${projectId}/clearState`,
  178. method: 'POST',
  179. },
  180. projectId,
  181. 'clear-project-state',
  182. callback
  183. )
  184. }
  185. function acceptChanges(projectId, docId, changeIds, callback) {
  186. _makeRequest(
  187. {
  188. path: `/project/${projectId}/doc/${docId}/change/accept`,
  189. json: { change_ids: changeIds },
  190. method: 'POST',
  191. },
  192. projectId,
  193. 'accept-changes',
  194. callback
  195. )
  196. }
  197. function deleteThread(projectId, docId, threadId, callback) {
  198. _makeRequest(
  199. {
  200. path: `/project/${projectId}/doc/${docId}/comment/${threadId}`,
  201. method: 'DELETE',
  202. },
  203. projectId,
  204. 'delete-thread',
  205. callback
  206. )
  207. }
  208. function resyncProjectHistory(
  209. projectId,
  210. projectHistoryId,
  211. docs,
  212. files,
  213. callback
  214. ) {
  215. _makeRequest(
  216. {
  217. path: `/project/${projectId}/history/resync`,
  218. json: { docs, files, projectHistoryId },
  219. method: 'POST',
  220. timeout: 6 * 60 * 1000, // allow 6 minutes for resync
  221. },
  222. projectId,
  223. 'resync-project-history',
  224. callback
  225. )
  226. }
  227. function updateProjectStructure(
  228. projectId,
  229. projectHistoryId,
  230. userId,
  231. changes,
  232. source,
  233. callback
  234. ) {
  235. if (
  236. settings.apis.project_history == null ||
  237. !settings.apis.project_history.sendProjectStructureOps
  238. ) {
  239. return callback()
  240. }
  241. const {
  242. deletes: docDeletes,
  243. adds: docAdds,
  244. renames: docRenames,
  245. } = _getUpdates('doc', changes.oldDocs, changes.newDocs)
  246. const {
  247. deletes: fileDeletes,
  248. adds: fileAdds,
  249. renames: fileRenames,
  250. } = _getUpdates('file', changes.oldFiles, changes.newFiles)
  251. const updates = [].concat(
  252. docDeletes,
  253. fileDeletes,
  254. docAdds,
  255. fileAdds,
  256. docRenames,
  257. fileRenames
  258. )
  259. const projectVersion =
  260. changes && changes.newProject && changes.newProject.version
  261. if (updates.length < 1) {
  262. return callback()
  263. }
  264. if (projectVersion == null) {
  265. logger.warn(
  266. { projectId, changes, projectVersion },
  267. 'did not receive project version in changes'
  268. )
  269. return callback(new Error('did not receive project version in changes'))
  270. }
  271. _makeRequest(
  272. {
  273. path: `/project/${projectId}`,
  274. json: {
  275. updates,
  276. userId,
  277. version: projectVersion,
  278. projectHistoryId,
  279. source,
  280. },
  281. method: 'POST',
  282. },
  283. projectId,
  284. 'update-project-structure',
  285. callback
  286. )
  287. }
  288. function _makeRequest(options, projectId, metricsKey, callback) {
  289. const timer = new metrics.Timer(metricsKey)
  290. request(
  291. {
  292. url: `${settings.apis.documentupdater.url}${options.path}`,
  293. json: options.json,
  294. method: options.method || 'GET',
  295. timeout: options.timeout || 30 * 1000,
  296. },
  297. function (error, res, body) {
  298. timer.done()
  299. if (error) {
  300. logger.warn(
  301. { error, projectId },
  302. 'error making request to document updater'
  303. )
  304. callback(error)
  305. } else if (res.statusCode >= 200 && res.statusCode < 300) {
  306. callback(null, body)
  307. } else {
  308. error = new Error(
  309. `document updater returned a failure status code: ${res.statusCode}`
  310. )
  311. logger.warn(
  312. { error, projectId },
  313. `document updater returned failure status code: ${res.statusCode}`
  314. )
  315. callback(error)
  316. }
  317. }
  318. )
  319. }
  320. function _getUpdates(entityType, oldEntities, newEntities) {
  321. if (!oldEntities) {
  322. oldEntities = []
  323. }
  324. if (!newEntities) {
  325. newEntities = []
  326. }
  327. const deletes = []
  328. const adds = []
  329. const renames = []
  330. const oldEntitiesHash = _.indexBy(oldEntities, entity =>
  331. entity[entityType]._id.toString()
  332. )
  333. const newEntitiesHash = _.indexBy(newEntities, entity =>
  334. entity[entityType]._id.toString()
  335. )
  336. // Send deletes before adds (and renames) to keep a 1:1 mapping between
  337. // paths and ids
  338. //
  339. // When a file is replaced, we first delete the old file and then add the
  340. // new file. If the 'add' operation is sent to project history before the
  341. // 'delete' then we would have two files with the same path at that point
  342. // in time.
  343. for (const id in oldEntitiesHash) {
  344. const oldEntity = oldEntitiesHash[id]
  345. const newEntity = newEntitiesHash[id]
  346. if (newEntity == null) {
  347. // entity deleted
  348. deletes.push({
  349. type: `rename-${entityType}`,
  350. id,
  351. pathname: oldEntity.path,
  352. newPathname: '',
  353. })
  354. }
  355. }
  356. for (const id in newEntitiesHash) {
  357. const newEntity = newEntitiesHash[id]
  358. const oldEntity = oldEntitiesHash[id]
  359. if (oldEntity == null) {
  360. // entity added
  361. adds.push({
  362. type: `add-${entityType}`,
  363. id,
  364. pathname: newEntity.path,
  365. docLines: newEntity.docLines,
  366. url: newEntity.url,
  367. hash: newEntity.file != null ? newEntity.file.hash : undefined,
  368. })
  369. } else if (newEntity.path !== oldEntity.path) {
  370. // entity renamed
  371. renames.push({
  372. type: `rename-${entityType}`,
  373. id,
  374. pathname: oldEntity.path,
  375. newPathname: newEntity.path,
  376. })
  377. }
  378. }
  379. return { deletes, adds, renames }
  380. }