HttpController.js 16 KB

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