HistoryController.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. let HistoryController
  2. const OError = require('@overleaf/o-error')
  3. const async = require('async')
  4. const logger = require('@overleaf/logger')
  5. const request = require('request')
  6. const settings = require('@overleaf/settings')
  7. const SessionManager = require('../Authentication/SessionManager')
  8. const UserGetter = require('../User/UserGetter')
  9. const Errors = require('../Errors/Errors')
  10. const HistoryManager = require('./HistoryManager')
  11. const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
  12. const ProjectEntityUpdateHandler = require('../Project/ProjectEntityUpdateHandler')
  13. const RestoreManager = require('./RestoreManager')
  14. const { pipeline } = require('stream')
  15. const { prepareZipAttachment } = require('../../infrastructure/Response')
  16. module.exports = HistoryController = {
  17. selectHistoryApi(req, res, next) {
  18. const { Project_id: projectId } = req.params
  19. // find out which type of history service this project uses
  20. ProjectDetailsHandler.getDetails(projectId, function (err, project) {
  21. if (err) {
  22. return next(err)
  23. }
  24. const history = project.overleaf && project.overleaf.history
  25. if (history && history.id && history.display) {
  26. req.useProjectHistory = true
  27. } else {
  28. req.useProjectHistory = false
  29. }
  30. next()
  31. })
  32. },
  33. ensureProjectHistoryEnabled(req, res, next) {
  34. if (req.useProjectHistory) {
  35. next()
  36. } else {
  37. res.sendStatus(404)
  38. }
  39. },
  40. proxyToHistoryApi(req, res, next) {
  41. const userId = SessionManager.getLoggedInUserId(req.session)
  42. const url =
  43. HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
  44. const getReq = request({
  45. url,
  46. method: req.method,
  47. headers: {
  48. 'X-User-Id': userId,
  49. },
  50. })
  51. getReq.pipe(res)
  52. getReq.on('error', function (err) {
  53. logger.warn({ url, err }, 'history API error')
  54. next(err)
  55. })
  56. },
  57. proxyToHistoryApiAndInjectUserDetails(req, res, next) {
  58. const userId = SessionManager.getLoggedInUserId(req.session)
  59. const url =
  60. HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url
  61. HistoryController._makeRequest(
  62. {
  63. url,
  64. method: req.method,
  65. json: true,
  66. headers: {
  67. 'X-User-Id': userId,
  68. },
  69. },
  70. function (err, body) {
  71. if (err) {
  72. return next(err)
  73. }
  74. HistoryManager.injectUserDetails(body, function (err, data) {
  75. if (err) {
  76. return next(err)
  77. }
  78. res.json(data)
  79. })
  80. }
  81. )
  82. },
  83. buildHistoryServiceUrl(useProjectHistory) {
  84. // choose a history service, either document-level (trackchanges)
  85. // or project-level (project_history)
  86. if (useProjectHistory) {
  87. return settings.apis.project_history.url
  88. } else {
  89. return settings.apis.trackchanges.url
  90. }
  91. },
  92. resyncProjectHistory(req, res, next) {
  93. const projectId = req.params.Project_id
  94. ProjectEntityUpdateHandler.resyncProjectHistory(projectId, function (err) {
  95. if (err instanceof Errors.ProjectHistoryDisabledError) {
  96. return res.sendStatus(404)
  97. }
  98. if (err) {
  99. return next(err)
  100. }
  101. res.sendStatus(204)
  102. })
  103. },
  104. restoreFileFromV2(req, res, next) {
  105. const { project_id: projectId } = req.params
  106. const { version, pathname } = req.body
  107. const userId = SessionManager.getLoggedInUserId(req.session)
  108. RestoreManager.restoreFileFromV2(
  109. userId,
  110. projectId,
  111. version,
  112. pathname,
  113. function (err, entity) {
  114. if (err) {
  115. return next(err)
  116. }
  117. res.json({
  118. type: entity.type,
  119. id: entity._id,
  120. })
  121. }
  122. )
  123. },
  124. restoreDocFromDeletedDoc(req, res, next) {
  125. const { project_id: projectId, doc_id: docId } = req.params
  126. const { name } = req.body
  127. const userId = SessionManager.getLoggedInUserId(req.session)
  128. if (name == null) {
  129. return res.sendStatus(400) // Malformed request
  130. }
  131. RestoreManager.restoreDocFromDeletedDoc(
  132. userId,
  133. projectId,
  134. docId,
  135. name,
  136. (err, doc) => {
  137. if (err) return next(err)
  138. res.json({
  139. doc_id: doc._id,
  140. })
  141. }
  142. )
  143. },
  144. getLabels(req, res, next) {
  145. const projectId = req.params.Project_id
  146. HistoryController._makeRequest(
  147. {
  148. method: 'GET',
  149. url: `${settings.apis.project_history.url}/project/${projectId}/labels`,
  150. json: true,
  151. },
  152. function (err, labels) {
  153. if (err) {
  154. return next(err)
  155. }
  156. HistoryController._enrichLabels(labels, (err, labels) => {
  157. if (err) {
  158. return next(err)
  159. }
  160. res.json(labels)
  161. })
  162. }
  163. )
  164. },
  165. createLabel(req, res, next) {
  166. const projectId = req.params.Project_id
  167. const { comment, version } = req.body
  168. const userId = SessionManager.getLoggedInUserId(req.session)
  169. HistoryController._makeRequest(
  170. {
  171. method: 'POST',
  172. url: `${settings.apis.project_history.url}/project/${projectId}/user/${userId}/labels`,
  173. json: { comment, version },
  174. },
  175. function (err, label) {
  176. if (err) {
  177. return next(err)
  178. }
  179. HistoryController._enrichLabel(label, (err, label) => {
  180. if (err) {
  181. return next(err)
  182. }
  183. res.json(label)
  184. })
  185. }
  186. )
  187. },
  188. _enrichLabel(label, callback) {
  189. if (!label.user_id) {
  190. return callback(null, label)
  191. }
  192. UserGetter.getUser(
  193. label.user_id,
  194. { first_name: 1, last_name: 1, email: 1 },
  195. (err, user) => {
  196. if (err) {
  197. return callback(err)
  198. }
  199. const newLabel = Object.assign({}, label)
  200. newLabel.user_display_name = HistoryController._displayNameForUser(user)
  201. callback(null, newLabel)
  202. }
  203. )
  204. },
  205. _enrichLabels(labels, callback) {
  206. if (!labels || !labels.length) {
  207. return callback(null, [])
  208. }
  209. const uniqueUsers = new Set(labels.map(label => label.user_id))
  210. // For backwards compatibility expect missing user_id fields
  211. uniqueUsers.delete(undefined)
  212. if (!uniqueUsers.size) {
  213. return callback(null, labels)
  214. }
  215. UserGetter.getUsers(
  216. Array.from(uniqueUsers),
  217. { first_name: 1, last_name: 1, email: 1 },
  218. function (err, rawUsers) {
  219. if (err) {
  220. return callback(err)
  221. }
  222. const users = new Map(rawUsers.map(user => [String(user._id), user]))
  223. labels.forEach(label => {
  224. const user = users.get(label.user_id)
  225. if (!user) return
  226. label.user_display_name = HistoryController._displayNameForUser(user)
  227. })
  228. callback(null, labels)
  229. }
  230. )
  231. },
  232. _displayNameForUser(user) {
  233. if (user == null) {
  234. return 'Anonymous'
  235. }
  236. if (user.name) {
  237. return user.name
  238. }
  239. let name = [user.first_name, user.last_name]
  240. .filter(n => n != null)
  241. .join(' ')
  242. .trim()
  243. if (name === '') {
  244. name = user.email.split('@')[0]
  245. }
  246. if (!name) {
  247. return '?'
  248. }
  249. return name
  250. },
  251. deleteLabel(req, res, next) {
  252. const { Project_id: projectId, label_id: labelId } = req.params
  253. const userId = SessionManager.getLoggedInUserId(req.session)
  254. HistoryController._makeRequest(
  255. {
  256. method: 'DELETE',
  257. url: `${settings.apis.project_history.url}/project/${projectId}/user/${userId}/labels/${labelId}`,
  258. },
  259. function (err) {
  260. if (err) {
  261. return next(err)
  262. }
  263. res.sendStatus(204)
  264. }
  265. )
  266. },
  267. _makeRequest(options, callback) {
  268. return request(options, function (err, response, body) {
  269. if (err) {
  270. return callback(err)
  271. }
  272. if (response.statusCode >= 200 && response.statusCode < 300) {
  273. callback(null, body)
  274. } else {
  275. err = new Error(
  276. `history api responded with non-success code: ${response.statusCode}`
  277. )
  278. callback(err)
  279. }
  280. })
  281. },
  282. downloadZipOfVersion(req, res, next) {
  283. const { project_id: projectId, version } = req.params
  284. ProjectDetailsHandler.getDetails(projectId, function (err, project) {
  285. if (err) {
  286. return next(err)
  287. }
  288. const v1Id =
  289. project.overleaf &&
  290. project.overleaf.history &&
  291. project.overleaf.history.id
  292. if (v1Id == null) {
  293. logger.error(
  294. { projectId, version },
  295. 'got request for zip version of non-v1 history project'
  296. )
  297. return res.sendStatus(402)
  298. }
  299. HistoryController._pipeHistoryZipToResponse(
  300. v1Id,
  301. version,
  302. `${project.name} (Version ${version})`,
  303. req,
  304. res,
  305. next
  306. )
  307. })
  308. },
  309. _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res, next) {
  310. if (req.aborted) {
  311. // client has disconnected -- skip project history api call and download
  312. return
  313. }
  314. // increase timeout to 6 minutes
  315. res.setTimeout(6 * 60 * 1000)
  316. const url = `${settings.apis.v1_history.url}/projects/${v1ProjectId}/version/${version}/zip`
  317. const options = {
  318. auth: {
  319. user: settings.apis.v1_history.user,
  320. pass: settings.apis.v1_history.pass,
  321. },
  322. json: true,
  323. method: 'post',
  324. url,
  325. }
  326. request(options, function (err, response, body) {
  327. if (err) {
  328. OError.tag(err, 'history API error', {
  329. v1ProjectId,
  330. version,
  331. })
  332. return next(err)
  333. }
  334. if (response.statusCode !== 200) {
  335. if (response.statusCode === 404) {
  336. return next(new Errors.NotFoundError('zip not found'))
  337. } else {
  338. return next(
  339. new OError('Error while getting zip for download', {
  340. v1ProjectId,
  341. statusCode: response.statusCode,
  342. })
  343. )
  344. }
  345. }
  346. if (req.aborted) {
  347. // client has disconnected -- skip delayed s3 download
  348. return
  349. }
  350. if (!body.zipUrl) {
  351. return next(
  352. new OError('Missing zipUrl, cannot fetch zip file', {
  353. v1ProjectId,
  354. body,
  355. statusCode: response.statusCode,
  356. })
  357. )
  358. }
  359. let retryAttempt = 0
  360. let retryDelay = 2000
  361. // retry for about 6 minutes starting with short delay
  362. async.retry(
  363. 40,
  364. callback =>
  365. setTimeout(function () {
  366. if (req.aborted) {
  367. // client has disconnected -- skip s3 download
  368. return callback() // stop async.retry loop
  369. }
  370. // increase delay by 1 second up to 10
  371. if (retryDelay < 10000) {
  372. retryDelay += 1000
  373. }
  374. retryAttempt++
  375. const getReq = request({
  376. url: body.zipUrl,
  377. sendImmediately: true,
  378. })
  379. const abortS3Request = () => getReq.abort()
  380. req.on('aborted', abortS3Request)
  381. res.on('timeout', abortS3Request)
  382. function cleanupAbortTrigger() {
  383. req.off('aborted', abortS3Request)
  384. res.off('timeout', abortS3Request)
  385. }
  386. getReq.on('response', function (response) {
  387. if (response.statusCode !== 200) {
  388. cleanupAbortTrigger()
  389. return callback(new Error('invalid response'))
  390. }
  391. // pipe also proxies the headers, but we want to customize these ones
  392. delete response.headers['content-disposition']
  393. delete response.headers['content-type']
  394. res.status(response.statusCode)
  395. prepareZipAttachment(res, `${name}.zip`)
  396. pipeline(response, res, err => {
  397. if (err) {
  398. logger.warn(
  399. { err, v1ProjectId, version, retryAttempt },
  400. 'history s3 proxying error'
  401. )
  402. }
  403. })
  404. callback()
  405. })
  406. getReq.on('error', function (err) {
  407. logger.warn(
  408. { err, v1ProjectId, version, retryAttempt },
  409. 'history s3 download error'
  410. )
  411. cleanupAbortTrigger()
  412. callback(err)
  413. })
  414. }, retryDelay),
  415. function (err) {
  416. if (err) {
  417. OError.tag(err, 'history s3 download failed', {
  418. v1ProjectId,
  419. version,
  420. retryAttempt,
  421. })
  422. next(err)
  423. }
  424. }
  425. )
  426. })
  427. },
  428. }