FSPersistor.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. const crypto = require('node:crypto')
  2. const fs = require('node:fs')
  3. const fsPromises = require('node:fs/promises')
  4. const { glob } = require('glob')
  5. const Path = require('node:path')
  6. const { promisify } = require('node:util')
  7. const { PassThrough } = require('node:stream')
  8. const { pipeline } = require('node:stream/promises')
  9. const openCb = promisify(fs.open)
  10. const AbstractPersistor = require('./AbstractPersistor')
  11. const { ReadError, WriteError, NotImplementedError } = require('./Errors')
  12. const PersistorHelper = require('./PersistorHelper')
  13. module.exports = class FSPersistor extends AbstractPersistor {
  14. constructor(settings = {}) {
  15. if (settings.storageClass) {
  16. throw new NotImplementedError(
  17. 'FS backend does not support storage classes'
  18. )
  19. }
  20. super()
  21. this.useSubdirectories = Boolean(settings.useSubdirectories)
  22. }
  23. async sendFile(location, target, source) {
  24. // actually copy the file (instead of moving it) to maintain consistent behaviour
  25. // between the different implementations
  26. try {
  27. const sourceStream = fs.createReadStream(source)
  28. await this.sendStream(location, target, sourceStream)
  29. } catch (err) {
  30. throw PersistorHelper.wrapError(
  31. err,
  32. 'failed to copy the specified file',
  33. { location, target, source },
  34. WriteError
  35. )
  36. }
  37. }
  38. async sendStream(location, target, sourceStream, opts = {}) {
  39. if (opts.ifNoneMatch === '*') {
  40. // The standard library only has fs.rename(), which does not support exclusive flags.
  41. // Refuse to act on this write operation.
  42. throw new NotImplementedError(
  43. 'Overwrite protection required by caller, but it is not available is FS backend. Configure GCS or S3 backend instead, get in touch with support for further information.'
  44. )
  45. }
  46. const targetPath = this._getFsPath(location, target)
  47. try {
  48. await this._ensureDirectoryExists(targetPath)
  49. const tempFilePath = await this._writeStreamToTempFile(
  50. location,
  51. sourceStream,
  52. opts
  53. )
  54. try {
  55. await fsPromises.rename(tempFilePath, targetPath)
  56. } finally {
  57. await this._cleanupTempFile(tempFilePath)
  58. }
  59. } catch (err) {
  60. throw PersistorHelper.wrapError(
  61. err,
  62. 'failed to write stream',
  63. { location, target, ifNoneMatch: opts.ifNoneMatch },
  64. WriteError
  65. )
  66. }
  67. }
  68. // opts may be {start: Number, end: Number}
  69. async getObjectStream(location, name, opts = {}) {
  70. if (opts.autoGunzip) {
  71. throw new NotImplementedError(
  72. 'opts.autoGunzip is not supported by FS backend. Configure GCS or S3 backend instead, get in touch with support for further information.'
  73. )
  74. }
  75. const observer = new PersistorHelper.ObserverStream({
  76. metric: 'fs.ingress', // ingress to us from disk
  77. bucket: location,
  78. })
  79. const fsPath = this._getFsPath(location, name, opts.useSubdirectories)
  80. let fd
  81. try {
  82. fd = await openCb(fsPath, 'r')
  83. } catch (err) {
  84. throw PersistorHelper.wrapError(
  85. err,
  86. 'failed to open file for streaming',
  87. { location, name, fsPath, opts },
  88. ReadError
  89. )
  90. }
  91. const stream = fs.createReadStream(null, { ...opts, fd })
  92. // Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
  93. const pass = new PassThrough()
  94. pipeline(stream, observer, pass).catch(() => {})
  95. return pass
  96. }
  97. async getRedirectUrl() {
  98. // not implemented
  99. return null
  100. }
  101. async getObjectSize(location, filename) {
  102. const fsPath = this._getFsPath(location, filename)
  103. try {
  104. const stat = await fsPromises.stat(fsPath)
  105. return stat.size
  106. } catch (err) {
  107. throw PersistorHelper.wrapError(
  108. err,
  109. 'failed to stat file',
  110. { location, filename },
  111. ReadError
  112. )
  113. }
  114. }
  115. async getObjectMd5Hash(location, filename) {
  116. const fsPath = this._getFsPath(location, filename)
  117. try {
  118. const stream = fs.createReadStream(fsPath)
  119. const hash = await PersistorHelper.calculateStreamMd5(stream)
  120. return hash
  121. } catch (err) {
  122. throw new ReadError(
  123. 'unable to get md5 hash from file',
  124. { location, filename },
  125. err
  126. )
  127. }
  128. }
  129. async copyObject(location, source, target) {
  130. const sourceFsPath = this._getFsPath(location, source)
  131. const targetFsPath = this._getFsPath(location, target)
  132. try {
  133. await this._ensureDirectoryExists(targetFsPath)
  134. await fsPromises.copyFile(sourceFsPath, targetFsPath)
  135. } catch (err) {
  136. throw PersistorHelper.wrapError(
  137. err,
  138. 'failed to copy file',
  139. { location, source, target, sourceFsPath, targetFsPath },
  140. WriteError
  141. )
  142. }
  143. }
  144. async deleteObject(location, name) {
  145. const fsPath = this._getFsPath(location, name)
  146. try {
  147. // S3 doesn't give us a 404 when a file wasn't there to be deleted, so we
  148. // should be consistent here as well
  149. await fsPromises.rm(fsPath, { force: true })
  150. } catch (err) {
  151. throw PersistorHelper.wrapError(
  152. err,
  153. 'failed to delete file',
  154. { location, name, fsPath },
  155. WriteError
  156. )
  157. }
  158. }
  159. async deleteDirectory(location, name) {
  160. const fsPath = this._getFsPath(location, name)
  161. try {
  162. if (this.useSubdirectories) {
  163. await fsPromises.rm(fsPath, { recursive: true, force: true })
  164. } else {
  165. const files = await this._listDirectory(fsPath)
  166. for (const file of files) {
  167. await fsPromises.rm(file, { force: true })
  168. }
  169. }
  170. } catch (err) {
  171. throw PersistorHelper.wrapError(
  172. err,
  173. 'failed to delete directory',
  174. { location, name, fsPath },
  175. WriteError
  176. )
  177. }
  178. }
  179. async listDirectoryKeys(location, name) {
  180. const fsPath = this._getFsPath(location, name)
  181. const paths = await this._listDirectory(fsPath)
  182. // Filter to only return files, not directories
  183. const files = []
  184. for (const path of paths) {
  185. try {
  186. const stat = await fsPromises.stat(path)
  187. if (stat.isFile()) {
  188. files.push(path)
  189. }
  190. } catch (err) {
  191. // ignore files that may have just been deleted
  192. if (err.code !== 'ENOENT') {
  193. throw err
  194. }
  195. }
  196. }
  197. return files
  198. }
  199. async listDirectoryStats(location, name) {
  200. const fsPath = this._getFsPath(location, name)
  201. const paths = await this._listDirectory(fsPath)
  202. // Filter to only return files, not directories, with their sizes
  203. const stats = []
  204. for (const path of paths) {
  205. try {
  206. const stat = await fsPromises.stat(path)
  207. if (stat.isFile()) {
  208. stats.push({ key: path, size: stat.size })
  209. }
  210. } catch (err) {
  211. // ignore files that may have just been deleted
  212. if (err.code !== 'ENOENT') {
  213. throw err
  214. }
  215. }
  216. }
  217. return stats
  218. }
  219. async checkIfObjectExists(location, name) {
  220. const fsPath = this._getFsPath(location, name)
  221. try {
  222. const stat = await fsPromises.stat(fsPath)
  223. return !!stat
  224. } catch (err) {
  225. if (err.code === 'ENOENT') {
  226. return false
  227. }
  228. throw PersistorHelper.wrapError(
  229. err,
  230. 'failed to stat file',
  231. { location, name, fsPath },
  232. ReadError
  233. )
  234. }
  235. }
  236. // note, does not recurse into subdirectories, as we use a flattened directory structure
  237. async directorySize(location, name) {
  238. const fsPath = this._getFsPath(location, name)
  239. let size = 0
  240. try {
  241. const files = await this._listDirectory(fsPath)
  242. for (const file of files) {
  243. try {
  244. const stat = await fsPromises.stat(file)
  245. if (stat.isFile()) {
  246. size += stat.size
  247. }
  248. } catch (err) {
  249. // ignore files that may have just been deleted
  250. if (err.code !== 'ENOENT') {
  251. throw err
  252. }
  253. }
  254. }
  255. } catch (err) {
  256. throw PersistorHelper.wrapError(
  257. err,
  258. 'failed to get directory size',
  259. { location, name },
  260. ReadError
  261. )
  262. }
  263. return size
  264. }
  265. async _writeStreamToTempFile(location, stream, opts = {}) {
  266. const observerOptions = {
  267. metric: 'fs.egress', // egress from us to disk
  268. bucket: location,
  269. }
  270. const observer = new PersistorHelper.ObserverStream(observerOptions)
  271. const tempDirPath = await fsPromises.mkdtemp(Path.join(location, 'tmp-'))
  272. const tempFilePath = Path.join(tempDirPath, 'uploaded-file')
  273. const transforms = [observer]
  274. let md5Observer
  275. if (opts.sourceMd5) {
  276. md5Observer = createMd5Observer()
  277. transforms.push(md5Observer.transform)
  278. }
  279. try {
  280. const writeStream = fs.createWriteStream(tempFilePath)
  281. await pipeline(stream, ...transforms, writeStream)
  282. } catch (err) {
  283. await this._cleanupTempFile(tempFilePath)
  284. throw new WriteError(
  285. 'problem writing temp file locally',
  286. { tempFilePath },
  287. err
  288. )
  289. }
  290. if (opts.sourceMd5) {
  291. const actualMd5 = md5Observer.hash.digest('hex')
  292. if (actualMd5 !== opts.sourceMd5) {
  293. await this._cleanupTempFile(tempFilePath)
  294. throw new WriteError('md5 hash mismatch', {
  295. expectedMd5: opts.sourceMd5,
  296. actualMd5,
  297. })
  298. }
  299. }
  300. return tempFilePath
  301. }
  302. async _cleanupTempFile(tempFilePath) {
  303. const dirPath = Path.dirname(tempFilePath)
  304. await fsPromises.rm(dirPath, { force: true, recursive: true })
  305. }
  306. _getFsPath(location, key, useSubdirectories = false) {
  307. key = key.replace(/\/$/, '')
  308. if (!this.useSubdirectories && !useSubdirectories) {
  309. key = key.replace(/\//g, '_')
  310. }
  311. return Path.join(location, key)
  312. }
  313. async _listDirectory(path) {
  314. if (this.useSubdirectories) {
  315. return await glob(Path.join(path, '**'))
  316. } else {
  317. return await glob(`${path}_*`)
  318. }
  319. }
  320. async _ensureDirectoryExists(path) {
  321. await fsPromises.mkdir(Path.dirname(path), { recursive: true })
  322. }
  323. }
  324. function createMd5Observer() {
  325. const hash = crypto.createHash('md5')
  326. async function* transform(chunks) {
  327. for await (const chunk of chunks) {
  328. hash.update(chunk)
  329. yield chunk
  330. }
  331. }
  332. return { hash, transform }
  333. }