S3Persistor.js 16 KB

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