S3Persistor.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791
  1. // @ts-check
  2. const http = require('node:http')
  3. const https = require('node:https')
  4. if (http.globalAgent.maxSockets < 300) {
  5. http.globalAgent.maxSockets = 300
  6. }
  7. if (https.globalAgent.maxSockets < 300) {
  8. https.globalAgent.maxSockets = 300
  9. }
  10. const Crypto = require('node:crypto')
  11. const Metrics = require('@overleaf/metrics')
  12. const AbstractPersistor = require('./AbstractPersistor')
  13. const PersistorHelper = require('./PersistorHelper')
  14. const { pipeline, PassThrough } = require('node:stream')
  15. const fs = require('node:fs')
  16. const {
  17. S3Client,
  18. CreateBucketCommand,
  19. GetObjectCommand,
  20. PutObjectCommand,
  21. HeadObjectCommand,
  22. DeleteObjectCommand,
  23. ListObjectsV2Command,
  24. DeleteObjectsCommand,
  25. CopyObjectCommand,
  26. } = require('@aws-sdk/client-s3')
  27. const { Upload } = require('@aws-sdk/lib-storage')
  28. const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
  29. const { NodeHttpHandler } = require('@aws-sdk/node-http-handler')
  30. const { WriteError, ReadError, NotFoundError } = require('./Errors')
  31. const zlib = require('node:zlib')
  32. const { addMd5Middleware } = require('./S3Md5Middleware')
  33. /**
  34. * @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
  35. */
  36. /**
  37. * Wrapper with private fields to avoid revealing them on console, JSON.stringify or similar.
  38. */
  39. class SSECOptions {
  40. #keyAsBuffer
  41. #keyMD5
  42. /**
  43. * @param {Buffer} keyAsBuffer
  44. */
  45. constructor(keyAsBuffer) {
  46. this.#keyAsBuffer = keyAsBuffer
  47. this.#keyMD5 = Crypto.createHash('md5').update(keyAsBuffer).digest('base64')
  48. }
  49. getPutOptions() {
  50. return {
  51. SSECustomerKey: this.#keyAsBuffer,
  52. SSECustomerKeyMD5: this.#keyMD5,
  53. SSECustomerAlgorithm: 'AES256',
  54. }
  55. }
  56. getGetOptions() {
  57. return {
  58. SSECustomerKey: this.#keyAsBuffer,
  59. SSECustomerKeyMD5: this.#keyMD5,
  60. SSECustomerAlgorithm: 'AES256',
  61. }
  62. }
  63. getCopyOptions() {
  64. return {
  65. CopySourceSSECustomerKey: this.#keyAsBuffer,
  66. CopySourceSSECustomerKeyMD5: this.#keyMD5,
  67. CopySourceSSECustomerAlgorithm: 'AES256',
  68. }
  69. }
  70. }
  71. /**
  72. * @typedef {import('@aws-sdk/client-s3').StorageClass} StorageClass
  73. * @typedef {import('@aws-sdk/client-s3').PutObjectCommandInput} PutObjectCommandInput
  74. * @typedef {import('@aws-sdk/client-s3').HeadObjectCommandOutput} HeadObjectCommandOutput
  75. * @typedef {import('@aws-sdk/client-s3').GetObjectCommandOutput} GetObjectCommandOutput
  76. * @typedef {import('@aws-sdk/client-s3').S3ClientConfig} S3ClientConfig
  77. */
  78. /**
  79. * @typedef {Object} StreamOptions
  80. * @property {string} [contentType]
  81. * @property {string} [contentEncoding]
  82. * @property {number} [contentLength]
  83. * @property {'*'} [ifNoneMatch]
  84. * @property {SSECOptions} [ssecOptions]
  85. */
  86. /**
  87. * @typedef {Object} S3PersistorSettings
  88. * @property {Object<string, StorageClass>} [storageClass]
  89. * @property {string} [key]
  90. * @property {string} [secret]
  91. * @property {string} [region]
  92. * @property {string} [endpoint]
  93. * @property {boolean} [pathStyle]
  94. * @property {number} [maxRetries]
  95. * @property {Object} [httpOptions]
  96. * @property {string} [ca]
  97. * @property {number} [signedUrlExpiryInMs]
  98. * @property {number} [partSize]
  99. * @property {Object<string, {auth_key: string, auth_secret: string}>} [bucketCreds]
  100. */
  101. class S3Persistor extends AbstractPersistor {
  102. /** @type {Map<string, S3Client>} */
  103. #clients = new Map()
  104. /** @type {S3PersistorSettings} */
  105. settings
  106. /**
  107. * @param {S3PersistorSettings} [settings]
  108. */
  109. constructor(settings = {}) {
  110. super()
  111. settings.storageClass = /** @type {Object<string, StorageClass>} */ (
  112. settings.storageClass || {}
  113. )
  114. this.settings = settings
  115. }
  116. /**
  117. * @param {string} bucketName
  118. * @param {string} key
  119. * @param {string} fsPath
  120. * @return {Promise<void>}
  121. */
  122. async sendFile(bucketName, key, fsPath) {
  123. await this.sendStream(bucketName, key, fs.createReadStream(fsPath))
  124. }
  125. /**
  126. * @param {string} bucketName
  127. * @param {string} key
  128. * @param {NodeJS.ReadableStream} readStream
  129. * @param {StreamOptions} opts
  130. * @return {Promise<void>}
  131. */
  132. async sendStream(bucketName, key, readStream, opts = {}) {
  133. try {
  134. const observeOptions = {
  135. metric: 's3.egress', // egress from us to S3
  136. bucket: bucketName,
  137. }
  138. const observer = new PersistorHelper.ObserverStream(observeOptions)
  139. // observer will catch errors, clean up and log a warning
  140. pipeline(readStream, observer, () => {})
  141. /** @type {import('@aws-sdk/client-s3').PutObjectCommandInput} */
  142. const uploadOptions = {
  143. Bucket: bucketName,
  144. Key: key,
  145. Body: observer,
  146. }
  147. const storageClass = /** @type {Object<string, StorageClass>} */ (
  148. this.settings.storageClass
  149. )
  150. if (storageClass[bucketName]) {
  151. uploadOptions.StorageClass = storageClass[bucketName]
  152. }
  153. if ('sourceMd5' in opts) {
  154. // we fail straight away to prevent the client from wasting CPU/IO precomputing the hash
  155. throw new Error(
  156. 'sourceMd5 option is not supported, S3 provides its own integrity protection mechanism'
  157. )
  158. }
  159. if (opts.contentType) {
  160. uploadOptions.ContentType = opts.contentType
  161. }
  162. if (opts.contentEncoding) {
  163. uploadOptions.ContentEncoding = opts.contentEncoding
  164. }
  165. if (opts.contentLength) {
  166. uploadOptions.ContentLength = opts.contentLength
  167. }
  168. if (opts.ifNoneMatch === '*') {
  169. uploadOptions.IfNoneMatch = '*'
  170. }
  171. if (opts.ssecOptions) {
  172. Object.assign(uploadOptions, opts.ssecOptions.getPutOptions())
  173. }
  174. const upload = new Upload({
  175. client: this._getClientForBucket(bucketName),
  176. params: uploadOptions,
  177. partSize: this.settings.partSize,
  178. })
  179. await upload.done()
  180. } catch (err) {
  181. throw PersistorHelper.wrapError(
  182. err,
  183. 'upload to S3 failed',
  184. { bucketName, key, ifNoneMatch: opts.ifNoneMatch },
  185. WriteError
  186. )
  187. }
  188. }
  189. /**
  190. * @param {string} bucketName
  191. * @param {string} key
  192. * @param {Object} [opts]
  193. * @param {number} [opts.start]
  194. * @param {number} [opts.end]
  195. * @param {boolean} [opts.autoGunzip]
  196. * @param {SSECOptions} [opts.ssecOptions]
  197. * @return {Promise<NodeJS.ReadableStream>}
  198. */
  199. async getObjectStream(bucketName, key, opts) {
  200. opts = opts || {}
  201. /** @type {import('@aws-sdk/client-s3').GetObjectCommandInput} */
  202. const params = {
  203. Bucket: bucketName,
  204. Key: key,
  205. }
  206. if (opts.start != null && opts.end != null) {
  207. params.Range = `bytes=${opts.start}-${opts.end}`
  208. }
  209. if (opts.ssecOptions) {
  210. Object.assign(params, opts.ssecOptions.getGetOptions())
  211. }
  212. const observer = new PersistorHelper.ObserverStream({
  213. metric: 's3.ingress', // ingress from S3 to us
  214. bucket: bucketName,
  215. })
  216. const abortController = new AbortController()
  217. let stream, contentEncoding
  218. try {
  219. const { Body, ContentEncoding } = await this._getClientForBucket(
  220. bucketName
  221. ).send(new GetObjectCommand(params), {
  222. abortSignal: abortController.signal,
  223. })
  224. stream = Body
  225. contentEncoding = ContentEncoding
  226. } catch (err) {
  227. abortController.abort()
  228. throw PersistorHelper.wrapError(
  229. err,
  230. 'error reading file from S3',
  231. { bucketName, key, opts },
  232. ReadError
  233. )
  234. }
  235. // Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
  236. const pass = new PassThrough()
  237. const transformer = []
  238. if (contentEncoding === 'gzip' && opts.autoGunzip) {
  239. transformer.push(zlib.createGunzip())
  240. }
  241. // @ts-ignore stream (Body) can be undefined in GetObjectCommand
  242. pipeline(stream, observer, ...transformer, pass, err => {
  243. if (err) {
  244. abortController.abort()
  245. }
  246. })
  247. return pass
  248. }
  249. /**
  250. * @param {string} bucketName
  251. * @param {string} key
  252. * @return {Promise<string>}
  253. */
  254. async getRedirectUrl(bucketName, key) {
  255. const expiresSeconds = Math.round(
  256. /** @type {number} */ (this.settings.signedUrlExpiryInMs) / 1000
  257. )
  258. try {
  259. return await getSignedUrl(
  260. this._getClientForBucket(bucketName),
  261. new GetObjectCommand({
  262. Bucket: bucketName,
  263. Key: key,
  264. }),
  265. { expiresIn: expiresSeconds }
  266. )
  267. } catch (err) {
  268. throw PersistorHelper.wrapError(
  269. err,
  270. 'error generating signed url for S3 file',
  271. { bucketName, key },
  272. ReadError
  273. )
  274. }
  275. }
  276. /**
  277. * @param {string} bucketName
  278. * @param {string} key
  279. * @param {string} [continuationToken]
  280. * @return {Promise<void>}
  281. */
  282. async deleteDirectory(bucketName, key, continuationToken) {
  283. const { contents, response } = await this.#listDirectory(
  284. bucketName,
  285. key,
  286. continuationToken
  287. )
  288. const objects = contents.map(item => ({ Key: item.Key || '' }))
  289. if (objects?.length) {
  290. try {
  291. await this._getClientForBucket(bucketName).send(
  292. new DeleteObjectsCommand({
  293. Bucket: bucketName,
  294. Delete: {
  295. Objects: objects,
  296. Quiet: true,
  297. },
  298. })
  299. )
  300. } catch (err) {
  301. throw PersistorHelper.wrapError(
  302. err,
  303. 'failed to delete objects in S3',
  304. { bucketName, key },
  305. WriteError
  306. )
  307. }
  308. }
  309. if (response.IsTruncated) {
  310. await this.deleteDirectory(
  311. bucketName,
  312. key,
  313. response.NextContinuationToken
  314. )
  315. }
  316. }
  317. /**
  318. *
  319. * @param {string} bucketName
  320. * @param {string} key
  321. * @param {string} [continuationToken]
  322. * @return {Promise<ListDirectoryResult>}
  323. */
  324. async #listDirectory(bucketName, key, continuationToken) {
  325. let response
  326. /** @type {{ Bucket: string, Prefix: string, ContinuationToken?: string }} */
  327. const options = { Bucket: bucketName, Prefix: key }
  328. if (continuationToken) {
  329. options.ContinuationToken = continuationToken
  330. }
  331. try {
  332. response = await this._getClientForBucket(bucketName).send(
  333. new ListObjectsV2Command(options)
  334. )
  335. } catch (err) {
  336. throw PersistorHelper.wrapError(
  337. err,
  338. 'failed to list objects in S3',
  339. { bucketName, key },
  340. ReadError
  341. )
  342. }
  343. return { contents: response.Contents ?? [], response }
  344. }
  345. /**
  346. * @param {string} bucketName
  347. * @param {string} key
  348. * @param {Object} opts
  349. * @param {SSECOptions} [opts.ssecOptions]
  350. * @return {Promise<import('@aws-sdk/client-s3').HeadObjectOutput>}
  351. */
  352. async #headObject(bucketName, key, opts = {}) {
  353. const params = { Bucket: bucketName, Key: key }
  354. if (opts.ssecOptions) {
  355. Object.assign(params, opts.ssecOptions.getGetOptions())
  356. }
  357. try {
  358. const client = await this._getClientForBucket(bucketName)
  359. return await client.send(new HeadObjectCommand(params))
  360. } catch (err) {
  361. throw PersistorHelper.wrapError(
  362. err,
  363. 'error getting size of s3 object',
  364. { bucketName, key },
  365. ReadError
  366. )
  367. }
  368. }
  369. /**
  370. * @param {string} bucketName
  371. * @param {string} key
  372. * @param {Object} opts
  373. * @param {SSECOptions} [opts.ssecOptions]
  374. * @return {Promise<number>}
  375. */
  376. async getObjectSize(bucketName, key, opts = {}) {
  377. const response = await this.#headObject(bucketName, key, opts)
  378. return response.ContentLength || 0
  379. }
  380. /**
  381. * @param {string} bucketName
  382. * @param {string} key
  383. * @param {Object} opts
  384. * @param {SSECOptions} [opts.ssecOptions]
  385. * @return {Promise<string | undefined>}
  386. */
  387. async getObjectStorageClass(bucketName, key, opts = {}) {
  388. const response = await this.#headObject(bucketName, key, opts)
  389. return response.StorageClass
  390. }
  391. /**
  392. * @param {string} bucketName
  393. * @param {string} key
  394. * @param {Object} opts
  395. * @param {SSECOptions} [opts.ssecOptions]
  396. * @param {boolean} [opts.etagIsNotMD5]
  397. * @return {Promise<string>}
  398. */
  399. async getObjectMd5Hash(bucketName, key, opts = {}) {
  400. try {
  401. if (!opts.etagIsNotMD5) {
  402. const response = await this.#headObject(bucketName, key, opts)
  403. const md5 = S3Persistor._md5FromResponse(response)
  404. if (md5) {
  405. return md5
  406. }
  407. }
  408. // etag is not in md5 format
  409. Metrics.inc('s3.md5Download')
  410. return await PersistorHelper.calculateStreamMd5(
  411. await this.getObjectStream(bucketName, key, opts)
  412. )
  413. } catch (err) {
  414. throw PersistorHelper.wrapError(
  415. err,
  416. 'error getting hash of s3 object',
  417. { bucketName, key },
  418. ReadError
  419. )
  420. }
  421. }
  422. /**
  423. * @param {string} bucketName
  424. * @param {string} key
  425. * @return {Promise<void>}
  426. */
  427. async deleteObject(bucketName, key) {
  428. try {
  429. await this._getClientForBucket(bucketName).send(
  430. new DeleteObjectCommand({ Bucket: bucketName, Key: key })
  431. )
  432. } catch (err) {
  433. // s3 does not give us a NotFoundError here
  434. throw PersistorHelper.wrapError(
  435. err,
  436. 'failed to delete file in S3',
  437. { bucketName, key },
  438. WriteError
  439. )
  440. }
  441. }
  442. /**
  443. * @param {string} bucketName
  444. * @param {string} sourceKey
  445. * @param {string} destKey
  446. * @param {Object} opts
  447. * @param {SSECOptions} [opts.ssecSrcOptions]
  448. * @param {SSECOptions} [opts.ssecOptions]
  449. * @return {Promise<void>}
  450. */
  451. async copyObject(bucketName, sourceKey, destKey, opts = {}) {
  452. const params = {
  453. Bucket: bucketName,
  454. Key: destKey,
  455. CopySource: `/${bucketName}/${sourceKey}`,
  456. }
  457. if (opts.ssecSrcOptions) {
  458. Object.assign(params, opts.ssecSrcOptions.getCopyOptions())
  459. }
  460. if (opts.ssecOptions) {
  461. Object.assign(params, opts.ssecOptions.getPutOptions())
  462. }
  463. try {
  464. await this._getClientForBucket(bucketName).send(
  465. new CopyObjectCommand(params)
  466. )
  467. } catch (err) {
  468. throw PersistorHelper.wrapError(
  469. err,
  470. 'failed to copy file in S3',
  471. params,
  472. WriteError
  473. )
  474. }
  475. }
  476. /**
  477. * @param {string} bucketName
  478. * @param {string} key
  479. * @param {Object} opts
  480. * @param {SSECOptions} [opts.ssecOptions]
  481. * @return {Promise<boolean>}
  482. */
  483. async checkIfObjectExists(bucketName, key, opts) {
  484. try {
  485. await this.getObjectSize(bucketName, key, opts)
  486. return true
  487. } catch (err) {
  488. if (err instanceof NotFoundError) {
  489. return false
  490. }
  491. throw PersistorHelper.wrapError(
  492. err,
  493. 'error checking whether S3 object exists',
  494. { bucketName, key },
  495. ReadError
  496. )
  497. }
  498. }
  499. /**
  500. * @param {string} bucketName
  501. * @param {string} key
  502. * @param {string} [continuationToken]
  503. * @return {Promise<number>}
  504. */
  505. async directorySize(bucketName, key, continuationToken) {
  506. try {
  507. /** @type {{ Bucket: string, Prefix: string, ContinuationToken?: string }} */
  508. const options = {
  509. Bucket: bucketName,
  510. Prefix: key,
  511. }
  512. if (continuationToken) {
  513. options.ContinuationToken = continuationToken
  514. }
  515. const response = await this._getClientForBucket(bucketName).send(
  516. new ListObjectsV2Command(options)
  517. )
  518. const size =
  519. response.Contents?.reduce((acc, item) => (item.Size || 0) + acc, 0) || 0
  520. if (response.IsTruncated) {
  521. return (
  522. size +
  523. (await this.directorySize(
  524. bucketName,
  525. key,
  526. response.NextContinuationToken
  527. ))
  528. )
  529. }
  530. return size
  531. } catch (err) {
  532. throw PersistorHelper.wrapError(
  533. err,
  534. 'error getting directory size in S3',
  535. { bucketName, key },
  536. ReadError
  537. )
  538. }
  539. }
  540. /**
  541. * @param {string} bucketName
  542. * @param {string} prefix
  543. * @return {Promise<Array<string>>}
  544. */
  545. async listDirectoryKeys(bucketName, prefix) {
  546. const keys = []
  547. let continuationToken
  548. do {
  549. const { contents, response } = await this.#listDirectory(
  550. bucketName,
  551. prefix,
  552. continuationToken
  553. )
  554. keys.push(...contents.map(item => item.Key || ''))
  555. continuationToken = response.IsTruncated
  556. ? response.NextContinuationToken
  557. : undefined
  558. } while (continuationToken)
  559. return keys
  560. }
  561. /**
  562. * @param {string} bucketName
  563. * @param {string} prefix
  564. * @return {Promise<Array<{key: string, size: number}>>}
  565. */
  566. async listDirectoryStats(bucketName, prefix) {
  567. const stats = []
  568. let continuationToken
  569. do {
  570. const { contents, response } = await this.#listDirectory(
  571. bucketName,
  572. prefix,
  573. continuationToken
  574. )
  575. stats.push(
  576. ...contents.map(item => ({
  577. key: item.Key || '',
  578. size: item.Size ?? -1,
  579. }))
  580. )
  581. continuationToken = response.IsTruncated
  582. ? response.NextContinuationToken
  583. : undefined
  584. } while (continuationToken)
  585. return stats
  586. }
  587. /**
  588. * @param {string} bucket
  589. * @return {S3Client}
  590. * @private
  591. */
  592. _getClientForBucket(bucket) {
  593. /** @type {import('@aws-sdk/client-s3').S3ClientConfig} */
  594. const clientOptions = {}
  595. const cacheKey = bucket
  596. let client = this.#clients.get(cacheKey)
  597. if (!client) {
  598. client = new S3Client(
  599. this._buildClientOptions(
  600. this.settings.bucketCreds?.[bucket],
  601. clientOptions
  602. )
  603. )
  604. this.#clients.set(cacheKey, client)
  605. // Some third-party S3-compatible services (as MinIO) do not support the default checksums
  606. // for object integrity in `DeleteObjectsCommand`. We add a fallback for those cases.
  607. // https://github.com/aws/aws-sdk-js-v3/blob/main/supplemental-docs/MD5_FALLBACK.md
  608. const useMd5Fallback = process.env.DELETE_OBJECTS_MD5_FALLBACK === 'true'
  609. if (useMd5Fallback) {
  610. addMd5Middleware(client)
  611. }
  612. }
  613. return client
  614. }
  615. /**
  616. * @param {{auth_key: string, auth_secret: string} | undefined} bucketCredentials
  617. * @param {import('@aws-sdk/client-s3').S3ClientConfig} clientOptions
  618. * @return {import('@aws-sdk/client-s3').S3ClientConfig}
  619. * @private
  620. */
  621. _buildClientOptions(bucketCredentials, clientOptions) {
  622. const options = clientOptions || {}
  623. if (bucketCredentials) {
  624. options.credentials = {
  625. accessKeyId: bucketCredentials.auth_key,
  626. secretAccessKey: bucketCredentials.auth_secret,
  627. }
  628. } else if (this.settings.key) {
  629. options.credentials = {
  630. accessKeyId: this.settings.key,
  631. secretAccessKey: /** @type {string} */ (this.settings.secret),
  632. }
  633. } else {
  634. // Use the default credentials provider (process.env -> SSP -> ini -> IAM)
  635. // Docs: https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
  636. }
  637. // region is required since aws sdk v3
  638. options.region =
  639. this.settings.region || process.env.AWS_REGION || 'us-east-1'
  640. let sslEnabled = false
  641. if (this.settings.endpoint) {
  642. const endpoint = new URL(this.settings.endpoint)
  643. options.endpoint = this.settings.endpoint
  644. sslEnabled = endpoint.protocol === 'https:'
  645. }
  646. // path-style access is only used for acceptance tests
  647. if (this.settings.pathStyle) {
  648. options.forcePathStyle = true
  649. }
  650. // maxRetries has been moved to maxAttempts in aws-sdk v3,
  651. // we're keeping the existing setting for backwards compatibility
  652. if (this.settings.maxRetries) {
  653. options.maxAttempts = this.settings.maxRetries + 1
  654. }
  655. const requestHandlerParams = this.settings.httpOptions || {}
  656. if (sslEnabled && this.settings.ca) {
  657. const agent = new https.Agent({
  658. rejectUnauthorized: true,
  659. ca: this.settings.ca,
  660. })
  661. Object.assign(requestHandlerParams, {
  662. httpAgent: agent,
  663. httpsAgent: agent,
  664. })
  665. options.requestHandler = new NodeHttpHandler({
  666. httpAgent: agent,
  667. httpsAgent: agent,
  668. })
  669. }
  670. const requestHandler = this._buildNodeHttpHandler(requestHandlerParams)
  671. if (requestHandler) {
  672. options.requestHandler = requestHandler
  673. }
  674. return options
  675. }
  676. /**
  677. * @param {import('@aws-sdk/node-http-handler').NodeHttpHandlerOptions & { timeout?: number }} params
  678. * @private
  679. */
  680. _buildNodeHttpHandler(params) {
  681. const isEmpty = Object.keys(params).length === 0
  682. if (isEmpty) {
  683. return
  684. }
  685. // ensures backwards compatibility with aws-sdk v2 httpOptions
  686. if (params.timeout) {
  687. params.connectionTimeout = params.timeout
  688. delete params.timeout
  689. }
  690. return new NodeHttpHandler(params)
  691. }
  692. // test-only
  693. /**
  694. * @param {string} bucketName
  695. */
  696. _createBucket(bucketName) {
  697. return this._getClientForBucket(bucketName).send(
  698. new CreateBucketCommand({ Bucket: bucketName })
  699. )
  700. }
  701. // test-only
  702. /**
  703. * @param {string} bucketName
  704. * @param {import('@aws-sdk/client-s3').PutObjectCommandInput} uploadOptions
  705. */
  706. _upload(bucketName, uploadOptions) {
  707. return this._getClientForBucket(bucketName).send(
  708. new PutObjectCommand(uploadOptions)
  709. )
  710. }
  711. /**
  712. * @param {import('@aws-sdk/client-s3').HeadObjectOutput} response
  713. * @return {string|null}
  714. * @private
  715. */
  716. static _md5FromResponse(response) {
  717. const md5 = (response.ETag || '').replace(/[ "]/g, '')
  718. if (!md5.match(/^[a-f0-9]{32}$/)) {
  719. return null
  720. }
  721. return md5
  722. }
  723. }
  724. module.exports = {
  725. S3Persistor,
  726. SSECOptions,
  727. }