S3Persistor.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. const http = require('http')
  2. const https = require('https')
  3. if (http.globalAgent.maxSockets < 300) {
  4. http.globalAgent.maxSockets = 300
  5. }
  6. if (https.globalAgent.maxSockets < 300) {
  7. https.globalAgent.maxSockets = 300
  8. }
  9. const AbstractPersistor = require('./AbstractPersistor')
  10. const PersistorHelper = require('./PersistorHelper')
  11. const fs = require('fs')
  12. const S3 = require('aws-sdk/clients/s3')
  13. const { URL } = require('url')
  14. const {
  15. WriteError,
  16. ReadError,
  17. NotFoundError,
  18. SettingsError
  19. } = require('./Errors')
  20. module.exports = class S3Persistor extends AbstractPersistor {
  21. constructor(settings = {}) {
  22. super()
  23. this.settings = settings
  24. }
  25. async sendFile(bucketName, key, fsPath) {
  26. return this.sendStream(bucketName, key, fs.createReadStream(fsPath))
  27. }
  28. async sendStream(bucketName, key, readStream, sourceMd5) {
  29. try {
  30. // egress from us to S3
  31. const observeOptions = {
  32. metric: 's3.egress',
  33. Metrics: this.settings.Metrics
  34. }
  35. const observer = new PersistorHelper.ObserverStream(observeOptions)
  36. // observer will catch errors, clean up and log a warning
  37. readStream.pipe(observer)
  38. // if we have an md5 hash, pass this to S3 to verify the upload
  39. const uploadOptions = {
  40. Bucket: bucketName,
  41. Key: key,
  42. Body: observer
  43. }
  44. // if we have an md5 hash, pass this to S3 to verify the upload - otherwise
  45. // we rely on the S3 client's checksum calculation to validate the upload
  46. const clientOptions = {}
  47. if (sourceMd5) {
  48. uploadOptions.ContentMD5 = PersistorHelper.hexToBase64(sourceMd5)
  49. } else {
  50. clientOptions.computeChecksums = true
  51. }
  52. await this._getClientForBucket(bucketName, clientOptions)
  53. .upload(uploadOptions, { partSize: this.settings.partSize })
  54. .promise()
  55. } catch (err) {
  56. throw PersistorHelper.wrapError(
  57. err,
  58. 'upload to S3 failed',
  59. { bucketName, key },
  60. WriteError
  61. )
  62. }
  63. }
  64. async getObjectStream(bucketName, key, opts) {
  65. opts = opts || {}
  66. const params = {
  67. Bucket: bucketName,
  68. Key: key
  69. }
  70. if (opts.start != null && opts.end != null) {
  71. params.Range = `bytes=${opts.start}-${opts.end}`
  72. }
  73. const stream = this._getClientForBucket(bucketName)
  74. .getObject(params)
  75. .createReadStream()
  76. // ingress from S3 to us
  77. const observer = new PersistorHelper.ObserverStream({
  78. metric: 's3.ingress',
  79. Metrics: this.settings.Metrics
  80. })
  81. try {
  82. // wait for the pipeline to be ready, to catch non-200s
  83. await PersistorHelper.getReadyPipeline(stream, observer)
  84. return observer
  85. } catch (err) {
  86. throw PersistorHelper.wrapError(
  87. err,
  88. 'error reading file from S3',
  89. { bucketName, key, opts },
  90. ReadError
  91. )
  92. }
  93. }
  94. async getRedirectUrl() {
  95. // not implemented
  96. return null
  97. }
  98. async deleteDirectory(bucketName, key, continuationToken) {
  99. let response
  100. const options = { Bucket: bucketName, Prefix: key }
  101. if (continuationToken) {
  102. options.ContinuationToken = continuationToken
  103. }
  104. try {
  105. response = await this._getClientForBucket(bucketName)
  106. .listObjectsV2(options)
  107. .promise()
  108. } catch (err) {
  109. throw PersistorHelper.wrapError(
  110. err,
  111. 'failed to list objects in S3',
  112. { bucketName, key },
  113. ReadError
  114. )
  115. }
  116. const objects = response.Contents.map((item) => ({ Key: item.Key }))
  117. if (objects.length) {
  118. try {
  119. await this._getClientForBucket(bucketName)
  120. .deleteObjects({
  121. Bucket: bucketName,
  122. Delete: {
  123. Objects: objects,
  124. Quiet: true
  125. }
  126. })
  127. .promise()
  128. } catch (err) {
  129. throw PersistorHelper.wrapError(
  130. err,
  131. 'failed to delete objects in S3',
  132. { bucketName, key },
  133. WriteError
  134. )
  135. }
  136. }
  137. if (response.IsTruncated) {
  138. await this.deleteDirectory(
  139. bucketName,
  140. key,
  141. response.NextContinuationToken
  142. )
  143. }
  144. }
  145. async getObjectSize(bucketName, key) {
  146. try {
  147. const response = await this._getClientForBucket(bucketName)
  148. .headObject({ Bucket: bucketName, Key: key })
  149. .promise()
  150. return response.ContentLength
  151. } catch (err) {
  152. throw PersistorHelper.wrapError(
  153. err,
  154. 'error getting size of s3 object',
  155. { bucketName, key },
  156. ReadError
  157. )
  158. }
  159. }
  160. async getObjectMd5Hash(bucketName, key) {
  161. try {
  162. const response = await this._getClientForBucket(bucketName)
  163. .headObject({ Bucket: bucketName, Key: key })
  164. .promise()
  165. const md5 = S3Persistor._md5FromResponse(response)
  166. if (md5) {
  167. return md5
  168. }
  169. // etag is not in md5 format
  170. if (this.settings.Metrics) {
  171. this.settings.Metrics.inc('s3.md5Download')
  172. }
  173. return PersistorHelper.calculateStreamMd5(
  174. await this.getObjectStream(bucketName, key)
  175. )
  176. } catch (err) {
  177. throw PersistorHelper.wrapError(
  178. err,
  179. 'error getting hash of s3 object',
  180. { bucketName, key },
  181. ReadError
  182. )
  183. }
  184. }
  185. async deleteObject(bucketName, key) {
  186. try {
  187. await this._getClientForBucket(bucketName)
  188. .deleteObject({ Bucket: bucketName, Key: key })
  189. .promise()
  190. } catch (err) {
  191. // s3 does not give us a NotFoundError here
  192. throw PersistorHelper.wrapError(
  193. err,
  194. 'failed to delete file in S3',
  195. { bucketName, key },
  196. WriteError
  197. )
  198. }
  199. }
  200. async copyObject(bucketName, sourceKey, destKey) {
  201. const params = {
  202. Bucket: bucketName,
  203. Key: destKey,
  204. CopySource: `${bucketName}/${sourceKey}`
  205. }
  206. try {
  207. await this._getClientForBucket(bucketName).copyObject(params).promise()
  208. } catch (err) {
  209. throw PersistorHelper.wrapError(
  210. err,
  211. 'failed to copy file in S3',
  212. params,
  213. WriteError
  214. )
  215. }
  216. }
  217. async checkIfObjectExists(bucketName, key) {
  218. try {
  219. await this.getObjectSize(bucketName, key)
  220. return true
  221. } catch (err) {
  222. if (err instanceof NotFoundError) {
  223. return false
  224. }
  225. throw PersistorHelper.wrapError(
  226. err,
  227. 'error checking whether S3 object exists',
  228. { bucketName, key },
  229. ReadError
  230. )
  231. }
  232. }
  233. async directorySize(bucketName, key, continuationToken) {
  234. try {
  235. const options = {
  236. Bucket: bucketName,
  237. Prefix: key
  238. }
  239. if (continuationToken) {
  240. options.ContinuationToken = continuationToken
  241. }
  242. const response = await this._getClientForBucket(bucketName)
  243. .listObjectsV2(options)
  244. .promise()
  245. const size = response.Contents.reduce((acc, item) => item.Size + acc, 0)
  246. if (response.IsTruncated) {
  247. return (
  248. size +
  249. (await this.directorySize(
  250. bucketName,
  251. key,
  252. response.NextContinuationToken
  253. ))
  254. )
  255. }
  256. return size
  257. } catch (err) {
  258. throw PersistorHelper.wrapError(
  259. err,
  260. 'error getting directory size in S3',
  261. { bucketName, key },
  262. ReadError
  263. )
  264. }
  265. }
  266. _getClientForBucket(bucket, clientOptions) {
  267. if (this.settings.bucketCreds && this.settings.bucketCreds[bucket]) {
  268. return new S3(
  269. this._buildClientOptions(
  270. this.settings.bucketCreds[bucket],
  271. clientOptions
  272. )
  273. )
  274. }
  275. // no specific credentials for the bucket
  276. if (this.settings.key) {
  277. return new S3(this._buildClientOptions(null, clientOptions))
  278. }
  279. throw new SettingsError(
  280. 'no bucket-specific or default credentials provided',
  281. { bucket }
  282. )
  283. }
  284. _buildClientOptions(bucketCredentials, clientOptions) {
  285. const options = clientOptions || {}
  286. if (bucketCredentials) {
  287. options.credentials = {
  288. accessKeyId: bucketCredentials.auth_key,
  289. secretAccessKey: bucketCredentials.auth_secret
  290. }
  291. } else {
  292. options.credentials = {
  293. accessKeyId: this.settings.key,
  294. secretAccessKey: this.settings.secret
  295. }
  296. }
  297. if (this.settings.endpoint) {
  298. const endpoint = new URL(this.settings.endpoint)
  299. options.endpoint = this.settings.endpoint
  300. options.sslEnabled = endpoint.protocol === 'https'
  301. }
  302. // path-style access is only used for acceptance tests
  303. if (this.settings.pathStyle) {
  304. options.s3ForcePathStyle = true
  305. }
  306. return options
  307. }
  308. static _md5FromResponse(response) {
  309. const md5 = (response.ETag || '').replace(/[ "]/g, '')
  310. if (!md5.match(/^[a-f0-9]{32}$/)) {
  311. return null
  312. }
  313. return md5
  314. }
  315. }