HistoryController.mjs 15 KB

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