FileWriter.mjs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. // TODO: This file was created by bulk-decaffeinate.
  2. /*
  3. * decaffeinate suggestions:
  4. * DS102: Remove unnecessary code created because of implicit returns
  5. * DS207: Consider shorter variations of null checks
  6. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  7. */
  8. import fs from 'node:fs'
  9. import OError from '@overleaf/o-error'
  10. import logger from '@overleaf/logger'
  11. import crypto from 'node:crypto'
  12. import Settings from '@overleaf/settings'
  13. import { fetchStream } from '@overleaf/fetch-utils'
  14. import { Transform, pipeline } from 'node:stream'
  15. import { FileTooLargeError } from '../Features/Errors/Errors.js'
  16. import { callbackify, promisify } from '@overleaf/promise-utils'
  17. export class SizeLimitedStream extends Transform {
  18. constructor(options) {
  19. options.autoDestroy = true
  20. super(options)
  21. this.bytes = 0
  22. this.maxSizeBytes = options.maxSizeBytes || Settings.maxUploadSize
  23. this.drain = false
  24. this.on('error', () => {
  25. this.drain = true
  26. this.resume()
  27. })
  28. }
  29. _transform(chunk, encoding, done) {
  30. if (this.drain) {
  31. // mechanism to drain the source stream on error, to avoid leaks
  32. // we consume the rest of the incoming stream and don't push it anywhere
  33. return done()
  34. }
  35. this.bytes += chunk.length
  36. if (this.maxSizeBytes && this.bytes > this.maxSizeBytes) {
  37. return done(
  38. new FileTooLargeError({
  39. message: 'stream size limit reached',
  40. info: { size: this.bytes },
  41. })
  42. )
  43. }
  44. this.push(chunk)
  45. done()
  46. }
  47. }
  48. const FileWriter = {
  49. ensureDumpFolderExists() {
  50. fs.mkdirSync(Settings.path.dumpFolder, { recursive: true })
  51. },
  52. writeLinesToDisk(identifier, lines, callback) {
  53. if (callback == null) {
  54. callback = function () {}
  55. }
  56. return FileWriter.writeContentToDisk(identifier, lines.join('\n'), callback)
  57. },
  58. writeContentToDisk(identifier, content, callback) {
  59. if (callback == null) {
  60. callback = function () {}
  61. }
  62. const fsPath = `${
  63. Settings.path.dumpFolder
  64. }/${identifier}_${crypto.randomUUID()}`
  65. return fs.writeFile(fsPath, content, function (error) {
  66. if (error != null) {
  67. return callback(error)
  68. }
  69. return callback(null, fsPath)
  70. })
  71. },
  72. writeStreamToDisk(identifier, stream, options, callback) {
  73. if (typeof options === 'function') {
  74. callback = options
  75. options = {}
  76. }
  77. if (callback == null) {
  78. callback = function () {}
  79. }
  80. options = options || {}
  81. const fsPath = `${
  82. Settings.path.dumpFolder
  83. }/${identifier}_${crypto.randomUUID()}`
  84. const writeStream = fs.createWriteStream(fsPath)
  85. const passThrough = new SizeLimitedStream({
  86. maxSizeBytes: options.maxSizeBytes,
  87. })
  88. // if writing fails, we want to consume the bytes from the source, to avoid leaks
  89. for (const evt of ['error', 'close']) {
  90. writeStream.on(evt, function () {
  91. passThrough.unpipe(writeStream)
  92. passThrough.resume()
  93. })
  94. }
  95. pipeline(stream, passThrough, writeStream, function (err) {
  96. if (
  97. options.maxSizeBytes &&
  98. passThrough.bytes >= options.maxSizeBytes &&
  99. !(err instanceof FileTooLargeError)
  100. ) {
  101. err = new FileTooLargeError({
  102. message: 'stream size limit reached',
  103. info: { size: passThrough.bytes },
  104. }).withCause(err || {})
  105. }
  106. if (err) {
  107. stream.destroy()
  108. writeStream.destroy()
  109. fs.unlink(fsPath, error => {
  110. if (error && error.code !== 'ENOENT') {
  111. logger.warn(
  112. { error, fsPath },
  113. 'Failed to delete partial file after error'
  114. )
  115. }
  116. })
  117. OError.tag(
  118. err,
  119. '[writeStreamToDisk] something went wrong writing the stream to disk',
  120. {
  121. identifier,
  122. fsPath,
  123. }
  124. )
  125. return callback(err)
  126. }
  127. logger.debug(
  128. { identifier, fsPath },
  129. '[writeStreamToDisk] write stream finished'
  130. )
  131. callback(null, fsPath)
  132. })
  133. },
  134. }
  135. async function writeUrlToDisk(identifier, url, options = {}) {
  136. let stream
  137. try {
  138. stream = await fetchStream(url)
  139. } catch (error) {
  140. const err = new OError('bad response from url', {
  141. statusCode: error.response?.status,
  142. })
  143. logger.warn({ err, identifier, url }, `[writeUrlToDisk] ${err.message}`)
  144. throw err
  145. }
  146. return await FileWriter.promises.writeStreamToDisk(
  147. identifier,
  148. stream,
  149. options
  150. )
  151. }
  152. FileWriter.writeUrlToDisk = callbackify(writeUrlToDisk)
  153. FileWriter.promises = {
  154. writeLinesToDisk: promisify(FileWriter.writeLinesToDisk),
  155. writeContentToDisk: promisify(FileWriter.writeContentToDisk),
  156. writeStreamToDisk: promisify(FileWriter.writeStreamToDisk),
  157. writeUrlToDisk,
  158. }
  159. export default FileWriter