S3Persistor.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  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 S3 = require('aws-sdk/clients/s3')
  17. const { URL } = require('node:url')
  18. const { WriteError, ReadError, NotFoundError } = require('./Errors')
  19. const zlib = require('node:zlib')
  20. /**
  21. * @typedef {import('aws-sdk/clients/s3').ListObjectsV2Output} ListObjectsV2Output
  22. */
  23. /**
  24. * @typedef {import('aws-sdk/clients/s3').Object} S3Object
  25. */
  26. /**
  27. * @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
  28. */
  29. /**
  30. * Wrapper with private fields to avoid revealing them on console, JSON.stringify or similar.
  31. */
  32. class SSECOptions {
  33. #keyAsBuffer
  34. #keyMD5
  35. /**
  36. * @param {Buffer} keyAsBuffer
  37. */
  38. constructor(keyAsBuffer) {
  39. this.#keyAsBuffer = keyAsBuffer
  40. this.#keyMD5 = Crypto.createHash('md5').update(keyAsBuffer).digest('base64')
  41. }
  42. getPutOptions() {
  43. return {
  44. SSECustomerKey: this.#keyAsBuffer,
  45. SSECustomerKeyMD5: this.#keyMD5,
  46. SSECustomerAlgorithm: 'AES256',
  47. }
  48. }
  49. getGetOptions() {
  50. return {
  51. SSECustomerKey: this.#keyAsBuffer,
  52. SSECustomerKeyMD5: this.#keyMD5,
  53. SSECustomerAlgorithm: 'AES256',
  54. }
  55. }
  56. getCopyOptions() {
  57. return {
  58. CopySourceSSECustomerKey: this.#keyAsBuffer,
  59. CopySourceSSECustomerKeyMD5: this.#keyMD5,
  60. CopySourceSSECustomerAlgorithm: 'AES256',
  61. }
  62. }
  63. }
  64. class S3Persistor extends AbstractPersistor {
  65. /** @type {Map<string, S3>} */
  66. #clients = new Map()
  67. constructor(settings = {}) {
  68. super()
  69. settings.storageClass = settings.storageClass || {}
  70. this.settings = settings
  71. }
  72. /**
  73. * @param {string} bucketName
  74. * @param {string} key
  75. * @param {string} fsPath
  76. * @return {Promise<void>}
  77. */
  78. async sendFile(bucketName, key, fsPath) {
  79. await this.sendStream(bucketName, key, fs.createReadStream(fsPath))
  80. }
  81. /**
  82. * @param {string} bucketName
  83. * @param {string} key
  84. * @param {NodeJS.ReadableStream} readStream
  85. * @param {Object} opts
  86. * @param {string} [opts.contentType]
  87. * @param {string} [opts.contentEncoding]
  88. * @param {number} [opts.contentLength]
  89. * @param {'*'} [opts.ifNoneMatch]
  90. * @param {SSECOptions} [opts.ssecOptions]
  91. * @param {string} [opts.sourceMd5]
  92. * @return {Promise<void>}
  93. */
  94. async sendStream(bucketName, key, readStream, opts = {}) {
  95. try {
  96. const observeOptions = {
  97. metric: 's3.egress', // egress from us to S3
  98. bucket: bucketName,
  99. }
  100. const observer = new PersistorHelper.ObserverStream(observeOptions)
  101. // observer will catch errors, clean up and log a warning
  102. pipeline(readStream, observer, () => {})
  103. /** @type {S3.PutObjectRequest} */
  104. const uploadOptions = {
  105. Bucket: bucketName,
  106. Key: key,
  107. Body: observer,
  108. }
  109. if (this.settings.storageClass[bucketName]) {
  110. uploadOptions.StorageClass = this.settings.storageClass[bucketName]
  111. }
  112. if (opts.contentType) {
  113. uploadOptions.ContentType = opts.contentType
  114. }
  115. if (opts.contentEncoding) {
  116. uploadOptions.ContentEncoding = opts.contentEncoding
  117. }
  118. if (opts.contentLength) {
  119. uploadOptions.ContentLength = opts.contentLength
  120. }
  121. if (opts.ifNoneMatch === '*') {
  122. uploadOptions.IfNoneMatch = '*'
  123. }
  124. if (opts.ssecOptions) {
  125. Object.assign(uploadOptions, opts.ssecOptions.getPutOptions())
  126. }
  127. // if we have an md5 hash, pass this to S3 to verify the upload - otherwise
  128. // we rely on the S3 client's checksum calculation to validate the upload
  129. let computeChecksums = false
  130. if (opts.sourceMd5) {
  131. uploadOptions.ContentMD5 = PersistorHelper.hexToBase64(opts.sourceMd5)
  132. } else {
  133. computeChecksums = true
  134. }
  135. if (this.settings.disableMultiPartUpload) {
  136. await this._getClientForBucket(bucketName, computeChecksums)
  137. .putObject(uploadOptions)
  138. .promise()
  139. } else {
  140. await this._getClientForBucket(bucketName, computeChecksums)
  141. .upload(uploadOptions, { partSize: this.settings.partSize })
  142. .promise()
  143. }
  144. } catch (err) {
  145. throw PersistorHelper.wrapError(
  146. err,
  147. 'upload to S3 failed',
  148. { bucketName, key, ifNoneMatch: opts.ifNoneMatch },
  149. WriteError
  150. )
  151. }
  152. }
  153. /**
  154. * @param {string} bucketName
  155. * @param {string} key
  156. * @param {Object} [opts]
  157. * @param {number} [opts.start]
  158. * @param {number} [opts.end]
  159. * @param {boolean} [opts.autoGunzip]
  160. * @param {SSECOptions} [opts.ssecOptions]
  161. * @return {Promise<NodeJS.ReadableStream>}
  162. */
  163. async getObjectStream(bucketName, key, opts) {
  164. opts = opts || {}
  165. const params = {
  166. Bucket: bucketName,
  167. Key: key,
  168. }
  169. if (opts.start != null && opts.end != null) {
  170. params.Range = `bytes=${opts.start}-${opts.end}`
  171. }
  172. if (opts.ssecOptions) {
  173. Object.assign(params, opts.ssecOptions.getGetOptions())
  174. }
  175. const observer = new PersistorHelper.ObserverStream({
  176. metric: 's3.ingress', // ingress from S3 to us
  177. bucket: bucketName,
  178. })
  179. const req = this._getClientForBucket(bucketName).getObject(params)
  180. const stream = req.createReadStream()
  181. let contentEncoding
  182. try {
  183. await new Promise((resolve, reject) => {
  184. req.on('httpHeaders', (statusCode, headers) => {
  185. switch (statusCode) {
  186. case 200: // full response
  187. case 206: // partial response
  188. contentEncoding = headers['content-encoding']
  189. return resolve(undefined)
  190. case 403: // AccessDenied
  191. return // handled by stream.on('error') handler below
  192. case 404: // NoSuchKey
  193. return reject(new NotFoundError('not found'))
  194. default:
  195. // handled by stream.on('error') handler below
  196. }
  197. })
  198. // The AWS SDK is forwarding any errors from the request to the stream.
  199. // The AWS SDK is emitting additional errors on the stream ahead of starting to stream.
  200. stream.on('error', reject)
  201. // The AWS SDK is kicking off the request in the next event loop cycle.
  202. })
  203. } catch (err) {
  204. throw PersistorHelper.wrapError(
  205. err,
  206. 'error reading file from S3',
  207. { bucketName, key, opts },
  208. ReadError
  209. )
  210. }
  211. // 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).
  212. const pass = new PassThrough()
  213. const transformer = []
  214. if (contentEncoding === 'gzip' && opts.autoGunzip) {
  215. transformer.push(zlib.createGunzip())
  216. }
  217. pipeline(stream, observer, ...transformer, pass, err => {
  218. if (err) req.abort()
  219. })
  220. return pass
  221. }
  222. /**
  223. * @param {string} bucketName
  224. * @param {string} key
  225. * @return {Promise<string>}
  226. */
  227. async getRedirectUrl(bucketName, key) {
  228. const expiresSeconds = Math.round(this.settings.signedUrlExpiryInMs / 1000)
  229. try {
  230. return await this._getClientForBucket(bucketName).getSignedUrlPromise(
  231. 'getObject',
  232. {
  233. Bucket: bucketName,
  234. Key: key,
  235. Expires: expiresSeconds,
  236. }
  237. )
  238. } catch (err) {
  239. throw PersistorHelper.wrapError(
  240. err,
  241. 'error generating signed url for S3 file',
  242. { bucketName, key },
  243. ReadError
  244. )
  245. }
  246. }
  247. /**
  248. * @param {string} bucketName
  249. * @param {string} key
  250. * @param {string} [continuationToken]
  251. * @return {Promise<void>}
  252. */
  253. async deleteDirectory(bucketName, key, continuationToken) {
  254. const { contents, response } = await this.listDirectory(
  255. bucketName,
  256. key,
  257. continuationToken
  258. )
  259. const objects = contents.map(item => ({ Key: item.Key || '' }))
  260. if (objects?.length) {
  261. try {
  262. await this._getClientForBucket(bucketName)
  263. .deleteObjects({
  264. Bucket: bucketName,
  265. Delete: {
  266. Objects: objects,
  267. Quiet: true,
  268. },
  269. })
  270. .promise()
  271. } catch (err) {
  272. throw PersistorHelper.wrapError(
  273. err,
  274. 'failed to delete objects in S3',
  275. { bucketName, key },
  276. WriteError
  277. )
  278. }
  279. }
  280. if (response.IsTruncated) {
  281. await this.deleteDirectory(
  282. bucketName,
  283. key,
  284. response.NextContinuationToken
  285. )
  286. }
  287. }
  288. /**
  289. *
  290. * @param {string} bucketName
  291. * @param {string} key
  292. * @param {string} [continuationToken]
  293. * @return {Promise<ListDirectoryResult>}
  294. */
  295. async listDirectory(bucketName, key, continuationToken) {
  296. let response
  297. const options = { Bucket: bucketName, Prefix: key }
  298. if (continuationToken) {
  299. options.ContinuationToken = continuationToken
  300. }
  301. try {
  302. response = await this._getClientForBucket(bucketName)
  303. .listObjectsV2(options)
  304. .promise()
  305. } catch (err) {
  306. throw PersistorHelper.wrapError(
  307. err,
  308. 'failed to list objects in S3',
  309. { bucketName, key },
  310. ReadError
  311. )
  312. }
  313. return { contents: response.Contents ?? [], response }
  314. }
  315. /**
  316. * @param {string} bucketName
  317. * @param {string} key
  318. * @param {Object} opts
  319. * @param {SSECOptions} [opts.ssecOptions]
  320. * @return {Promise<S3.HeadObjectOutput>}
  321. */
  322. async #headObject(bucketName, key, opts = {}) {
  323. const params = { Bucket: bucketName, Key: key }
  324. if (opts.ssecOptions) {
  325. Object.assign(params, opts.ssecOptions.getGetOptions())
  326. }
  327. try {
  328. return await this._getClientForBucket(bucketName)
  329. .headObject(params)
  330. .promise()
  331. } catch (err) {
  332. throw PersistorHelper.wrapError(
  333. err,
  334. 'error getting size of s3 object',
  335. { bucketName, key },
  336. ReadError
  337. )
  338. }
  339. }
  340. /**
  341. * @param {string} bucketName
  342. * @param {string} key
  343. * @param {Object} opts
  344. * @param {SSECOptions} [opts.ssecOptions]
  345. * @return {Promise<number>}
  346. */
  347. async getObjectSize(bucketName, key, opts = {}) {
  348. const response = await this.#headObject(bucketName, key, opts)
  349. return response.ContentLength || 0
  350. }
  351. /**
  352. * @param {string} bucketName
  353. * @param {string} key
  354. * @param {Object} opts
  355. * @param {SSECOptions} [opts.ssecOptions]
  356. * @return {Promise<string | undefined>}
  357. */
  358. async getObjectStorageClass(bucketName, key, opts = {}) {
  359. const response = await this.#headObject(bucketName, key, opts)
  360. return response.StorageClass
  361. }
  362. /**
  363. * @param {string} bucketName
  364. * @param {string} key
  365. * @param {Object} opts
  366. * @param {SSECOptions} [opts.ssecOptions]
  367. * @param {boolean} [opts.etagIsNotMD5]
  368. * @return {Promise<string>}
  369. */
  370. async getObjectMd5Hash(bucketName, key, opts = {}) {
  371. try {
  372. if (!opts.etagIsNotMD5) {
  373. const response = await this.#headObject(bucketName, key, opts)
  374. const md5 = S3Persistor._md5FromResponse(response)
  375. if (md5) {
  376. return md5
  377. }
  378. }
  379. // etag is not in md5 format
  380. Metrics.inc('s3.md5Download')
  381. return await PersistorHelper.calculateStreamMd5(
  382. await this.getObjectStream(bucketName, key, opts)
  383. )
  384. } catch (err) {
  385. throw PersistorHelper.wrapError(
  386. err,
  387. 'error getting hash of s3 object',
  388. { bucketName, key },
  389. ReadError
  390. )
  391. }
  392. }
  393. /**
  394. * @param {string} bucketName
  395. * @param {string} key
  396. * @return {Promise<void>}
  397. */
  398. async deleteObject(bucketName, key) {
  399. try {
  400. await this._getClientForBucket(bucketName)
  401. .deleteObject({ Bucket: bucketName, Key: key })
  402. .promise()
  403. } catch (err) {
  404. // s3 does not give us a NotFoundError here
  405. throw PersistorHelper.wrapError(
  406. err,
  407. 'failed to delete file in S3',
  408. { bucketName, key },
  409. WriteError
  410. )
  411. }
  412. }
  413. /**
  414. * @param {string} bucketName
  415. * @param {string} sourceKey
  416. * @param {string} destKey
  417. * @param {Object} opts
  418. * @param {SSECOptions} [opts.ssecSrcOptions]
  419. * @param {SSECOptions} [opts.ssecOptions]
  420. * @return {Promise<void>}
  421. */
  422. async copyObject(bucketName, sourceKey, destKey, opts = {}) {
  423. const params = {
  424. Bucket: bucketName,
  425. Key: destKey,
  426. CopySource: `${bucketName}/${sourceKey}`,
  427. }
  428. if (opts.ssecSrcOptions) {
  429. Object.assign(params, opts.ssecSrcOptions.getCopyOptions())
  430. }
  431. if (opts.ssecOptions) {
  432. Object.assign(params, opts.ssecOptions.getPutOptions())
  433. }
  434. try {
  435. await this._getClientForBucket(bucketName).copyObject(params).promise()
  436. } catch (err) {
  437. throw PersistorHelper.wrapError(
  438. err,
  439. 'failed to copy file in S3',
  440. params,
  441. WriteError
  442. )
  443. }
  444. }
  445. /**
  446. * @param {string} bucketName
  447. * @param {string} key
  448. * @param {Object} opts
  449. * @param {SSECOptions} [opts.ssecOptions]
  450. * @return {Promise<boolean>}
  451. */
  452. async checkIfObjectExists(bucketName, key, opts) {
  453. try {
  454. await this.getObjectSize(bucketName, key, opts)
  455. return true
  456. } catch (err) {
  457. if (err instanceof NotFoundError) {
  458. return false
  459. }
  460. throw PersistorHelper.wrapError(
  461. err,
  462. 'error checking whether S3 object exists',
  463. { bucketName, key },
  464. ReadError
  465. )
  466. }
  467. }
  468. /**
  469. * @param {string} bucketName
  470. * @param {string} key
  471. * @param {string} [continuationToken]
  472. * @return {Promise<number>}
  473. */
  474. async directorySize(bucketName, key, continuationToken) {
  475. try {
  476. const options = {
  477. Bucket: bucketName,
  478. Prefix: key,
  479. }
  480. if (continuationToken) {
  481. options.ContinuationToken = continuationToken
  482. }
  483. const response = await this._getClientForBucket(bucketName)
  484. .listObjectsV2(options)
  485. .promise()
  486. const size =
  487. response.Contents?.reduce((acc, item) => (item.Size || 0) + acc, 0) || 0
  488. if (response.IsTruncated) {
  489. return (
  490. size +
  491. (await this.directorySize(
  492. bucketName,
  493. key,
  494. response.NextContinuationToken
  495. ))
  496. )
  497. }
  498. return size
  499. } catch (err) {
  500. throw PersistorHelper.wrapError(
  501. err,
  502. 'error getting directory size in S3',
  503. { bucketName, key },
  504. ReadError
  505. )
  506. }
  507. }
  508. /**
  509. * @param {string} bucket
  510. * @param {boolean} computeChecksums
  511. * @return {S3}
  512. * @private
  513. */
  514. _getClientForBucket(bucket, computeChecksums = false) {
  515. /** @type {S3.Types.ClientConfiguration} */
  516. const clientOptions = {}
  517. const cacheKey = `${bucket}:${computeChecksums}`
  518. if (computeChecksums) {
  519. clientOptions.computeChecksums = true
  520. }
  521. let client = this.#clients.get(cacheKey)
  522. if (!client) {
  523. client = new S3(
  524. this._buildClientOptions(
  525. this.settings.bucketCreds?.[bucket],
  526. clientOptions
  527. )
  528. )
  529. this.#clients.set(cacheKey, client)
  530. }
  531. return client
  532. }
  533. /**
  534. * @param {Object} bucketCredentials
  535. * @param {S3.Types.ClientConfiguration} clientOptions
  536. * @return {S3.Types.ClientConfiguration}
  537. * @private
  538. */
  539. _buildClientOptions(bucketCredentials, clientOptions) {
  540. const options = clientOptions || {}
  541. if (bucketCredentials) {
  542. options.credentials = {
  543. accessKeyId: bucketCredentials.auth_key,
  544. secretAccessKey: bucketCredentials.auth_secret,
  545. }
  546. } else if (this.settings.key) {
  547. options.credentials = {
  548. accessKeyId: this.settings.key,
  549. secretAccessKey: this.settings.secret,
  550. }
  551. } else {
  552. // Use the default credentials provider (process.env -> SSP -> ini -> IAM)
  553. // Docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CredentialProviderChain.html#defaultProviders-property
  554. }
  555. if (this.settings.endpoint) {
  556. const endpoint = new URL(this.settings.endpoint)
  557. options.endpoint = this.settings.endpoint
  558. options.sslEnabled = endpoint.protocol === 'https:'
  559. }
  560. // path-style access is only used for acceptance tests
  561. if (this.settings.pathStyle) {
  562. options.s3ForcePathStyle = true
  563. }
  564. for (const opt of ['httpOptions', 'maxRetries', 'region']) {
  565. if (this.settings[opt]) {
  566. options[opt] = this.settings[opt]
  567. }
  568. }
  569. if (options.sslEnabled && this.settings.ca && !options.httpOptions?.agent) {
  570. options.httpOptions = options.httpOptions || {}
  571. options.httpOptions.agent = new https.Agent({
  572. rejectUnauthorized: true,
  573. ca: this.settings.ca,
  574. })
  575. }
  576. return options
  577. }
  578. /**
  579. * @param {S3.HeadObjectOutput} response
  580. * @return {string|null}
  581. * @private
  582. */
  583. static _md5FromResponse(response) {
  584. const md5 = (response.ETag || '').replace(/[ "]/g, '')
  585. if (!md5.match(/^[a-f0-9]{32}$/)) {
  586. return null
  587. }
  588. return md5
  589. }
  590. }
  591. module.exports = {
  592. S3Persistor,
  593. SSECOptions,
  594. }