project_archive.js 3.1 KB

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