FSPersistor.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. const crypto = require('crypto')
  2. const fs = require('fs')
  3. const fsPromises = require('fs/promises')
  4. const globCallbacks = require('glob')
  5. const Path = require('path')
  6. const { PassThrough } = require('stream')
  7. const { pipeline } = require('stream/promises')
  8. const { promisify } = require('util')
  9. const AbstractPersistor = require('./AbstractPersistor')
  10. const { ReadError, WriteError } = require('./Errors')
  11. const PersistorHelper = require('./PersistorHelper')
  12. const glob = promisify(globCallbacks)
  13. module.exports = class FSPersistor extends AbstractPersistor {
  14. constructor(settings = {}) {
  15. super()
  16. this.useSubdirectories = Boolean(settings.useSubdirectories)
  17. }
  18. async sendFile(location, target, source) {
  19. // actually copy the file (instead of moving it) to maintain consistent behaviour
  20. // between the different implementations
  21. try {
  22. const sourceStream = fs.createReadStream(source)
  23. await this.sendStream(location, target, sourceStream)
  24. } catch (err) {
  25. throw PersistorHelper.wrapError(
  26. err,
  27. 'failed to copy the specified file',
  28. { location, target, source },
  29. WriteError
  30. )
  31. }
  32. }
  33. async sendStream(location, target, sourceStream, opts = {}) {
  34. const targetPath = this._getFsPath(location, target)
  35. try {
  36. await this._ensureDirectoryExists(targetPath)
  37. const tempFilePath = await this._writeStreamToTempFile(
  38. location,
  39. sourceStream,
  40. opts
  41. )
  42. try {
  43. await fsPromises.rename(tempFilePath, targetPath)
  44. } finally {
  45. await this._cleanupTempFile(tempFilePath)
  46. }
  47. } catch (err) {
  48. throw PersistorHelper.wrapError(
  49. err,
  50. 'failed to write stream',
  51. { location, target },
  52. WriteError
  53. )
  54. }
  55. }
  56. // opts may be {start: Number, end: Number}
  57. async getObjectStream(location, name, opts = {}) {
  58. const observer = new PersistorHelper.ObserverStream({
  59. metric: 'fs.ingress', // ingress to us from disk
  60. bucket: location,
  61. })
  62. const fsPath = this._getFsPath(location, name)
  63. try {
  64. opts.fd = await fsPromises.open(fsPath, 'r')
  65. } catch (err) {
  66. throw PersistorHelper.wrapError(
  67. err,
  68. 'failed to open file for streaming',
  69. { location, name, fsPath, opts },
  70. ReadError
  71. )
  72. }
  73. const stream = fs.createReadStream(null, opts)
  74. // 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).
  75. const pass = new PassThrough()
  76. pipeline(stream, observer, pass).catch(() => {})
  77. return pass
  78. }
  79. async getRedirectUrl() {
  80. // not implemented
  81. return null
  82. }
  83. async getObjectSize(location, filename) {
  84. const fsPath = this._getFsPath(location, filename)
  85. try {
  86. const stat = await fsPromises.stat(fsPath)
  87. return stat.size
  88. } catch (err) {
  89. throw PersistorHelper.wrapError(
  90. err,
  91. 'failed to stat file',
  92. { location, filename },
  93. ReadError
  94. )
  95. }
  96. }
  97. async getObjectMd5Hash(location, filename) {
  98. const fsPath = this._getFsPath(location, filename)
  99. try {
  100. const stream = fs.createReadStream(fsPath)
  101. const hash = await PersistorHelper.calculateStreamMd5(stream)
  102. return hash
  103. } catch (err) {
  104. throw new ReadError(
  105. 'unable to get md5 hash from file',
  106. { location, filename },
  107. err
  108. )
  109. }
  110. }
  111. async copyObject(location, source, target) {
  112. const sourceFsPath = this._getFsPath(location, source)
  113. const targetFsPath = this._getFsPath(location, target)
  114. try {
  115. await this._ensureDirectoryExists(targetFsPath)
  116. await fsPromises.copyFile(sourceFsPath, targetFsPath)
  117. } catch (err) {
  118. throw PersistorHelper.wrapError(
  119. err,
  120. 'failed to copy file',
  121. { location, source, target, sourceFsPath, targetFsPath },
  122. WriteError
  123. )
  124. }
  125. }
  126. async deleteObject(location, name) {
  127. const fsPath = this._getFsPath(location, name)
  128. try {
  129. // S3 doesn't give us a 404 when a file wasn't there to be deleted, so we
  130. // should be consistent here as well
  131. await fsPromises.rm(fsPath, { force: true })
  132. } catch (err) {
  133. throw PersistorHelper.wrapError(
  134. err,
  135. 'failed to delete file',
  136. { location, name, fsPath },
  137. WriteError
  138. )
  139. }
  140. }
  141. async deleteDirectory(location, name) {
  142. const fsPath = this._getFsPath(location, name)
  143. try {
  144. if (this.useSubdirectories) {
  145. await fsPromises.rm(fsPath, { recursive: true, force: true })
  146. } else {
  147. const files = await this._listDirectory(fsPath)
  148. for (const file of files) {
  149. await fsPromises.rm(file, { force: true })
  150. }
  151. }
  152. } catch (err) {
  153. throw PersistorHelper.wrapError(
  154. err,
  155. 'failed to delete directory',
  156. { location, name, fsPath },
  157. WriteError
  158. )
  159. }
  160. }
  161. async checkIfObjectExists(location, name) {
  162. const fsPath = this._getFsPath(location, name)
  163. try {
  164. const stat = await fsPromises.stat(fsPath)
  165. return !!stat
  166. } catch (err) {
  167. if (err.code === 'ENOENT') {
  168. return false
  169. }
  170. throw PersistorHelper.wrapError(
  171. err,
  172. 'failed to stat file',
  173. { location, name, fsPath },
  174. ReadError
  175. )
  176. }
  177. }
  178. // note, does not recurse into subdirectories, as we use a flattened directory structure
  179. async directorySize(location, name) {
  180. const fsPath = this._getFsPath(location, name)
  181. let size = 0
  182. try {
  183. const files = await this._listDirectory(fsPath)
  184. for (const file of files) {
  185. try {
  186. const stat = await fsPromises.stat(file)
  187. if (stat.isFile()) {
  188. size += stat.size
  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. } catch (err) {
  198. throw PersistorHelper.wrapError(
  199. err,
  200. 'failed to get directory size',
  201. { location, name },
  202. ReadError
  203. )
  204. }
  205. return size
  206. }
  207. async _writeStreamToTempFile(location, stream, opts = {}) {
  208. const observerOptions = {
  209. metric: 'fs.egress', // egress from us to disk
  210. bucket: location,
  211. }
  212. const observer = new PersistorHelper.ObserverStream(observerOptions)
  213. const tempDirPath = await fsPromises.mkdtemp(Path.join(location, 'tmp-'))
  214. const tempFilePath = Path.join(tempDirPath, 'uploaded-file')
  215. const transforms = [observer]
  216. let md5Observer
  217. if (opts.sourceMd5) {
  218. md5Observer = createMd5Observer()
  219. transforms.push(md5Observer.transform)
  220. }
  221. try {
  222. const writeStream = fs.createWriteStream(tempFilePath)
  223. await pipeline(stream, ...transforms, writeStream)
  224. } catch (err) {
  225. await this._cleanupTempFile(tempFilePath)
  226. throw new WriteError(
  227. 'problem writing temp file locally',
  228. { tempFilePath },
  229. err
  230. )
  231. }
  232. if (opts.sourceMd5) {
  233. const actualMd5 = md5Observer.hash.digest('hex')
  234. if (actualMd5 !== opts.sourceMd5) {
  235. await this._cleanupTempFile(tempFilePath)
  236. throw new WriteError('md5 hash mismatch', {
  237. expectedMd5: opts.sourceMd5,
  238. actualMd5,
  239. })
  240. }
  241. }
  242. return tempFilePath
  243. }
  244. async _cleanupTempFile(tempFilePath) {
  245. const dirPath = Path.dirname(tempFilePath)
  246. await fsPromises.rm(dirPath, { force: true, recursive: true })
  247. }
  248. _getFsPath(location, key) {
  249. key = key.replace(/\/$/, '')
  250. if (!this.useSubdirectories) {
  251. key = key.replace(/\//g, '_')
  252. }
  253. return Path.join(location, key)
  254. }
  255. async _listDirectory(path) {
  256. if (this.useSubdirectories) {
  257. return await glob(Path.join(path, '**'))
  258. } else {
  259. return await glob(`${path}_*`)
  260. }
  261. }
  262. async _ensureDirectoryExists(path) {
  263. await fsPromises.mkdir(Path.dirname(path), { recursive: true })
  264. }
  265. }
  266. function createMd5Observer() {
  267. const hash = crypto.createHash('md5')
  268. async function* transform(chunks) {
  269. for await (const chunk of chunks) {
  270. hash.update(chunk)
  271. yield chunk
  272. }
  273. }
  274. return { hash, transform }
  275. }