projects.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. 'use strict'
  2. const _ = require('lodash')
  3. const Path = require('node:path')
  4. const Stream = require('node:stream')
  5. const HTTPStatus = require('http-status')
  6. const fs = require('node:fs')
  7. const { promisify } = require('node:util')
  8. const config = require('config')
  9. const OError = require('@overleaf/o-error')
  10. const logger = require('@overleaf/logger')
  11. const { Chunk, ChunkResponse, Blob } = require('overleaf-editor-core')
  12. const {
  13. BlobStore,
  14. blobHash,
  15. chunkStore,
  16. redisBuffer,
  17. HashCheckBlobStore,
  18. ProjectArchive,
  19. zipStore,
  20. } = require('../../storage')
  21. const render = require('./render')
  22. const expressify = require('./expressify')
  23. const withTmpDir = require('./with_tmp_dir')
  24. const StreamSizeLimit = require('./stream_size_limit')
  25. const pipeline = promisify(Stream.pipeline)
  26. async function initializeProject(req, res, next) {
  27. let projectId = req.swagger.params.body.value.projectId
  28. try {
  29. projectId = await chunkStore.initializeProject(projectId)
  30. res.status(HTTPStatus.OK).json({ projectId })
  31. } catch (err) {
  32. if (err instanceof chunkStore.AlreadyInitialized) {
  33. logger.warn({ err, projectId }, 'failed to initialize')
  34. render.conflict(res)
  35. } else {
  36. throw err
  37. }
  38. }
  39. }
  40. async function getLatestContent(req, res, next) {
  41. const projectId = req.swagger.params.project_id.value
  42. const blobStore = new BlobStore(projectId)
  43. const chunk = await chunkStore.loadLatest(projectId)
  44. const snapshot = chunk.getSnapshot()
  45. snapshot.applyAll(chunk.getChanges())
  46. await snapshot.loadFiles('eager', blobStore)
  47. res.json(snapshot.toRaw())
  48. }
  49. async function getContentAtVersion(req, res, next) {
  50. const projectId = req.swagger.params.project_id.value
  51. const version = req.swagger.params.version.value
  52. const blobStore = new BlobStore(projectId)
  53. const snapshot = await getSnapshotAtVersion(projectId, version)
  54. await snapshot.loadFiles('eager', blobStore)
  55. res.json(snapshot.toRaw())
  56. }
  57. async function getLatestHashedContent(req, res, next) {
  58. const projectId = req.swagger.params.project_id.value
  59. const blobStore = new HashCheckBlobStore(new BlobStore(projectId))
  60. const chunk = await chunkStore.loadLatest(projectId)
  61. const snapshot = chunk.getSnapshot()
  62. snapshot.applyAll(chunk.getChanges())
  63. await snapshot.loadFiles('eager', blobStore)
  64. const rawSnapshot = await snapshot.store(blobStore)
  65. res.json(rawSnapshot)
  66. }
  67. async function getLatestHistory(req, res, next) {
  68. const projectId = req.swagger.params.project_id.value
  69. try {
  70. const chunk = await chunkStore.loadLatest(projectId)
  71. const chunkResponse = new ChunkResponse(chunk)
  72. res.json(chunkResponse.toRaw())
  73. } catch (err) {
  74. if (err instanceof Chunk.NotFoundError) {
  75. render.notFound(res)
  76. } else {
  77. throw err
  78. }
  79. }
  80. }
  81. async function getLatestHistoryRaw(req, res, next) {
  82. const projectId = req.swagger.params.project_id.value
  83. const readOnly = req.swagger.params.readOnly.value
  84. try {
  85. const { startVersion, endVersion, endTimestamp } =
  86. await chunkStore.getLatestChunkMetadata(projectId, { readOnly })
  87. res.json({
  88. startVersion,
  89. endVersion,
  90. endTimestamp,
  91. })
  92. } catch (err) {
  93. if (err instanceof Chunk.NotFoundError) {
  94. render.notFound(res)
  95. } else {
  96. throw err
  97. }
  98. }
  99. }
  100. async function getHistory(req, res, next) {
  101. const projectId = req.swagger.params.project_id.value
  102. const version = req.swagger.params.version.value
  103. try {
  104. const chunk = await chunkStore.loadAtVersion(projectId, version)
  105. const chunkResponse = new ChunkResponse(chunk)
  106. res.json(chunkResponse.toRaw())
  107. } catch (err) {
  108. if (err instanceof Chunk.NotFoundError) {
  109. render.notFound(res)
  110. } else {
  111. throw err
  112. }
  113. }
  114. }
  115. async function getHistoryBefore(req, res, next) {
  116. const projectId = req.swagger.params.project_id.value
  117. const timestamp = req.swagger.params.timestamp.value
  118. try {
  119. const chunk = await chunkStore.loadAtTimestamp(projectId, timestamp)
  120. const chunkResponse = new ChunkResponse(chunk)
  121. res.json(chunkResponse.toRaw())
  122. } catch (err) {
  123. if (err instanceof Chunk.NotFoundError) {
  124. render.notFound(res)
  125. } else {
  126. throw err
  127. }
  128. }
  129. }
  130. /**
  131. * Get all changes since the beginning of history or since a given version
  132. */
  133. async function getChanges(req, res, next) {
  134. const projectId = req.swagger.params.project_id.value
  135. const since = req.swagger.params.since.value ?? 0
  136. if (since < 0) {
  137. // Negative values would cause an infinite loop
  138. return res.status(400).json({
  139. error: `Version out of bounds: ${since}`,
  140. })
  141. }
  142. let chunk
  143. try {
  144. chunk = await chunkStore.loadAtVersion(projectId, since, {
  145. preferNewer: true,
  146. })
  147. } catch (err) {
  148. if (err instanceof Chunk.VersionNotFoundError) {
  149. return res.status(400).json({
  150. error: `Version out of bounds: ${since}`,
  151. })
  152. }
  153. throw err
  154. }
  155. const latestChunkMetadata = await chunkStore.getLatestChunkMetadata(projectId)
  156. // Extract the relevant changes from the chunk that contains the start version
  157. const changes = chunk.getChanges().slice(since - chunk.getStartVersion())
  158. const hasMore = latestChunkMetadata.endVersion > chunk.getEndVersion()
  159. res.json({ changes: changes.map(change => change.toRaw()), hasMore })
  160. }
  161. async function getZip(req, res, next) {
  162. const projectId = req.swagger.params.project_id.value
  163. const version = req.swagger.params.version.value
  164. const blobStore = new BlobStore(projectId)
  165. let snapshot
  166. try {
  167. snapshot = await getSnapshotAtVersion(projectId, version)
  168. } catch (err) {
  169. if (err instanceof Chunk.NotFoundError) {
  170. return render.notFound(res)
  171. } else {
  172. throw err
  173. }
  174. }
  175. await withTmpDir('get-zip-', async tmpDir => {
  176. const tmpFilename = Path.join(tmpDir, 'project.zip')
  177. const archive = new ProjectArchive(snapshot)
  178. await archive.writeZip(blobStore, tmpFilename)
  179. res.set('Content-Type', 'application/octet-stream')
  180. res.set('Content-Disposition', 'attachment; filename=project.zip')
  181. const stream = fs.createReadStream(tmpFilename)
  182. await pipeline(stream, res)
  183. })
  184. }
  185. async function createZip(req, res, next) {
  186. const projectId = req.swagger.params.project_id.value
  187. const version = req.swagger.params.version.value
  188. try {
  189. const snapshot = await getSnapshotAtVersion(projectId, version)
  190. const zipUrl = await zipStore.getSignedUrl(projectId, version)
  191. // Do not await this; run it in the background.
  192. zipStore.storeZip(projectId, version, snapshot).catch(err => {
  193. logger.error({ err, projectId, version }, 'createZip: storeZip failed')
  194. })
  195. res.status(HTTPStatus.OK).json({ zipUrl })
  196. } catch (error) {
  197. if (error instanceof Chunk.NotFoundError) {
  198. render.notFound(res)
  199. } else {
  200. next(error)
  201. }
  202. }
  203. }
  204. async function deleteProject(req, res, next) {
  205. const projectId = req.swagger.params.project_id.value
  206. const blobStore = new BlobStore(projectId)
  207. await Promise.all([
  208. redisBuffer.hardDeleteProject(projectId),
  209. chunkStore.deleteProjectChunks(projectId),
  210. blobStore.deleteBlobs(),
  211. ])
  212. res.status(HTTPStatus.NO_CONTENT).send()
  213. }
  214. async function createProjectBlob(req, res, next) {
  215. const projectId = req.swagger.params.project_id.value
  216. const expectedHash = req.swagger.params.hash.value
  217. const maxUploadSize = parseInt(config.get('maxFileUploadSize'), 10)
  218. await withTmpDir('blob-', async tmpDir => {
  219. const tmpPath = Path.join(tmpDir, 'content')
  220. const sizeLimit = new StreamSizeLimit(maxUploadSize)
  221. await pipeline(req, sizeLimit, fs.createWriteStream(tmpPath))
  222. if (sizeLimit.sizeLimitExceeded) {
  223. logger.warn(
  224. { projectId, expectedHash, maxUploadSize },
  225. 'blob exceeds size threshold'
  226. )
  227. return render.requestEntityTooLarge(res)
  228. }
  229. const hash = await blobHash.fromFile(tmpPath)
  230. if (hash !== expectedHash) {
  231. logger.warn({ projectId, hash, expectedHash }, 'Hash mismatch')
  232. return render.conflict(res, 'File hash mismatch')
  233. }
  234. const blobStore = new BlobStore(projectId)
  235. const newBlob = await blobStore.putFile(tmpPath)
  236. if (config.has('backupStore')) {
  237. try {
  238. const { backupBlob } = await import('../../storage/lib/backupBlob.mjs')
  239. await backupBlob(projectId, newBlob, tmpPath)
  240. } catch (error) {
  241. logger.warn({ error, projectId, hash }, 'Failed to backup blob')
  242. }
  243. }
  244. res.status(HTTPStatus.CREATED).end()
  245. })
  246. }
  247. async function headProjectBlob(req, res) {
  248. const projectId = req.swagger.params.project_id.value
  249. const hash = req.swagger.params.hash.value
  250. const blobStore = new BlobStore(projectId)
  251. const blob = await blobStore.getBlob(hash)
  252. if (blob) {
  253. res.set('Content-Length', blob.getByteLength())
  254. res.status(200).end()
  255. } else {
  256. res.status(404).end()
  257. }
  258. }
  259. // Support simple, singular ranges starting from zero only, up-to 2MB = 2_000_000, 7 digits
  260. const RANGE_HEADER = /^bytes=0-(\d{1,7})$/
  261. /**
  262. * @param {string} header
  263. * @return {{}|{start: number, end: number}}
  264. * @private
  265. */
  266. function _getRangeOpts(header) {
  267. if (!header) return {}
  268. const match = header.match(RANGE_HEADER)
  269. if (match) {
  270. const end = parseInt(match[1], 10)
  271. return { start: 0, end }
  272. }
  273. return {}
  274. }
  275. async function getProjectBlob(req, res, next) {
  276. const projectId = req.swagger.params.project_id.value
  277. const hash = req.swagger.params.hash.value
  278. const opts = _getRangeOpts(req.swagger.params.range.value || '')
  279. const blobStore = new BlobStore(projectId)
  280. logger.debug({ projectId, hash }, 'getProjectBlob started')
  281. try {
  282. let stream
  283. try {
  284. stream = await blobStore.getStream(hash, opts)
  285. } catch (err) {
  286. if (err instanceof Blob.NotFoundError) {
  287. logger.warn({ projectId, hash }, 'Blob not found')
  288. return res.status(404).end()
  289. } else {
  290. throw err
  291. }
  292. }
  293. res.set('Content-Type', 'application/octet-stream')
  294. try {
  295. await pipeline(stream, res)
  296. } catch (err) {
  297. if (err?.code === 'ERR_STREAM_PREMATURE_CLOSE') {
  298. res.end()
  299. } else {
  300. throw OError.tag(err, 'error transferring stream', { projectId, hash })
  301. }
  302. }
  303. } finally {
  304. logger.debug({ projectId, hash }, 'getProjectBlob finished')
  305. }
  306. }
  307. async function copyProjectBlob(req, res, next) {
  308. const sourceProjectId = req.swagger.params.copyFrom.value
  309. const targetProjectId = req.swagger.params.project_id.value
  310. const blobHash = req.swagger.params.hash.value
  311. // Check that blob exists in source project
  312. const sourceBlobStore = new BlobStore(sourceProjectId)
  313. const targetBlobStore = new BlobStore(targetProjectId)
  314. const [sourceBlob, targetBlob] = await Promise.all([
  315. sourceBlobStore.getBlob(blobHash),
  316. targetBlobStore.getBlob(blobHash),
  317. ])
  318. if (!sourceBlob) {
  319. logger.warn(
  320. { sourceProjectId, targetProjectId, blobHash },
  321. 'missing source blob when copying across projects'
  322. )
  323. return render.notFound(res)
  324. }
  325. // Exit early if the blob exists in the target project.
  326. // This will also catch global blobs, which always exist.
  327. if (targetBlob) {
  328. return res.status(HTTPStatus.NO_CONTENT).end()
  329. }
  330. // Otherwise, copy blob from source project to target project
  331. await sourceBlobStore.copyBlob(sourceBlob, targetProjectId)
  332. res.status(HTTPStatus.CREATED).end()
  333. }
  334. async function getSnapshotAtVersion(projectId, version) {
  335. const chunk = await chunkStore.loadAtVersion(projectId, version)
  336. const snapshot = chunk.getSnapshot()
  337. const changes = _.dropRight(
  338. chunk.getChanges(),
  339. chunk.getEndVersion() - version
  340. )
  341. snapshot.applyAll(changes)
  342. return snapshot
  343. }
  344. module.exports = {
  345. initializeProject: expressify(initializeProject),
  346. getLatestContent: expressify(getLatestContent),
  347. getContentAtVersion: expressify(getContentAtVersion),
  348. getLatestHashedContent: expressify(getLatestHashedContent),
  349. getLatestPersistedHistory: expressify(getLatestHistory),
  350. getLatestHistory: expressify(getLatestHistory),
  351. getLatestHistoryRaw: expressify(getLatestHistoryRaw),
  352. getHistory: expressify(getHistory),
  353. getHistoryBefore: expressify(getHistoryBefore),
  354. getChanges: expressify(getChanges),
  355. getZip: expressify(getZip),
  356. createZip: expressify(createZip),
  357. deleteProject: expressify(deleteProject),
  358. createProjectBlob: expressify(createProjectBlob),
  359. getProjectBlob: expressify(getProjectBlob),
  360. headProjectBlob: expressify(headProjectBlob),
  361. copyProjectBlob: expressify(copyProjectBlob),
  362. }