S3Persistor.js 19 KB

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