HttpController.js 15 KB

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