S3Persistor.js 20 KB

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