S3Persistor.js 9.5 KB

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