S3Persistor.js 10 KB

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