S3Persistor.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  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 { pipeline, PassThrough } = require('stream')
  12. const fs = require('fs')
  13. const S3 = require('aws-sdk/clients/s3')
  14. const { URL } = require('url')
  15. const { WriteError, ReadError, NotFoundError } = require('./Errors')
  16. module.exports = class S3Persistor extends AbstractPersistor {
  17. constructor(settings = {}) {
  18. super()
  19. this.settings = settings
  20. }
  21. async sendFile(bucketName, key, fsPath) {
  22. return this.sendStream(bucketName, key, fs.createReadStream(fsPath))
  23. }
  24. async sendStream(bucketName, key, readStream, opts = {}) {
  25. try {
  26. // egress from us to S3
  27. const observeOptions = {
  28. metric: 's3.egress',
  29. Metrics: this.settings.Metrics,
  30. }
  31. const observer = new PersistorHelper.ObserverStream(observeOptions)
  32. // observer will catch errors, clean up and log a warning
  33. pipeline(readStream, observer, () => {})
  34. // if we have an md5 hash, pass this to S3 to verify the upload
  35. const uploadOptions = {
  36. Bucket: bucketName,
  37. Key: key,
  38. Body: observer,
  39. }
  40. if (opts.contentType) {
  41. uploadOptions.ContentType = opts.contentType
  42. }
  43. if (opts.contentEncoding) {
  44. uploadOptions.ContentEncoding = opts.contentEncoding
  45. }
  46. // if we have an md5 hash, pass this to S3 to verify the upload - otherwise
  47. // we rely on the S3 client's checksum calculation to validate the upload
  48. const clientOptions = {}
  49. if (opts.sourceMd5) {
  50. uploadOptions.ContentMD5 = PersistorHelper.hexToBase64(opts.sourceMd5)
  51. } else {
  52. clientOptions.computeChecksums = true
  53. }
  54. await this._getClientForBucket(bucketName, clientOptions)
  55. .upload(uploadOptions, { partSize: this.settings.partSize })
  56. .promise()
  57. } catch (err) {
  58. throw PersistorHelper.wrapError(
  59. err,
  60. 'upload to S3 failed',
  61. { bucketName, key },
  62. WriteError
  63. )
  64. }
  65. }
  66. async getObjectStream(bucketName, key, opts) {
  67. opts = opts || {}
  68. const params = {
  69. Bucket: bucketName,
  70. Key: key,
  71. }
  72. if (opts.start != null && opts.end != null) {
  73. params.Range = `bytes=${opts.start}-${opts.end}`
  74. }
  75. const req = this._getClientForBucket(bucketName).getObject(params)
  76. const stream = req.createReadStream()
  77. try {
  78. await new Promise((resolve, reject) => {
  79. req.on('httpHeaders', statusCode => {
  80. switch (statusCode) {
  81. case 200: // full response
  82. case 206: // partial response
  83. return resolve()
  84. case 403: // AccessDenied is handled the same as NoSuchKey
  85. case 404: // NoSuchKey
  86. return reject(new NotFoundError())
  87. default:
  88. return reject(new Error('non success status: ' + statusCode))
  89. }
  90. })
  91. // The AWS SDK is forwarding any errors from the request to the stream.
  92. // The AWS SDK is emitting additional errors on the stream ahead of starting to stream.
  93. stream.on('error', reject)
  94. // The AWS SDK is kicking off the request in the next event loop cycle.
  95. })
  96. } catch (err) {
  97. throw PersistorHelper.wrapError(
  98. err,
  99. 'error reading file from S3',
  100. { bucketName, key, opts },
  101. ReadError
  102. )
  103. }
  104. // ingress from S3 to us
  105. const observer = new PersistorHelper.ObserverStream({
  106. metric: 's3.ingress',
  107. Metrics: this.settings.Metrics,
  108. })
  109. const pass = new PassThrough()
  110. pipeline(stream, observer, pass, err => {
  111. if (err) req.abort()
  112. })
  113. return pass
  114. }
  115. async getRedirectUrl(bucketName, key) {
  116. const expiresSeconds = Math.round(this.settings.signedUrlExpiryInMs / 1000)
  117. try {
  118. const url = await this._getClientForBucket(
  119. bucketName
  120. ).getSignedUrlPromise('getObject', {
  121. Bucket: bucketName,
  122. Key: key,
  123. Expires: expiresSeconds,
  124. })
  125. return url
  126. } catch (err) {
  127. throw PersistorHelper.wrapError(
  128. err,
  129. 'error generating signed url for S3 file',
  130. { bucketName, key },
  131. ReadError
  132. )
  133. }
  134. }
  135. async deleteDirectory(bucketName, key, continuationToken) {
  136. let response
  137. const options = { Bucket: bucketName, Prefix: key }
  138. if (continuationToken) {
  139. options.ContinuationToken = continuationToken
  140. }
  141. try {
  142. response = await this._getClientForBucket(bucketName)
  143. .listObjectsV2(options)
  144. .promise()
  145. } catch (err) {
  146. throw PersistorHelper.wrapError(
  147. err,
  148. 'failed to list objects in S3',
  149. { bucketName, key },
  150. ReadError
  151. )
  152. }
  153. const objects = response.Contents.map(item => ({ Key: item.Key }))
  154. if (objects.length) {
  155. try {
  156. await this._getClientForBucket(bucketName)
  157. .deleteObjects({
  158. Bucket: bucketName,
  159. Delete: {
  160. Objects: objects,
  161. Quiet: true,
  162. },
  163. })
  164. .promise()
  165. } catch (err) {
  166. throw PersistorHelper.wrapError(
  167. err,
  168. 'failed to delete objects in S3',
  169. { bucketName, key },
  170. WriteError
  171. )
  172. }
  173. }
  174. if (response.IsTruncated) {
  175. await this.deleteDirectory(
  176. bucketName,
  177. key,
  178. response.NextContinuationToken
  179. )
  180. }
  181. }
  182. async getObjectSize(bucketName, key) {
  183. try {
  184. const response = await this._getClientForBucket(bucketName)
  185. .headObject({ Bucket: bucketName, Key: key })
  186. .promise()
  187. return response.ContentLength
  188. } catch (err) {
  189. throw PersistorHelper.wrapError(
  190. err,
  191. 'error getting size of s3 object',
  192. { bucketName, key },
  193. ReadError
  194. )
  195. }
  196. }
  197. async getObjectMd5Hash(bucketName, key) {
  198. try {
  199. const response = await this._getClientForBucket(bucketName)
  200. .headObject({ Bucket: bucketName, Key: key })
  201. .promise()
  202. const md5 = S3Persistor._md5FromResponse(response)
  203. if (md5) {
  204. return md5
  205. }
  206. // etag is not in md5 format
  207. if (this.settings.Metrics) {
  208. this.settings.Metrics.inc('s3.md5Download')
  209. }
  210. return PersistorHelper.calculateStreamMd5(
  211. await this.getObjectStream(bucketName, key)
  212. )
  213. } catch (err) {
  214. throw PersistorHelper.wrapError(
  215. err,
  216. 'error getting hash of s3 object',
  217. { bucketName, key },
  218. ReadError
  219. )
  220. }
  221. }
  222. async deleteObject(bucketName, key) {
  223. try {
  224. await this._getClientForBucket(bucketName)
  225. .deleteObject({ Bucket: bucketName, Key: key })
  226. .promise()
  227. } catch (err) {
  228. // s3 does not give us a NotFoundError here
  229. throw PersistorHelper.wrapError(
  230. err,
  231. 'failed to delete file in S3',
  232. { bucketName, key },
  233. WriteError
  234. )
  235. }
  236. }
  237. async copyObject(bucketName, sourceKey, destKey) {
  238. const params = {
  239. Bucket: bucketName,
  240. Key: destKey,
  241. CopySource: `${bucketName}/${sourceKey}`,
  242. }
  243. try {
  244. await this._getClientForBucket(bucketName).copyObject(params).promise()
  245. } catch (err) {
  246. throw PersistorHelper.wrapError(
  247. err,
  248. 'failed to copy file in S3',
  249. params,
  250. WriteError
  251. )
  252. }
  253. }
  254. async checkIfObjectExists(bucketName, key) {
  255. try {
  256. await this.getObjectSize(bucketName, key)
  257. return true
  258. } catch (err) {
  259. if (err instanceof NotFoundError) {
  260. return false
  261. }
  262. throw PersistorHelper.wrapError(
  263. err,
  264. 'error checking whether S3 object exists',
  265. { bucketName, key },
  266. ReadError
  267. )
  268. }
  269. }
  270. async directorySize(bucketName, key, continuationToken) {
  271. try {
  272. const options = {
  273. Bucket: bucketName,
  274. Prefix: key,
  275. }
  276. if (continuationToken) {
  277. options.ContinuationToken = continuationToken
  278. }
  279. const response = await this._getClientForBucket(bucketName)
  280. .listObjectsV2(options)
  281. .promise()
  282. const size = response.Contents.reduce((acc, item) => item.Size + acc, 0)
  283. if (response.IsTruncated) {
  284. return (
  285. size +
  286. (await this.directorySize(
  287. bucketName,
  288. key,
  289. response.NextContinuationToken
  290. ))
  291. )
  292. }
  293. return size
  294. } catch (err) {
  295. throw PersistorHelper.wrapError(
  296. err,
  297. 'error getting directory size in S3',
  298. { bucketName, key },
  299. ReadError
  300. )
  301. }
  302. }
  303. _getClientForBucket(bucket, clientOptions) {
  304. return new S3(
  305. this._buildClientOptions(
  306. this.settings.bucketCreds?.[bucket],
  307. clientOptions
  308. )
  309. )
  310. }
  311. _buildClientOptions(bucketCredentials, clientOptions) {
  312. const options = clientOptions || {}
  313. if (bucketCredentials) {
  314. options.credentials = {
  315. accessKeyId: bucketCredentials.auth_key,
  316. secretAccessKey: bucketCredentials.auth_secret,
  317. }
  318. } else if (this.settings.key) {
  319. options.credentials = {
  320. accessKeyId: this.settings.key,
  321. secretAccessKey: this.settings.secret,
  322. }
  323. } else {
  324. // Use the default credentials provider (process.env -> SSP -> ini -> IAM)
  325. // Docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CredentialProviderChain.html#defaultProviders-property
  326. }
  327. if (this.settings.endpoint) {
  328. const endpoint = new URL(this.settings.endpoint)
  329. options.endpoint = this.settings.endpoint
  330. options.sslEnabled = endpoint.protocol === 'https'
  331. }
  332. // path-style access is only used for acceptance tests
  333. if (this.settings.pathStyle) {
  334. options.s3ForcePathStyle = true
  335. }
  336. for (const opt of ['httpOptions', 'maxRetries', 'region']) {
  337. if (this.settings[opt]) {
  338. options[opt] = this.settings[opt]
  339. }
  340. }
  341. return options
  342. }
  343. static _md5FromResponse(response) {
  344. const md5 = (response.ETag || '').replace(/[ "]/g, '')
  345. if (!md5.match(/^[a-f0-9]{32}$/)) {
  346. return null
  347. }
  348. return md5
  349. }
  350. }