HistoryController.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. // @ts-check
  2. const { setTimeout } = require('timers/promises')
  3. const { pipeline } = require('stream/promises')
  4. const OError = require('@overleaf/o-error')
  5. const logger = require('@overleaf/logger')
  6. const { expressify } = require('@overleaf/promise-utils')
  7. const {
  8. fetchStream,
  9. fetchStreamWithResponse,
  10. fetchJson,
  11. fetchNothing,
  12. RequestFailedError,
  13. } = require('@overleaf/fetch-utils')
  14. const settings = require('@overleaf/settings')
  15. const SessionManager = require('../Authentication/SessionManager')
  16. const UserGetter = require('../User/UserGetter')
  17. const ProjectGetter = require('../Project/ProjectGetter')
  18. const Errors = require('../Errors/Errors')
  19. const HistoryManager = require('./HistoryManager')
  20. const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
  21. const ProjectEntityUpdateHandler = require('../Project/ProjectEntityUpdateHandler')
  22. const RestoreManager = require('./RestoreManager')
  23. const { prepareZipAttachment } = require('../../infrastructure/Response')
  24. const Features = require('../../infrastructure/Features')
  25. // Number of seconds after which the browser should send a request to revalidate
  26. // blobs
  27. const REVALIDATE_BLOB_AFTER_SECONDS = 86400 // 1 day
  28. // Number of seconds during which the browser can serve a stale response while
  29. // revalidating
  30. const STALE_WHILE_REVALIDATE_SECONDS = 365 * 86400 // 1 year
  31. const MAX_HISTORY_ZIP_ATTEMPTS = 40
  32. async function getBlob(req, res) {
  33. await requestBlob('GET', req, res)
  34. }
  35. async function headBlob(req, res) {
  36. await requestBlob('HEAD', req, res)
  37. }
  38. async function requestBlob(method, req, res) {
  39. const { project_id: projectId, hash } = req.params
  40. // Handle conditional GET request
  41. if (req.get('If-None-Match') === hash) {
  42. setBlobCacheHeaders(res, hash)
  43. return res.status(304).end()
  44. }
  45. const range = req.get('Range')
  46. let stream, contentLength
  47. try {
  48. ;({ stream, contentLength } =
  49. await HistoryManager.promises.requestBlobWithProjectId(
  50. projectId,
  51. hash,
  52. method,
  53. range
  54. ))
  55. } catch (err) {
  56. if (err instanceof Errors.NotFoundError) return res.status(404).end()
  57. throw err
  58. }
  59. if (contentLength) res.setHeader('Content-Length', contentLength) // set on HEAD
  60. res.setHeader('Content-Type', 'application/octet-stream')
  61. setBlobCacheHeaders(res, hash)
  62. try {
  63. await pipeline(stream, res)
  64. } catch (err) {
  65. // If the downstream request is cancelled, we get an
  66. // ERR_STREAM_PREMATURE_CLOSE, ignore these "errors".
  67. if (!isPrematureClose(err)) {
  68. throw err
  69. }
  70. }
  71. }
  72. function setBlobCacheHeaders(res, etag) {
  73. // Blobs are immutable, so they can in principle be cached indefinitely. Here,
  74. // we ask the browser to cache them for some time, but then check back
  75. // regularly in case they changed (even though they shouldn't). This is a
  76. // precaution in case a bug makes us send bad data through that endpoint.
  77. res.set(
  78. 'Cache-Control',
  79. `private, max-age=${REVALIDATE_BLOB_AFTER_SECONDS}, stale-while-revalidate=${STALE_WHILE_REVALIDATE_SECONDS}`
  80. )
  81. res.set('ETag', etag)
  82. }
  83. async function proxyToHistoryApi(req, res, next) {
  84. const userId = SessionManager.getLoggedInUserId(req.session)
  85. const url = settings.apis.project_history.url + req.url
  86. const { stream, response } = await fetchStreamWithResponse(url, {
  87. method: req.method,
  88. headers: { 'X-User-Id': userId },
  89. })
  90. const contentType = response.headers.get('Content-Type')
  91. const contentLength = response.headers.get('Content-Length')
  92. if (contentType != null) {
  93. res.set('Content-Type', contentType)
  94. }
  95. if (contentLength != null) {
  96. res.set('Content-Length', contentLength)
  97. }
  98. try {
  99. await pipeline(stream, res)
  100. } catch (err) {
  101. // If the downstream request is cancelled, we get an
  102. // ERR_STREAM_PREMATURE_CLOSE.
  103. if (!isPrematureClose(err)) {
  104. throw err
  105. }
  106. }
  107. }
  108. async function proxyToHistoryApiAndInjectUserDetails(req, res, next) {
  109. const userId = SessionManager.getLoggedInUserId(req.session)
  110. const url = settings.apis.project_history.url + req.url
  111. const body = await fetchJson(url, {
  112. method: req.method,
  113. headers: { 'X-User-Id': userId },
  114. })
  115. const data = await HistoryManager.promises.injectUserDetails(body)
  116. res.json(data)
  117. }
  118. async function resyncProjectHistory(req, res, next) {
  119. // increase timeout to 6 minutes
  120. res.setTimeout(6 * 60 * 1000)
  121. const projectId = req.params.Project_id
  122. const opts = {}
  123. const historyRangesMigration = req.body.historyRangesMigration
  124. if (historyRangesMigration) {
  125. opts.historyRangesMigration = historyRangesMigration
  126. }
  127. if (req.body.resyncProjectStructureOnly) {
  128. opts.resyncProjectStructureOnly = req.body.resyncProjectStructureOnly
  129. }
  130. try {
  131. await ProjectEntityUpdateHandler.promises.resyncProjectHistory(
  132. projectId,
  133. opts
  134. )
  135. } catch (err) {
  136. if (err instanceof Errors.ProjectHistoryDisabledError) {
  137. return res.sendStatus(404)
  138. } else {
  139. throw err
  140. }
  141. }
  142. res.sendStatus(204)
  143. }
  144. async function restoreFileFromV2(req, res, next) {
  145. const { project_id: projectId } = req.params
  146. const { version, pathname } = req.body
  147. const userId = SessionManager.getLoggedInUserId(req.session)
  148. const entity = await RestoreManager.promises.restoreFileFromV2(
  149. userId,
  150. projectId,
  151. version,
  152. pathname
  153. )
  154. res.json({
  155. type: entity.type,
  156. id: entity._id,
  157. })
  158. }
  159. async function revertFile(req, res, next) {
  160. const { project_id: projectId } = req.params
  161. const { version, pathname } = req.body
  162. const userId = SessionManager.getLoggedInUserId(req.session)
  163. const entity = await RestoreManager.promises.revertFile(
  164. userId,
  165. projectId,
  166. version,
  167. pathname,
  168. {}
  169. )
  170. res.json({
  171. type: entity.type,
  172. id: entity._id,
  173. })
  174. }
  175. async function revertProject(req, res, next) {
  176. const { project_id: projectId } = req.params
  177. const { version } = req.body
  178. const userId = SessionManager.getLoggedInUserId(req.session)
  179. await RestoreManager.promises.revertProject(userId, projectId, version)
  180. res.sendStatus(200)
  181. }
  182. async function getLabels(req, res, next) {
  183. const projectId = req.params.Project_id
  184. let labels = await fetchJson(
  185. `${settings.apis.project_history.url}/project/${projectId}/labels`
  186. )
  187. labels = await _enrichLabels(labels)
  188. res.json(labels)
  189. }
  190. async function createLabel(req, res, next) {
  191. const projectId = req.params.Project_id
  192. const { comment, version } = req.body
  193. const userId = SessionManager.getLoggedInUserId(req.session)
  194. let label = await fetchJson(
  195. `${settings.apis.project_history.url}/project/${projectId}/labels`,
  196. {
  197. method: 'POST',
  198. json: { comment, version, user_id: userId },
  199. }
  200. )
  201. label = await _enrichLabel(label)
  202. res.json(label)
  203. }
  204. async function _enrichLabel(label) {
  205. const newLabel = Object.assign({}, label)
  206. if (!label.user_id) {
  207. newLabel.user_display_name = _displayNameForUser(null)
  208. return newLabel
  209. }
  210. const user = await UserGetter.promises.getUser(label.user_id, {
  211. first_name: 1,
  212. last_name: 1,
  213. email: 1,
  214. })
  215. newLabel.user_display_name = _displayNameForUser(user)
  216. return newLabel
  217. }
  218. async function _enrichLabels(labels) {
  219. if (!labels || !labels.length) {
  220. return []
  221. }
  222. const uniqueUsers = new Set(labels.map(label => label.user_id))
  223. // For backwards compatibility, and for anonymously created labels in SP
  224. // expect missing user_id fields
  225. uniqueUsers.delete(undefined)
  226. if (!uniqueUsers.size) {
  227. return labels
  228. }
  229. const rawUsers = await UserGetter.promises.getUsers(Array.from(uniqueUsers), {
  230. first_name: 1,
  231. last_name: 1,
  232. email: 1,
  233. })
  234. const users = new Map(rawUsers.map(user => [String(user._id), user]))
  235. labels.forEach(label => {
  236. const user = users.get(label.user_id)
  237. label.user_display_name = _displayNameForUser(user)
  238. })
  239. return labels
  240. }
  241. function _displayNameForUser(user) {
  242. if (user == null) {
  243. return 'Anonymous'
  244. }
  245. if (user.name) {
  246. return user.name
  247. }
  248. let name = [user.first_name, user.last_name]
  249. .filter(n => n != null)
  250. .join(' ')
  251. .trim()
  252. if (name === '') {
  253. name = user.email.split('@')[0]
  254. }
  255. if (!name) {
  256. return '?'
  257. }
  258. return name
  259. }
  260. async function deleteLabel(req, res, next) {
  261. const { Project_id: projectId, label_id: labelId } = req.params
  262. const userId = SessionManager.getLoggedInUserId(req.session)
  263. const project = await ProjectGetter.promises.getProject(projectId, {
  264. owner_ref: true,
  265. })
  266. // If the current user is the project owner, we can use the non-user-specific
  267. // delete label endpoint. Otherwise, we have to use the user-specific version
  268. // (which only deletes the label if it is owned by the user)
  269. const deleteEndpointUrl = project.owner_ref.equals(userId)
  270. ? `${settings.apis.project_history.url}/project/${projectId}/labels/${labelId}`
  271. : `${settings.apis.project_history.url}/project/${projectId}/user/${userId}/labels/${labelId}`
  272. await fetchNothing(deleteEndpointUrl, {
  273. method: 'DELETE',
  274. })
  275. res.sendStatus(204)
  276. }
  277. async function downloadZipOfVersion(req, res, next) {
  278. const { project_id: projectId, version } = req.params
  279. const project = await ProjectDetailsHandler.promises.getDetails(projectId)
  280. const v1Id =
  281. project.overleaf && project.overleaf.history && project.overleaf.history.id
  282. if (v1Id == null) {
  283. logger.error(
  284. { projectId, version },
  285. 'got request for zip version of non-v1 history project'
  286. )
  287. return res.sendStatus(402)
  288. }
  289. await _pipeHistoryZipToResponse(
  290. v1Id,
  291. version,
  292. `${project.name} (Version ${version})`,
  293. req,
  294. res
  295. )
  296. }
  297. async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) {
  298. if (req.destroyed) {
  299. // client has disconnected -- skip project history api call and download
  300. return
  301. }
  302. // increase timeout to 6 minutes
  303. res.setTimeout(6 * 60 * 1000)
  304. const url = `${settings.apis.v1_history.url}/projects/${v1ProjectId}/version/${version}/zip`
  305. const basicAuth = {
  306. user: settings.apis.v1_history.user,
  307. password: settings.apis.v1_history.pass,
  308. }
  309. if (!Features.hasFeature('saas')) {
  310. let stream
  311. try {
  312. stream = await fetchStream(url, { basicAuth })
  313. } catch (err) {
  314. if (err instanceof RequestFailedError && err.response.status === 404) {
  315. return res.sendStatus(404)
  316. } else {
  317. throw err
  318. }
  319. }
  320. prepareZipAttachment(res, `${name}.zip`)
  321. try {
  322. await pipeline(stream, res)
  323. } catch (err) {
  324. // If the downstream request is cancelled, we get an
  325. // ERR_STREAM_PREMATURE_CLOSE.
  326. if (!isPrematureClose(err)) {
  327. throw err
  328. }
  329. }
  330. return
  331. }
  332. let body
  333. try {
  334. body = await fetchJson(url, { method: 'POST', basicAuth })
  335. } catch (err) {
  336. if (err instanceof RequestFailedError && err.response.status === 404) {
  337. throw new Errors.NotFoundError('zip not found')
  338. } else {
  339. throw err
  340. }
  341. }
  342. if (req.destroyed) {
  343. // client has disconnected -- skip delayed s3 download
  344. return
  345. }
  346. if (!body.zipUrl) {
  347. throw new OError('Missing zipUrl, cannot fetch zip file', {
  348. v1ProjectId,
  349. body,
  350. })
  351. }
  352. // retry for about 6 minutes starting with short delay
  353. let retryDelay = 2000
  354. let attempt = 0
  355. while (true) {
  356. attempt += 1
  357. await setTimeout(retryDelay)
  358. if (req.destroyed) {
  359. // client has disconnected -- skip s3 download
  360. return
  361. }
  362. // increase delay by 1 second up to 10
  363. if (retryDelay < 10000) {
  364. retryDelay += 1000
  365. }
  366. try {
  367. const stream = await fetchStream(body.zipUrl)
  368. prepareZipAttachment(res, `${name}.zip`)
  369. await pipeline(stream, res)
  370. } catch (err) {
  371. if (attempt > MAX_HISTORY_ZIP_ATTEMPTS) {
  372. throw err
  373. }
  374. if (err instanceof RequestFailedError && err.response.status === 404) {
  375. // File not ready yet. Retry.
  376. continue
  377. } else if (isPrematureClose(err)) {
  378. // Downstream request cancelled. Retry.
  379. continue
  380. } else {
  381. // Unknown error. Log and retry.
  382. logger.warn(
  383. { err, v1ProjectId, version, retryAttempt: attempt },
  384. 'history s3 proxying error'
  385. )
  386. continue
  387. }
  388. }
  389. // We made it through. No need to retry anymore. Exit loop
  390. break
  391. }
  392. }
  393. async function getLatestHistory(req, res, next) {
  394. const projectId = req.params.project_id
  395. const history = await HistoryManager.promises.getLatestHistory(projectId)
  396. res.json(history)
  397. }
  398. async function getChanges(req, res, next) {
  399. const projectId = req.params.project_id
  400. let since = req.query.since
  401. // TODO: Transition flag; remove after a while
  402. const paginated = req.query.paginated === 'true'
  403. if (paginated) {
  404. const changes = await HistoryManager.promises.getChanges(projectId, {
  405. since,
  406. })
  407. return res.json(changes)
  408. } else {
  409. // TODO: Remove this code path after a while
  410. let hasMore = true
  411. const allChanges = []
  412. while (hasMore) {
  413. const response = await HistoryManager.promises.getChanges(projectId, {
  414. since,
  415. })
  416. let changes
  417. if (Array.isArray(response)) {
  418. changes = response
  419. hasMore = false
  420. } else {
  421. changes = response.changes
  422. hasMore = response.hasMore
  423. since += changes.length
  424. }
  425. allChanges.push(...changes)
  426. }
  427. return res.json(allChanges)
  428. }
  429. }
  430. function isPrematureClose(err) {
  431. return (
  432. err instanceof Error &&
  433. 'code' in err &&
  434. err.code === 'ERR_STREAM_PREMATURE_CLOSE'
  435. )
  436. }
  437. module.exports = {
  438. getBlob: expressify(getBlob),
  439. headBlob: expressify(headBlob),
  440. proxyToHistoryApi: expressify(proxyToHistoryApi),
  441. proxyToHistoryApiAndInjectUserDetails: expressify(
  442. proxyToHistoryApiAndInjectUserDetails
  443. ),
  444. resyncProjectHistory: expressify(resyncProjectHistory),
  445. restoreFileFromV2: expressify(restoreFileFromV2),
  446. revertFile: expressify(revertFile),
  447. revertProject: expressify(revertProject),
  448. getLabels: expressify(getLabels),
  449. createLabel: expressify(createLabel),
  450. deleteLabel: expressify(deleteLabel),
  451. downloadZipOfVersion: expressify(downloadZipOfVersion),
  452. getLatestHistory: expressify(getLatestHistory),
  453. getChanges: expressify(getChanges),
  454. _displayNameForUser,
  455. promises: {
  456. _pipeHistoryZipToResponse,
  457. },
  458. }