project_archive.js 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. // @ts-check
  2. 'use strict'
  3. /**
  4. * @import { Snapshot } from 'overleaf-editor-core'
  5. * @import { BlobStore } from '../../storage/lib/blob_store/index'
  6. */
  7. const Archive = require('archiver')
  8. const BPromise = require('bluebird')
  9. const fs = require('node:fs')
  10. const { pipeline } = require('node:stream')
  11. const core = require('overleaf-editor-core')
  12. const Snapshot = core.Snapshot
  13. const OError = require('@overleaf/o-error')
  14. const assert = require('./assert')
  15. // The maximum safe concurrency appears to be 1.
  16. // https://github.com/overleaf/issues/issues/1909
  17. const FETCH_CONCURRENCY = 1 // number of files to fetch at once
  18. class DownloadError extends OError {
  19. constructor(hash) {
  20. super(`ProjectArchive: blob download failed: ${hash}`, { hash })
  21. }
  22. }
  23. class ArchiveTimeout extends OError {
  24. constructor() {
  25. super('ProjectArchive timed out')
  26. }
  27. }
  28. class MissingfileError extends OError {
  29. constructor() {
  30. super('ProjectArchive: attempting to look up a file that does not exist')
  31. }
  32. }
  33. class ProjectArchive {
  34. static ArchiveTimeout = ArchiveTimeout
  35. static MissingfileError = MissingfileError
  36. static DownloadError = DownloadError
  37. /**
  38. * @constructor
  39. * @param {Snapshot} snapshot
  40. * @param {number} timeout in ms
  41. * @classdesc
  42. * Writes the project snapshot to a zip file.
  43. */
  44. constructor(snapshot, timeout) {
  45. assert.instance(snapshot, Snapshot)
  46. this.snapshot = snapshot
  47. this.timeout = timeout
  48. }
  49. /**
  50. * Write zip archive to the given file path.
  51. *
  52. * @param {BlobStore} blobStore
  53. * @param {string} zipFilePath
  54. */
  55. writeZip(blobStore, zipFilePath) {
  56. const snapshot = this.snapshot
  57. const timeout = this.timeout
  58. const startTime = process.hrtime()
  59. const archive = new Archive('zip')
  60. // Convert elapsed seconds and nanoseconds to milliseconds.
  61. function findElapsedMilliseconds() {
  62. const elapsed = process.hrtime(startTime)
  63. return elapsed[0] * 1e3 + elapsed[1] * 1e-6
  64. }
  65. function addFileToArchive(pathname) {
  66. if (findElapsedMilliseconds() > timeout) {
  67. throw new ProjectArchive.ArchiveTimeout()
  68. }
  69. const file = snapshot.getFile(pathname)
  70. if (!file) {
  71. throw new ProjectArchive.MissingfileError()
  72. }
  73. return file.load('eager', blobStore).then(function () {
  74. const content = file.getContent({ filterTrackedDeletes: true })
  75. if (content === null) {
  76. return streamFileToArchive(pathname, file).catch(function (err) {
  77. throw new ProjectArchive.DownloadError(file.getHash()).withCause(
  78. err
  79. )
  80. })
  81. } else {
  82. archive.append(content, { name: pathname })
  83. }
  84. })
  85. }
  86. function streamFileToArchive(pathname, file) {
  87. return new BPromise(function (resolve, reject) {
  88. blobStore
  89. .getStream(file.getHash())
  90. .then(stream => {
  91. stream.on('error', reject)
  92. stream.on('end', resolve)
  93. archive.append(stream, { name: pathname })
  94. })
  95. .catch(reject)
  96. })
  97. }
  98. const addFilesToArchiveAndFinalize = BPromise.map(
  99. snapshot.getFilePathnames(),
  100. addFileToArchive,
  101. { concurrency: FETCH_CONCURRENCY }
  102. ).then(function () {
  103. archive.finalize()
  104. })
  105. const streamArchiveToFile = new BPromise(function (resolve, reject) {
  106. const stream = fs.createWriteStream(zipFilePath)
  107. pipeline(archive, stream, function (err) {
  108. if (err) {
  109. reject(err)
  110. } else {
  111. resolve()
  112. }
  113. })
  114. })
  115. return BPromise.join(streamArchiveToFile, addFilesToArchiveAndFinalize)
  116. }
  117. }
  118. module.exports = ProjectArchive