Files
overleaf-cep/services/web/app/src/infrastructure/FileWriter.mjs
Domagoj Kriskovic ecda482213 add JSDoc comments for FileWriter methods and promises
GitOrigin-RevId: a7b2229b12973a8ebf4a1d6b94159efbd63df02a
2026-04-20 08:04:58 +00:00

193 lines
5.5 KiB
JavaScript

// TODO: This file was created by bulk-decaffeinate.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import fs from 'node:fs'
import OError from '@overleaf/o-error'
import logger from '@overleaf/logger'
import crypto from 'node:crypto'
import Settings from '@overleaf/settings'
import { fetchStream } from '@overleaf/fetch-utils'
import { Transform, pipeline } from 'node:stream'
import { FileTooLargeError } from '../Features/Errors/Errors.js'
import { callbackify, promisify } from '@overleaf/promise-utils'
export class SizeLimitedStream extends Transform {
constructor(options) {
options.autoDestroy = true
super(options)
this.bytes = 0
this.maxSizeBytes = options.maxSizeBytes || Settings.maxUploadSize
this.drain = false
this.on('error', () => {
this.drain = true
this.resume()
})
}
_transform(chunk, encoding, done) {
if (this.drain) {
// mechanism to drain the source stream on error, to avoid leaks
// we consume the rest of the incoming stream and don't push it anywhere
return done()
}
this.bytes += chunk.length
if (this.maxSizeBytes && this.bytes > this.maxSizeBytes) {
return done(
new FileTooLargeError({
message: 'stream size limit reached',
info: { size: this.bytes },
})
)
}
this.push(chunk)
done()
}
}
/**
* @type {{
* ensureDumpFolderExists: () => void,
* writeLinesToDisk: (identifier: any, lines: any, callback?: any) => void,
* writeContentToDisk: (identifier: any, content: any, callback?: any) => void,
* writeStreamToDisk: (identifier: any, stream: any, options?: any, callback?: any) => void,
* writeUrlToDisk: (identifier: any, url: any, options?: any, callback?: any) => void,
* promises: {
* writeLinesToDisk: (identifier: any, lines: any) => Promise<string>,
* writeContentToDisk: (identifier: any, content: any) => Promise<string>,
* writeStreamToDisk: (identifier: any, stream: any, options?: any) => Promise<string>,
* writeUrlToDisk: (identifier: any, url: any, options?: any) => Promise<string>,
* },
* }}
*/
const FileWriter = {
ensureDumpFolderExists() {
fs.mkdirSync(Settings.path.dumpFolder, { recursive: true })
},
writeLinesToDisk(identifier, lines, callback) {
if (callback == null) {
callback = function () {}
}
return FileWriter.writeContentToDisk(identifier, lines.join('\n'), callback)
},
writeContentToDisk(identifier, content, callback) {
if (callback == null) {
callback = function () {}
}
const fsPath = `${
Settings.path.dumpFolder
}/${identifier}_${crypto.randomUUID()}`
return fs.writeFile(fsPath, content, function (error) {
if (error != null) {
return callback(error)
}
return callback(null, fsPath)
})
},
writeStreamToDisk(identifier, stream, options, callback) {
if (typeof options === 'function') {
callback = options
options = {}
}
if (callback == null) {
callback = function () {}
}
options = options || {}
const fsPath = `${
Settings.path.dumpFolder
}/${identifier}_${crypto.randomUUID()}`
const writeStream = fs.createWriteStream(fsPath)
const passThrough = new SizeLimitedStream({
maxSizeBytes: options.maxSizeBytes,
})
// if writing fails, we want to consume the bytes from the source, to avoid leaks
for (const evt of ['error', 'close']) {
writeStream.on(evt, function () {
passThrough.unpipe(writeStream)
passThrough.resume()
})
}
pipeline(stream, passThrough, writeStream, function (err) {
if (
options.maxSizeBytes &&
passThrough.bytes >= options.maxSizeBytes &&
!(err instanceof FileTooLargeError)
) {
err = new FileTooLargeError({
message: 'stream size limit reached',
info: { size: passThrough.bytes },
}).withCause(err || {})
}
if (err) {
stream.destroy()
writeStream.destroy()
fs.unlink(fsPath, error => {
if (error && error.code !== 'ENOENT') {
logger.warn(
{ error, fsPath },
'Failed to delete partial file after error'
)
}
})
OError.tag(
err,
'[writeStreamToDisk] something went wrong writing the stream to disk',
{
identifier,
fsPath,
}
)
return callback(err)
}
logger.debug(
{ identifier, fsPath },
'[writeStreamToDisk] write stream finished'
)
callback(null, fsPath)
})
},
}
async function writeUrlToDisk(identifier, url, options = {}) {
let stream
try {
stream = await fetchStream(url)
} catch (error) {
const err = new OError('bad response from url', {
statusCode: error.response?.status,
})
logger.warn({ err, identifier, url }, `[writeUrlToDisk] ${err.message}`)
throw err
}
return await FileWriter.promises.writeStreamToDisk(
identifier,
stream,
options
)
}
FileWriter.writeUrlToDisk = callbackify(writeUrlToDisk)
FileWriter.promises = {
writeLinesToDisk: promisify(FileWriter.writeLinesToDisk),
writeContentToDisk: promisify(FileWriter.writeContentToDisk),
writeStreamToDisk: promisify(FileWriter.writeStreamToDisk),
writeUrlToDisk,
}
export default FileWriter