FSPersistor.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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 { pipeline } = require('stream/promises')
  7. const { promisify } = require('util')
  8. const AbstractPersistor = require('./AbstractPersistor')
  9. const { ReadError, WriteError } = require('./Errors')
  10. const PersistorHelper = require('./PersistorHelper')
  11. const glob = promisify(globCallbacks)
  12. module.exports = class FSPersistor extends AbstractPersistor {
  13. constructor(settings = {}) {
  14. super()
  15. this.useSubdirectories = Boolean(settings.useSubdirectories)
  16. this.metrics = settings.Metrics
  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 fsPath = this._getFsPath(location, name)
  59. try {
  60. opts.fd = await fsPromises.open(fsPath, 'r')
  61. } catch (err) {
  62. throw PersistorHelper.wrapError(
  63. err,
  64. 'failed to open file for streaming',
  65. { location, name, fsPath, opts },
  66. ReadError
  67. )
  68. }
  69. return fs.createReadStream(null, opts)
  70. }
  71. async getRedirectUrl() {
  72. // not implemented
  73. return null
  74. }
  75. async getObjectSize(location, filename) {
  76. const fsPath = this._getFsPath(location, filename)
  77. try {
  78. const stat = await fsPromises.stat(fsPath)
  79. return stat.size
  80. } catch (err) {
  81. throw PersistorHelper.wrapError(
  82. err,
  83. 'failed to stat file',
  84. { location, filename },
  85. ReadError
  86. )
  87. }
  88. }
  89. async getObjectMd5Hash(location, filename) {
  90. const fsPath = this._getFsPath(location, filename)
  91. try {
  92. const stream = fs.createReadStream(fsPath)
  93. const hash = await PersistorHelper.calculateStreamMd5(stream)
  94. return hash
  95. } catch (err) {
  96. throw new ReadError(
  97. 'unable to get md5 hash from file',
  98. { location, filename },
  99. err
  100. )
  101. }
  102. }
  103. async copyObject(location, source, target) {
  104. const sourceFsPath = this._getFsPath(location, source)
  105. const targetFsPath = this._getFsPath(location, target)
  106. try {
  107. await this._ensureDirectoryExists(targetFsPath)
  108. await fsPromises.copyFile(sourceFsPath, targetFsPath)
  109. } catch (err) {
  110. throw PersistorHelper.wrapError(
  111. err,
  112. 'failed to copy file',
  113. { location, source, target, sourceFsPath, targetFsPath },
  114. WriteError
  115. )
  116. }
  117. }
  118. async deleteObject(location, name) {
  119. const fsPath = this._getFsPath(location, name)
  120. try {
  121. // S3 doesn't give us a 404 when a file wasn't there to be deleted, so we
  122. // should be consistent here as well
  123. await fsPromises.rm(fsPath, { force: true })
  124. } catch (err) {
  125. throw PersistorHelper.wrapError(
  126. err,
  127. 'failed to delete file',
  128. { location, name, fsPath },
  129. WriteError
  130. )
  131. }
  132. }
  133. async deleteDirectory(location, name) {
  134. const fsPath = this._getFsPath(location, name)
  135. try {
  136. if (this.useSubdirectories) {
  137. await fsPromises.rm(fsPath, { recursive: true, force: true })
  138. } else {
  139. const files = await this._listDirectory(fsPath)
  140. for (const file of files) {
  141. await fsPromises.rm(file, { force: true })
  142. }
  143. }
  144. } catch (err) {
  145. throw PersistorHelper.wrapError(
  146. err,
  147. 'failed to delete directory',
  148. { location, name, fsPath },
  149. WriteError
  150. )
  151. }
  152. }
  153. async checkIfObjectExists(location, name) {
  154. const fsPath = this._getFsPath(location, name)
  155. try {
  156. const stat = await fsPromises.stat(fsPath)
  157. return !!stat
  158. } catch (err) {
  159. if (err.code === 'ENOENT') {
  160. return false
  161. }
  162. throw PersistorHelper.wrapError(
  163. err,
  164. 'failed to stat file',
  165. { location, name, fsPath },
  166. ReadError
  167. )
  168. }
  169. }
  170. // note, does not recurse into subdirectories, as we use a flattened directory structure
  171. async directorySize(location, name) {
  172. const fsPath = this._getFsPath(location, name)
  173. let size = 0
  174. try {
  175. const files = await this._listDirectory(fsPath)
  176. for (const file of files) {
  177. try {
  178. const stat = await fsPromises.stat(file)
  179. if (stat.isFile()) {
  180. size += stat.size
  181. }
  182. } catch (err) {
  183. // ignore files that may have just been deleted
  184. if (err.code !== 'ENOENT') {
  185. throw err
  186. }
  187. }
  188. }
  189. } catch (err) {
  190. throw PersistorHelper.wrapError(
  191. err,
  192. 'failed to get directory size',
  193. { location, name },
  194. ReadError
  195. )
  196. }
  197. return size
  198. }
  199. async _writeStreamToTempFile(location, stream, opts = {}) {
  200. const tempDirPath = await fsPromises.mkdtemp(Path.join(location, 'tmp-'))
  201. const tempFilePath = Path.join(tempDirPath, 'uploaded-file')
  202. const transforms = []
  203. let md5Observer
  204. if (opts.sourceMd5) {
  205. md5Observer = createMd5Observer()
  206. transforms.push(md5Observer.transform)
  207. }
  208. let timer
  209. if (this.metrics) {
  210. timer = new this.metrics.Timer('writingFile')
  211. }
  212. try {
  213. const writeStream = fs.createWriteStream(tempFilePath)
  214. await pipeline(stream, ...transforms, writeStream)
  215. if (timer) {
  216. timer.done()
  217. }
  218. } catch (err) {
  219. await this._cleanupTempFile(tempFilePath)
  220. throw new WriteError(
  221. 'problem writing temp file locally',
  222. { tempFilePath },
  223. err
  224. )
  225. }
  226. if (opts.sourceMd5) {
  227. const actualMd5 = md5Observer.hash.digest('hex')
  228. if (actualMd5 !== opts.sourceMd5) {
  229. await this._cleanupTempFile(tempFilePath)
  230. throw new WriteError('md5 hash mismatch', {
  231. expectedMd5: opts.sourceMd5,
  232. actualMd5,
  233. })
  234. }
  235. }
  236. return tempFilePath
  237. }
  238. async _cleanupTempFile(tempFilePath) {
  239. const dirPath = Path.dirname(tempFilePath)
  240. await fsPromises.rm(dirPath, { force: true, recursive: true })
  241. }
  242. _getFsPath(location, key) {
  243. key = key.replace(/\/$/, '')
  244. if (!this.useSubdirectories) {
  245. key = key.replace(/\//g, '_')
  246. }
  247. return Path.join(location, key)
  248. }
  249. async _listDirectory(path) {
  250. if (this.useSubdirectories) {
  251. return await glob(Path.join(path, '**'))
  252. } else {
  253. return await glob(`${path}_*`)
  254. }
  255. }
  256. async _ensureDirectoryExists(path) {
  257. await fsPromises.mkdir(Path.dirname(path), { recursive: true })
  258. }
  259. }
  260. function createMd5Observer() {
  261. const hash = crypto.createHash('md5')
  262. async function* transform(chunks) {
  263. for await (const chunk of chunks) {
  264. hash.update(chunk)
  265. yield chunk
  266. }
  267. }
  268. return { hash, transform }
  269. }