project_archive.js 3.1 KB

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