HttpController.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. const { expressify } = require('@overleaf/promise-utils')
  2. const DocumentManager = require('./DocumentManager')
  3. const HistoryManager = require('./HistoryManager')
  4. const ProjectManager = require('./ProjectManager')
  5. const RedisManager = require('./RedisManager')
  6. const Errors = require('./Errors')
  7. const logger = require('@overleaf/logger')
  8. const Settings = require('@overleaf/settings')
  9. const Metrics = require('./Metrics')
  10. const DeleteQueueManager = require('./DeleteQueueManager')
  11. const { getTotalSizeOfLines } = require('./Limits')
  12. const { StringFileData } = require('overleaf-editor-core')
  13. const { addTrackedDeletesToContent } = require('./Utils')
  14. const HistoryConversions = require('./HistoryConversions')
  15. async function getDoc(req, res) {
  16. let fromVersion
  17. const docId = req.params.doc_id
  18. const projectId = req.params.project_id
  19. const historyRanges = req.query.historyRanges === 'true'
  20. logger.debug({ projectId, docId, historyRanges }, 'getting doc via http')
  21. const timer = new Metrics.Timer('http.getDoc')
  22. if (req.query.fromVersion != null) {
  23. fromVersion = parseInt(req.query.fromVersion, 10)
  24. } else {
  25. fromVersion = -1
  26. }
  27. let { lines, version, ops, ranges, pathname, type } =
  28. await DocumentManager.promises.getDocAndRecentOpsWithLock(
  29. projectId,
  30. docId,
  31. fromVersion
  32. )
  33. timer.done()
  34. logger.debug({ projectId, docId, historyRanges }, 'got doc via http')
  35. if (lines == null || version == null) {
  36. throw new Errors.NotFoundError('document not found')
  37. }
  38. if (!Array.isArray(lines) && req.query.historyOTSupport !== 'true') {
  39. const file = StringFileData.fromRaw(lines)
  40. // TODO(24596): tc support for history-ot
  41. lines = file.getLines()
  42. }
  43. if (historyRanges) {
  44. const docContentWithTrackedDeletes = addTrackedDeletesToContent(
  45. lines.join('\n'),
  46. ranges?.changes ?? []
  47. )
  48. const docLinesWithTrackedDeletes = docContentWithTrackedDeletes.split('\n')
  49. const rangesWithTrackedDeletes = HistoryConversions.toHistoryRanges(ranges)
  50. res.json({
  51. id: docId,
  52. lines: docLinesWithTrackedDeletes,
  53. version,
  54. ops,
  55. ranges: rangesWithTrackedDeletes,
  56. pathname,
  57. ttlInS: RedisManager.DOC_OPS_TTL,
  58. type,
  59. })
  60. } else {
  61. res.json({
  62. id: docId,
  63. lines,
  64. version,
  65. ops,
  66. ranges,
  67. pathname,
  68. ttlInS: RedisManager.DOC_OPS_TTL,
  69. type,
  70. })
  71. }
  72. }
  73. async function getComment(req, res) {
  74. const docId = req.params.doc_id
  75. const projectId = req.params.project_id
  76. const commentId = req.params.comment_id
  77. logger.debug({ projectId, docId, commentId }, 'getting comment via http')
  78. const comment = await DocumentManager.promises.getCommentWithLock(
  79. projectId,
  80. docId,
  81. commentId
  82. )
  83. if (comment == null) {
  84. throw new Errors.NotFoundError('comment not found')
  85. }
  86. res.json(comment)
  87. }
  88. // return the doc from redis if present, but don't load it from mongo
  89. async function peekDoc(req, res) {
  90. const docId = req.params.doc_id
  91. const projectId = req.params.project_id
  92. logger.debug({ projectId, docId }, 'peeking at doc via http')
  93. let { lines, version } = await RedisManager.promises.getDoc(projectId, docId)
  94. if (lines == null || version == null) {
  95. throw new Errors.NotFoundError('document not found')
  96. }
  97. if (!Array.isArray(lines) && req.query.historyOTSupport !== 'true') {
  98. const file = StringFileData.fromRaw(lines)
  99. // TODO(24596): tc support for history-ot
  100. lines = file.getLines()
  101. }
  102. res.json({ id: docId, lines, version })
  103. }
  104. async function getProjectDocsAndFlushIfOld(req, res) {
  105. const projectId = req.params.project_id
  106. const projectStateHash = req.query.state
  107. // exclude is string of existing docs "id:version,id:version,..."
  108. const excludeItems =
  109. req.query.exclude != null ? req.query.exclude.split(',') : []
  110. logger.debug({ projectId, exclude: excludeItems }, 'getting docs via http')
  111. const timer = new Metrics.Timer('http.getAllDocs')
  112. const excludeVersions = {}
  113. for (const item of excludeItems) {
  114. const [id, version] = item.split(':')
  115. excludeVersions[id] = version
  116. }
  117. logger.debug(
  118. { projectId, projectStateHash, excludeVersions },
  119. 'excluding versions'
  120. )
  121. let result
  122. try {
  123. result = await ProjectManager.promises.getProjectDocsAndFlushIfOld(
  124. projectId,
  125. projectStateHash,
  126. excludeVersions
  127. )
  128. } catch (error) {
  129. if (error instanceof Errors.ProjectStateChangedError) {
  130. return res.sendStatus(409) // conflict
  131. } else {
  132. throw error
  133. }
  134. }
  135. timer.done()
  136. logger.debug(
  137. {
  138. projectId,
  139. result: result.map(doc => `${doc._id}:${doc.v}`),
  140. },
  141. 'got docs via http'
  142. )
  143. res.send(result)
  144. }
  145. async function getProjectLastUpdatedAt(req, res) {
  146. const projectId = req.params.project_id
  147. let timestamps =
  148. await ProjectManager.promises.getProjectDocsTimestamps(projectId)
  149. // Filter out nulls. This can happen when
  150. // - docs get flushed between the listing and getting the individual docs ts
  151. // - a doc flush failed half way (doc keys removed, project tracking not updated)
  152. timestamps = timestamps.filter(ts => !!ts)
  153. timestamps = timestamps.map(ts => parseInt(ts, 10))
  154. timestamps.sort((a, b) => (a > b ? 1 : -1))
  155. res.json({ lastUpdatedAt: timestamps.pop() })
  156. }
  157. async function getProjectRanges(req, res) {
  158. const projectId = req.params.project_id
  159. const docs = await ProjectManager.promises.getProjectRanges(projectId)
  160. res.json({ docs })
  161. }
  162. async function clearProjectState(req, res) {
  163. const projectId = req.params.project_id
  164. const timer = new Metrics.Timer('http.clearProjectState')
  165. logger.debug({ projectId }, 'clearing project state via http')
  166. await ProjectManager.promises.clearProjectState(projectId)
  167. timer.done()
  168. res.sendStatus(200)
  169. }
  170. async function setDoc(req, res) {
  171. const docId = req.params.doc_id
  172. const projectId = req.params.project_id
  173. const { lines, source, user_id: userId, undoing } = req.body
  174. const lineSize = getTotalSizeOfLines(lines)
  175. if (lineSize > Settings.max_doc_length) {
  176. logger.warn(
  177. { projectId, docId, source, lineSize, userId },
  178. 'document too large, returning 406 response'
  179. )
  180. return res.sendStatus(406)
  181. }
  182. logger.debug(
  183. { projectId, docId, lines, source, userId, undoing },
  184. 'setting doc via http'
  185. )
  186. const timer = new Metrics.Timer('http.setDoc')
  187. const result = await DocumentManager.promises.setDocWithLock(
  188. projectId,
  189. docId,
  190. lines,
  191. source,
  192. userId,
  193. undoing,
  194. true
  195. )
  196. timer.done()
  197. logger.debug({ projectId, docId }, 'set doc via http')
  198. // If the document is unchanged and hasn't been updated, `result` will be
  199. // undefined, which leads to an invalid JSON response, so we send an empty
  200. // object instead.
  201. res.json(result || {})
  202. }
  203. async function appendToDoc(req, res) {
  204. const docId = req.params.doc_id
  205. const projectId = req.params.project_id
  206. const { lines, source, user_id: userId } = req.body
  207. const timer = new Metrics.Timer('http.appendToDoc')
  208. let result
  209. try {
  210. result = await DocumentManager.promises.appendToDocWithLock(
  211. projectId,
  212. docId,
  213. lines,
  214. source,
  215. userId
  216. )
  217. } catch (error) {
  218. if (error instanceof Errors.FileTooLargeError) {
  219. logger.warn('refusing to append to file, it would become too large')
  220. return res.sendStatus(422)
  221. } else {
  222. throw error
  223. }
  224. }
  225. timer.done()
  226. logger.debug(
  227. { projectId, docId, lines, source, userId },
  228. 'appending to doc via http'
  229. )
  230. res.json(result)
  231. }
  232. async function flushDocIfLoaded(req, res) {
  233. const docId = req.params.doc_id
  234. const projectId = req.params.project_id
  235. logger.debug({ projectId, docId }, 'flushing doc via http')
  236. const timer = new Metrics.Timer('http.flushDoc')
  237. await DocumentManager.promises.flushDocIfLoadedWithLock(projectId, docId)
  238. timer.done()
  239. logger.debug({ projectId, docId }, 'flushed doc via http')
  240. res.sendStatus(204) // No Content
  241. }
  242. async function deleteDoc(req, res) {
  243. const docId = req.params.doc_id
  244. const projectId = req.params.project_id
  245. const ignoreFlushErrors = req.query.ignore_flush_errors === 'true'
  246. const timer = new Metrics.Timer('http.deleteDoc')
  247. logger.debug({ projectId, docId }, 'deleting doc via http')
  248. try {
  249. await DocumentManager.promises.flushAndDeleteDocWithLock(projectId, docId, {
  250. ignoreFlushErrors,
  251. })
  252. } finally {
  253. timer.done()
  254. // There is no harm in flushing project history if the previous call
  255. // failed and sometimes it is required
  256. HistoryManager.flushProjectChangesAsync(projectId)
  257. }
  258. logger.debug({ projectId, docId }, 'deleted doc via http')
  259. res.sendStatus(204) // No Content
  260. }
  261. async function flushProject(req, res) {
  262. const projectId = req.params.project_id
  263. logger.debug({ projectId }, 'flushing project via http')
  264. const timer = new Metrics.Timer('http.flushProject')
  265. await ProjectManager.promises.flushProjectWithLocks(projectId)
  266. timer.done()
  267. logger.debug({ projectId }, 'flushed project via http')
  268. res.sendStatus(204) // No Content
  269. }
  270. async function deleteProject(req, res) {
  271. const projectId = req.params.project_id
  272. logger.debug({ projectId }, 'deleting project via http')
  273. const options = {}
  274. if (req.query.background) {
  275. options.background = true
  276. } // allow non-urgent flushes to be queued
  277. if (req.query.shutdown) {
  278. options.skip_history_flush = true
  279. } // don't flush history when realtime shuts down
  280. if (req.query.background) {
  281. await ProjectManager.promises.queueFlushAndDeleteProject(projectId)
  282. logger.debug({ projectId }, 'queue delete of project via http')
  283. } else {
  284. const timer = new Metrics.Timer('http.deleteProject')
  285. await ProjectManager.promises.flushAndDeleteProjectWithLocks(
  286. projectId,
  287. options
  288. )
  289. timer.done()
  290. logger.debug({ projectId }, 'deleted project via http')
  291. }
  292. res.sendStatus(204)
  293. }
  294. async function deleteMultipleProjects(req, res) {
  295. const projectIds = req.body.project_ids || []
  296. logger.debug({ projectIds }, 'deleting multiple projects via http')
  297. for (const projectId of projectIds) {
  298. logger.debug({ projectId }, 'queue delete of project via http')
  299. await ProjectManager.promises.queueFlushAndDeleteProject(projectId)
  300. }
  301. res.sendStatus(204) // No Content
  302. }
  303. async function acceptChanges(req, res) {
  304. const { project_id: projectId, doc_id: docId } = req.params
  305. let changeIds = req.body.change_ids
  306. if (changeIds == null) {
  307. changeIds = [req.params.change_id]
  308. }
  309. logger.debug(
  310. { projectId, docId },
  311. `accepting ${changeIds.length} changes via http`
  312. )
  313. const timer = new Metrics.Timer('http.acceptChanges')
  314. const changeContributors =
  315. await DocumentManager.promises.acceptChangesWithLock(
  316. projectId,
  317. docId,
  318. changeIds
  319. )
  320. timer.done()
  321. logger.debug(
  322. { projectId, docId },
  323. `accepted ${changeIds.length} changes via http`
  324. )
  325. res.status(200).json({ changeContributors })
  326. }
  327. async function rejectChanges(req, res) {
  328. const { project_id: projectId, doc_id: docId } = req.params
  329. const changeIds = req.body.change_ids
  330. const userId = req.body.user_id
  331. logger.debug(
  332. { projectId, docId },
  333. `rejecting ${changeIds.length} changes via http`
  334. )
  335. const response = await DocumentManager.promises.rejectChangesWithLock(
  336. projectId,
  337. docId,
  338. changeIds,
  339. userId
  340. )
  341. logger.debug(
  342. { projectId, docId, changeIds, response },
  343. `rejected ${changeIds.length} changes via http`
  344. )
  345. res.json(response)
  346. }
  347. async function resolveComment(req, res) {
  348. const {
  349. project_id: projectId,
  350. doc_id: docId,
  351. comment_id: commentId,
  352. } = req.params
  353. const userId = req.body.user_id
  354. logger.debug({ projectId, docId, commentId }, 'resolving comment via http')
  355. await DocumentManager.promises.updateCommentStateWithLock(
  356. projectId,
  357. docId,
  358. commentId,
  359. userId,
  360. true
  361. )
  362. logger.debug({ projectId, docId, commentId }, 'resolved comment via http')
  363. res.sendStatus(204) // No Content
  364. }
  365. async function reopenComment(req, res) {
  366. const {
  367. project_id: projectId,
  368. doc_id: docId,
  369. comment_id: commentId,
  370. } = req.params
  371. const userId = req.body.user_id
  372. logger.debug({ projectId, docId, commentId }, 'reopening comment via http')
  373. await DocumentManager.promises.updateCommentStateWithLock(
  374. projectId,
  375. docId,
  376. commentId,
  377. userId,
  378. false
  379. )
  380. logger.debug({ projectId, docId, commentId }, 'reopened comment via http')
  381. res.sendStatus(204) // No Content
  382. }
  383. async function deleteComment(req, res) {
  384. const {
  385. project_id: projectId,
  386. doc_id: docId,
  387. comment_id: commentId,
  388. } = req.params
  389. const userId = req.body.user_id
  390. logger.debug({ projectId, docId, commentId }, 'deleting comment via http')
  391. const timer = new Metrics.Timer('http.deleteComment')
  392. await DocumentManager.promises.deleteCommentWithLock(
  393. projectId,
  394. docId,
  395. commentId,
  396. userId
  397. )
  398. timer.done()
  399. logger.debug({ projectId, docId, commentId }, 'deleted comment via http')
  400. res.sendStatus(204) // No Content
  401. }
  402. async function updateProject(req, res) {
  403. const timer = new Metrics.Timer('http.updateProject')
  404. const projectId = req.params.project_id
  405. const { projectHistoryId, userId, updates = [], version, source } = req.body
  406. logger.debug({ projectId, updates, version }, 'updating project via http')
  407. await ProjectManager.promises.updateProjectWithLocks(
  408. projectId,
  409. projectHistoryId,
  410. userId,
  411. updates,
  412. version,
  413. source
  414. )
  415. timer.done()
  416. logger.debug({ projectId }, 'updated project via http')
  417. res.sendStatus(204) // No Content
  418. }
  419. async function resyncProjectHistory(req, res) {
  420. const projectId = req.params.project_id
  421. const {
  422. projectHistoryId,
  423. docs,
  424. files,
  425. historyRangesMigration,
  426. resyncProjectStructureOnly,
  427. } = req.body
  428. logger.debug(
  429. { projectId, docs, files },
  430. 'queuing project history resync via http'
  431. )
  432. const opts = {}
  433. if (historyRangesMigration) {
  434. opts.historyRangesMigration = historyRangesMigration
  435. }
  436. if (resyncProjectStructureOnly) {
  437. opts.resyncProjectStructureOnly = resyncProjectStructureOnly
  438. }
  439. await HistoryManager.promises.resyncProjectHistory(
  440. projectId,
  441. projectHistoryId,
  442. docs,
  443. files,
  444. opts
  445. )
  446. logger.debug({ projectId }, 'queued project history resync via http')
  447. res.sendStatus(204)
  448. }
  449. async function flushQueuedProjects(req, res) {
  450. res.setTimeout(10 * 60 * 1000)
  451. const options = {
  452. limit: req.query.limit || 1000,
  453. timeout: 5 * 60 * 1000,
  454. min_delete_age: req.query.min_delete_age || 5 * 60 * 1000,
  455. }
  456. await DeleteQueueManager.promises.flushAndDeleteOldProjects(
  457. options,
  458. (err, flushed) => {
  459. if (err) {
  460. logger.err({ err }, 'error flushing old projects')
  461. res.sendStatus(500)
  462. } else {
  463. logger.info({ flushed }, 'flush of queued projects completed')
  464. res.send({ flushed })
  465. }
  466. }
  467. )
  468. }
  469. /**
  470. * Block a project from getting loaded in docupdater
  471. *
  472. * The project is blocked only if it's not already loaded in docupdater. The
  473. * response indicates whether the project has been blocked or not.
  474. */
  475. async function blockProject(req, res) {
  476. const projectId = req.params.project_id
  477. const blocked = await RedisManager.promises.blockProject(projectId)
  478. res.json({ blocked })
  479. }
  480. /**
  481. * Unblock a project
  482. */
  483. async function unblockProject(req, res) {
  484. const projectId = req.params.project_id
  485. const wasBlocked = await RedisManager.promises.unblockProject(projectId)
  486. res.json({ wasBlocked })
  487. }
  488. module.exports = {
  489. getDoc: expressify(getDoc),
  490. peekDoc: expressify(peekDoc),
  491. getProjectDocsAndFlushIfOld: expressify(getProjectDocsAndFlushIfOld),
  492. getProjectLastUpdatedAt: expressify(getProjectLastUpdatedAt),
  493. getProjectRanges: expressify(getProjectRanges),
  494. clearProjectState: expressify(clearProjectState),
  495. appendToDoc: expressify(appendToDoc),
  496. setDoc: expressify(setDoc),
  497. flushDocIfLoaded: expressify(flushDocIfLoaded),
  498. deleteDoc: expressify(deleteDoc),
  499. flushProject: expressify(flushProject),
  500. deleteProject: expressify(deleteProject),
  501. deleteMultipleProjects: expressify(deleteMultipleProjects),
  502. acceptChanges: expressify(acceptChanges),
  503. rejectChanges: expressify(rejectChanges),
  504. resolveComment: expressify(resolveComment),
  505. reopenComment: expressify(reopenComment),
  506. deleteComment: expressify(deleteComment),
  507. updateProject: expressify(updateProject),
  508. resyncProjectHistory: expressify(resyncProjectHistory),
  509. flushQueuedProjects: expressify(flushQueuedProjects),
  510. blockProject: expressify(blockProject),
  511. unblockProject: expressify(unblockProject),
  512. getComment: expressify(getComment),
  513. }