project_archive.js 3.7 KB

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