HttpController.js 17 KB

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