FileWriter.mjs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  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. /**
  49. * @type {{
  50. * ensureDumpFolderExists: () => void,
  51. * writeLinesToDisk: (identifier: any, lines: any, callback?: any) => void,
  52. * writeContentToDisk: (identifier: any, content: any, callback?: any) => void,
  53. * writeStreamToDisk: (identifier: any, stream: any, options?: any, callback?: any) => void,
  54. * writeUrlToDisk: (identifier: any, url: any, options?: any, callback?: any) => void,
  55. * promises: {
  56. * writeLinesToDisk: (identifier: any, lines: any) => Promise<string>,
  57. * writeContentToDisk: (identifier: any, content: any) => Promise<string>,
  58. * writeStreamToDisk: (identifier: any, stream: any, options?: any) => Promise<string>,
  59. * writeUrlToDisk: (identifier: any, url: any, options?: any) => Promise<string>,
  60. * },
  61. * }}
  62. */
  63. const FileWriter = {
  64. ensureDumpFolderExists() {
  65. fs.mkdirSync(Settings.path.dumpFolder, { recursive: true })
  66. },
  67. writeLinesToDisk(identifier, lines, callback) {
  68. if (callback == null) {
  69. callback = function () {}
  70. }
  71. return FileWriter.writeContentToDisk(identifier, lines.join('\n'), callback)
  72. },
  73. writeContentToDisk(identifier, content, callback) {
  74. if (callback == null) {
  75. callback = function () {}
  76. }
  77. const fsPath = `${
  78. Settings.path.dumpFolder
  79. }/${identifier}_${crypto.randomUUID()}`
  80. return fs.writeFile(fsPath, content, function (error) {
  81. if (error != null) {
  82. return callback(error)
  83. }
  84. return callback(null, fsPath)
  85. })
  86. },
  87. writeStreamToDisk(identifier, stream, options, callback) {
  88. if (typeof options === 'function') {
  89. callback = options
  90. options = {}
  91. }
  92. if (callback == null) {
  93. callback = function () {}
  94. }
  95. options = options || {}
  96. const fsPath = `${
  97. Settings.path.dumpFolder
  98. }/${identifier}_${crypto.randomUUID()}`
  99. const writeStream = fs.createWriteStream(fsPath)
  100. const passThrough = new SizeLimitedStream({
  101. maxSizeBytes: options.maxSizeBytes,
  102. })
  103. // if writing fails, we want to consume the bytes from the source, to avoid leaks
  104. for (const evt of ['error', 'close']) {
  105. writeStream.on(evt, function () {
  106. passThrough.unpipe(writeStream)
  107. passThrough.resume()
  108. })
  109. }
  110. pipeline(stream, passThrough, writeStream, function (err) {
  111. if (
  112. options.maxSizeBytes &&
  113. passThrough.bytes >= options.maxSizeBytes &&
  114. !(err instanceof FileTooLargeError)
  115. ) {
  116. err = new FileTooLargeError({
  117. message: 'stream size limit reached',
  118. info: { size: passThrough.bytes },
  119. }).withCause(err || {})
  120. }
  121. if (err) {
  122. stream.destroy()
  123. writeStream.destroy()
  124. fs.unlink(fsPath, error => {
  125. if (error && error.code !== 'ENOENT') {
  126. logger.warn(
  127. { error, fsPath },
  128. 'Failed to delete partial file after error'
  129. )
  130. }
  131. })
  132. OError.tag(
  133. err,
  134. '[writeStreamToDisk] something went wrong writing the stream to disk',
  135. {
  136. identifier,
  137. fsPath,
  138. }
  139. )
  140. return callback(err)
  141. }
  142. logger.debug(
  143. { identifier, fsPath },
  144. '[writeStreamToDisk] write stream finished'
  145. )
  146. callback(null, fsPath)
  147. })
  148. },
  149. }
  150. async function writeUrlToDisk(identifier, url, options = {}) {
  151. let stream
  152. try {
  153. stream = await fetchStream(url)
  154. } catch (error) {
  155. const err = new OError('bad response from url', {
  156. statusCode: error.response?.status,
  157. })
  158. logger.warn({ err, identifier, url }, `[writeUrlToDisk] ${err.message}`)
  159. throw err
  160. }
  161. return await FileWriter.promises.writeStreamToDisk(
  162. identifier,
  163. stream,
  164. options
  165. )
  166. }
  167. FileWriter.writeUrlToDisk = callbackify(writeUrlToDisk)
  168. FileWriter.promises = {
  169. writeLinesToDisk: promisify(FileWriter.writeLinesToDisk),
  170. writeContentToDisk: promisify(FileWriter.writeContentToDisk),
  171. writeStreamToDisk: promisify(FileWriter.writeStreamToDisk),
  172. writeUrlToDisk,
  173. }
  174. export default FileWriter