projects.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  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. BatchBlobStore,
  15. blobHash,
  16. chunkStore,
  17. redisBuffer,
  18. HashCheckBlobStore,
  19. ProjectArchive,
  20. zipStore,
  21. } = require('../../storage')
  22. const render = require('./render')
  23. const expressify = require('./expressify')
  24. const withTmpDir = require('./with_tmp_dir')
  25. const StreamSizeLimit = require('./stream_size_limit')
  26. const { getProjectBlobsBatch } = require('../../storage/lib/blob_store')
  27. const assert = require('../../storage/lib/assert')
  28. const { getChunkMetadataForVersion } = require('../../storage/lib/chunk_store')
  29. const pipeline = promisify(Stream.pipeline)
  30. async function initializeProject(req, res, next) {
  31. let projectId = req.swagger.params.body.value.projectId
  32. try {
  33. projectId = await chunkStore.initializeProject(projectId)
  34. res.status(HTTPStatus.OK).json({ projectId })
  35. } catch (err) {
  36. if (err instanceof chunkStore.AlreadyInitialized) {
  37. logger.warn({ err, projectId }, 'failed to initialize')
  38. render.conflict(res)
  39. } else {
  40. throw err
  41. }
  42. }
  43. }
  44. async function getLatestContent(req, res, next) {
  45. const projectId = req.swagger.params.project_id.value
  46. const blobStore = new BlobStore(projectId)
  47. const chunk = await chunkStore.loadLatest(projectId)
  48. const snapshot = chunk.getSnapshot()
  49. snapshot.applyAll(chunk.getChanges())
  50. await snapshot.loadFiles('eager', blobStore)
  51. res.json(snapshot.toRaw())
  52. }
  53. async function getContentAtVersion(req, res, next) {
  54. const projectId = req.swagger.params.project_id.value
  55. const version = req.swagger.params.version.value
  56. const blobStore = new BlobStore(projectId)
  57. const snapshot = await getSnapshotAtVersion(projectId, version)
  58. await snapshot.loadFiles('eager', blobStore)
  59. res.json(snapshot.toRaw())
  60. }
  61. async function getLatestHashedContent(req, res, next) {
  62. const projectId = req.swagger.params.project_id.value
  63. const blobStore = new HashCheckBlobStore(new BlobStore(projectId))
  64. const chunk = await chunkStore.loadLatest(projectId)
  65. const snapshot = chunk.getSnapshot()
  66. snapshot.applyAll(chunk.getChanges())
  67. await snapshot.loadFiles('eager', blobStore)
  68. const rawSnapshot = await snapshot.store(blobStore)
  69. res.json(rawSnapshot)
  70. }
  71. async function getLatestHistory(req, res, next) {
  72. const projectId = req.swagger.params.project_id.value
  73. try {
  74. const chunk = await chunkStore.loadLatest(projectId)
  75. const chunkResponse = new ChunkResponse(chunk)
  76. res.json(chunkResponse.toRaw())
  77. } catch (err) {
  78. if (err instanceof Chunk.NotFoundError) {
  79. render.notFound(res)
  80. } else {
  81. throw err
  82. }
  83. }
  84. }
  85. async function getLatestHistoryRaw(req, res, next) {
  86. const projectId = req.swagger.params.project_id.value
  87. const readOnly = req.swagger.params.readOnly.value
  88. try {
  89. const { startVersion, endVersion, endTimestamp } =
  90. await chunkStore.getLatestChunkMetadata(projectId, { readOnly })
  91. res.json({
  92. startVersion,
  93. endVersion,
  94. endTimestamp,
  95. })
  96. } catch (err) {
  97. if (err instanceof Chunk.NotFoundError) {
  98. render.notFound(res)
  99. } else {
  100. throw err
  101. }
  102. }
  103. }
  104. async function getHistory(req, res, next) {
  105. const projectId = req.swagger.params.project_id.value
  106. const version = req.swagger.params.version.value
  107. try {
  108. const chunk = await chunkStore.loadAtVersion(projectId, version)
  109. const chunkResponse = new ChunkResponse(chunk)
  110. res.json(chunkResponse.toRaw())
  111. } catch (err) {
  112. if (err instanceof Chunk.NotFoundError) {
  113. render.notFound(res)
  114. } else {
  115. throw err
  116. }
  117. }
  118. }
  119. async function getHistoryBefore(req, res, next) {
  120. const projectId = req.swagger.params.project_id.value
  121. const timestamp = req.swagger.params.timestamp.value
  122. try {
  123. const chunk = await chunkStore.loadAtTimestamp(projectId, timestamp)
  124. const chunkResponse = new ChunkResponse(chunk)
  125. res.json(chunkResponse.toRaw())
  126. } catch (err) {
  127. if (err instanceof Chunk.NotFoundError) {
  128. render.notFound(res)
  129. } else {
  130. throw err
  131. }
  132. }
  133. }
  134. /**
  135. * Get all changes since the beginning of history or since a given version
  136. */
  137. async function getChanges(req, res, next) {
  138. const projectId = req.swagger.params.project_id.value
  139. const since = req.swagger.params.since.value ?? 0
  140. if (since < 0) {
  141. // Negative values would cause an infinite loop
  142. return res.status(400).json({
  143. error: `Version out of bounds: ${since}`,
  144. })
  145. }
  146. try {
  147. const { changes, hasMore } = await chunkStore.getChangesSinceVersion(
  148. projectId,
  149. since
  150. )
  151. res.json({ changes: changes.map(change => change.toRaw()), hasMore })
  152. } catch (err) {
  153. if (err instanceof Chunk.VersionNotFoundError) {
  154. return res.status(400).json({
  155. error: `Version out of bounds: ${since}`,
  156. })
  157. }
  158. throw err
  159. }
  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=(\d{1,7})-(\d{1,7})$/
  261. /**
  262. * @param {string} header
  263. * @return {undefined | {start: number, end: number}}
  264. * @private
  265. */
  266. function _getRangeOpts(header) {
  267. if (!header) return undefined
  268. const match = header.match(RANGE_HEADER)
  269. if (match) {
  270. const start = parseInt(match[1], 10)
  271. const end = parseInt(match[2], 10)
  272. return { start, end }
  273. }
  274. return undefined
  275. }
  276. async function getProjectBlob(req, res, next) {
  277. const projectId = req.swagger.params.project_id.value
  278. const hash = req.swagger.params.hash.value
  279. const opts = _getRangeOpts(req.swagger.params.range.value || '')
  280. const blobStore = new BlobStore(projectId)
  281. logger.debug({ projectId, hash }, 'getProjectBlob started')
  282. try {
  283. let stream
  284. try {
  285. if (opts) {
  286. // This is a range request, so we need to set the appropriate headers
  287. // Browser caching only works if the total size is known, so we have
  288. // to fetch the blob metadata first.
  289. const metaData = await blobStore.getBlob(hash)
  290. if (metaData) {
  291. const blobLength = metaData.getByteLength()
  292. if (opts.start > opts.end || opts.start >= blobLength) {
  293. return res
  294. .status(416) // Requested Range Not Satisfiable
  295. .set('Content-Range', `bytes */${blobLength}`)
  296. .set('Content-Length', '0')
  297. .end()
  298. }
  299. // Valid range request
  300. const actualEnd = Math.min(opts.end, blobLength - 1)
  301. const returnedSize = actualEnd - opts.start + 1
  302. res.set('Content-Length', returnedSize)
  303. res.set(
  304. 'Content-Range',
  305. `bytes ${opts.start}-${actualEnd}/${blobLength}`
  306. )
  307. res.status(206)
  308. }
  309. }
  310. stream = await blobStore.getStream(hash, opts)
  311. } catch (err) {
  312. if (err instanceof Blob.NotFoundError) {
  313. logger.warn({ projectId, hash }, 'Blob not found')
  314. return res.status(404).end()
  315. } else {
  316. throw err
  317. }
  318. }
  319. res.set('Content-Type', 'application/octet-stream')
  320. try {
  321. await pipeline(stream, res)
  322. } catch (err) {
  323. if (err?.code === 'ERR_STREAM_PREMATURE_CLOSE') {
  324. res.end()
  325. } else {
  326. throw OError.tag(err, 'error transferring stream', { projectId, hash })
  327. }
  328. }
  329. } finally {
  330. logger.debug({ projectId, hash }, 'getProjectBlob finished')
  331. }
  332. }
  333. async function copyProjectBlob(req, res, next) {
  334. const sourceProjectId = req.swagger.params.copyFrom.value
  335. const targetProjectId = req.swagger.params.project_id.value
  336. const blobHash = req.swagger.params.hash.value
  337. // Check that blob exists in source project
  338. const sourceBlobStore = new BlobStore(sourceProjectId)
  339. const targetBlobStore = new BlobStore(targetProjectId)
  340. const [sourceBlob, targetBlob] = await Promise.all([
  341. sourceBlobStore.getBlob(blobHash),
  342. targetBlobStore.getBlob(blobHash),
  343. ])
  344. if (!sourceBlob) {
  345. logger.warn(
  346. { sourceProjectId, targetProjectId, blobHash },
  347. 'missing source blob when copying across projects'
  348. )
  349. return render.notFound(res)
  350. }
  351. // Exit early if the blob exists in the target project.
  352. // This will also catch global blobs, which always exist.
  353. if (targetBlob) {
  354. return res.status(HTTPStatus.NO_CONTENT).end()
  355. }
  356. // Otherwise, copy blob from source project to target project
  357. await sourceBlobStore.copyBlob(sourceBlob, targetProjectId)
  358. res.status(HTTPStatus.CREATED).end()
  359. }
  360. async function getSnapshotAtVersion(projectId, version) {
  361. const chunk = await chunkStore.loadAtVersion(projectId, version)
  362. const snapshot = chunk.getSnapshot()
  363. const changes = _.dropRight(
  364. chunk.getChanges(),
  365. chunk.getEndVersion() - version
  366. )
  367. if (changes.length > 0) {
  368. snapshot.applyAll(changes)
  369. } else {
  370. // There are no changes in this chunk; we need to look at the previous chunk
  371. // to get the snapshot's timestamp
  372. let chunkMetadata
  373. try {
  374. chunkMetadata = await getChunkMetadataForVersion(projectId, version)
  375. } catch (err) {
  376. if (err instanceof Chunk.VersionNotFoundError) {
  377. // The snapshot is the first snapshot of the first chunk, so we can't
  378. // find a timestamp. This shouldn't happen often. Ignore the error and
  379. // leave the timestamp empty.
  380. } else {
  381. throw err
  382. }
  383. }
  384. snapshot.setTimestamp(chunkMetadata.endTimestamp)
  385. }
  386. return snapshot
  387. }
  388. function sumUpByteLength(blobs) {
  389. return blobs.reduce((sum, blob) => sum + blob.getByteLength(), 0)
  390. }
  391. async function getBlobStats(req, res) {
  392. const projectId = req.swagger.params.project_id.value
  393. const blobHashes = req.swagger.params.body.value.blobHashes || []
  394. for (const hash of blobHashes) {
  395. assert.blobHash(hash, 'bad hash')
  396. }
  397. const blobStore = new BlobStore(projectId)
  398. const batchBlobStore = new BatchBlobStore(blobStore)
  399. await batchBlobStore.preload(Array.from(blobHashes))
  400. const blobs = Array.from(batchBlobStore.blobs.values()).filter(Boolean)
  401. const textBlobs = blobs.filter(b => b.getStringLength() !== null)
  402. const binaryBlobs = blobs.filter(b => b.getStringLength() === null)
  403. const textBlobBytes = sumUpByteLength(textBlobs)
  404. const binaryBlobBytes = sumUpByteLength(binaryBlobs)
  405. res.json({
  406. projectId,
  407. textBlobBytes,
  408. binaryBlobBytes,
  409. totalBytes: textBlobBytes + binaryBlobBytes,
  410. nTextBlobs: textBlobs.length,
  411. nBinaryBlobs: binaryBlobs.length,
  412. })
  413. }
  414. async function getProjectBlobsStats(req, res) {
  415. const projectIds = req.swagger.params.body.value.projectIds
  416. const { blobs } = await getProjectBlobsBatch(
  417. projectIds.map(id => {
  418. if (assert.POSTGRES_ID_REGEXP.test(id)) {
  419. return parseInt(id, 10)
  420. } else {
  421. return id
  422. }
  423. })
  424. )
  425. const sizes = []
  426. for (const projectId of projectIds) {
  427. const projectBlobs = blobs.get(projectId) || []
  428. const textBlobs = projectBlobs.filter(b => b.getStringLength() !== null)
  429. const binaryBlobs = projectBlobs.filter(b => b.getStringLength() === null)
  430. const textBlobBytes = sumUpByteLength(textBlobs)
  431. const binaryBlobBytes = sumUpByteLength(binaryBlobs)
  432. sizes.push({
  433. projectId,
  434. textBlobBytes,
  435. binaryBlobBytes,
  436. totalBytes: textBlobBytes + binaryBlobBytes,
  437. nTextBlobs: textBlobs.length,
  438. nBinaryBlobs: binaryBlobs.length,
  439. })
  440. }
  441. res.json(sizes)
  442. }
  443. module.exports = {
  444. initializeProject: expressify(initializeProject),
  445. getLatestContent: expressify(getLatestContent),
  446. getContentAtVersion: expressify(getContentAtVersion),
  447. getLatestHashedContent: expressify(getLatestHashedContent),
  448. getLatestPersistedHistory: expressify(getLatestHistory),
  449. getLatestHistory: expressify(getLatestHistory),
  450. getLatestHistoryRaw: expressify(getLatestHistoryRaw),
  451. getHistory: expressify(getHistory),
  452. getHistoryBefore: expressify(getHistoryBefore),
  453. getChanges: expressify(getChanges),
  454. getZip: expressify(getZip),
  455. createZip: expressify(createZip),
  456. deleteProject: expressify(deleteProject),
  457. createProjectBlob: expressify(createProjectBlob),
  458. getProjectBlob: expressify(getProjectBlob),
  459. headProjectBlob: expressify(headProjectBlob),
  460. copyProjectBlob: expressify(copyProjectBlob),
  461. getBlobStats: expressify(getBlobStats),
  462. getProjectBlobsStats: expressify(getProjectBlobsStats),
  463. }