GcsPersistor.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. const fs = require('node:fs')
  2. const { pipeline } = require('node:stream/promises')
  3. const { PassThrough } = require('node:stream')
  4. const { Storage, IdempotencyStrategy } = require('@google-cloud/storage')
  5. const {
  6. WriteError,
  7. ReadError,
  8. NotFoundError,
  9. NotImplementedError,
  10. } = require('./Errors')
  11. const asyncPool = require('tiny-async-pool')
  12. const AbstractPersistor = require('./AbstractPersistor')
  13. const PersistorHelper = require('./PersistorHelper')
  14. const Logger = require('@overleaf/logger')
  15. const zlib = require('node:zlib')
  16. module.exports = class GcsPersistor extends AbstractPersistor {
  17. constructor(settings) {
  18. if (settings.storageClass) {
  19. throw new NotImplementedError(
  20. 'Use default bucket class for GCS instead of settings.storageClass'
  21. )
  22. }
  23. super()
  24. this.settings = settings
  25. // endpoint settings will be null by default except for tests
  26. // that's OK - GCS uses the locally-configured service account by default
  27. const storageOptions = {}
  28. if (this.settings.endpoint) {
  29. storageOptions.projectId = this.settings.endpoint.projectId
  30. storageOptions.apiEndpoint = this.settings.endpoint.apiEndpoint
  31. }
  32. storageOptions.retryOptions = { ...this.settings.retryOptions }
  33. if (storageOptions.retryOptions) {
  34. if (storageOptions.retryOptions.idempotencyStrategy) {
  35. const value =
  36. IdempotencyStrategy[this.settings.retryOptions.idempotencyStrategy]
  37. if (value === undefined) {
  38. throw new Error(
  39. 'Unrecognised value for retryOptions.idempotencyStrategy'
  40. )
  41. }
  42. Logger.info(
  43. `Setting retryOptions.idempotencyStrategy to ${storageOptions.retryOptions.idempotencyStrategy} (${value})`
  44. )
  45. storageOptions.retryOptions.idempotencyStrategy = value
  46. }
  47. }
  48. this.storage = new Storage(storageOptions)
  49. }
  50. async sendFile(bucketName, key, fsPath) {
  51. return await this.sendStream(bucketName, key, fs.createReadStream(fsPath))
  52. }
  53. async sendStream(bucketName, key, readStream, opts = {}) {
  54. try {
  55. const observeOptions = {
  56. metric: 'gcs.egress', // egress from us to GCS
  57. bucket: bucketName,
  58. }
  59. let sourceMd5 = opts.sourceMd5
  60. if (!sourceMd5) {
  61. // if there is no supplied md5 hash, we calculate the hash as the data passes through
  62. observeOptions.hash = 'md5'
  63. }
  64. const observer = new PersistorHelper.ObserverStream(observeOptions)
  65. const writeOptions = {
  66. // disabling of resumable uploads is recommended by Google:
  67. resumable: false,
  68. }
  69. if (sourceMd5) {
  70. writeOptions.validation = 'md5'
  71. writeOptions.metadata = writeOptions.metadata || {}
  72. writeOptions.metadata.md5Hash = PersistorHelper.hexToBase64(sourceMd5)
  73. }
  74. if (opts.contentType) {
  75. writeOptions.metadata = writeOptions.metadata || {}
  76. writeOptions.metadata.contentType = opts.contentType
  77. }
  78. if (opts.contentEncoding) {
  79. writeOptions.metadata = writeOptions.metadata || {}
  80. writeOptions.metadata.contentEncoding = opts.contentEncoding
  81. }
  82. const fileOptions = {}
  83. if (opts.ifNoneMatch === '*') {
  84. fileOptions.generation = 0
  85. }
  86. const uploadStream = this.storage
  87. .bucket(bucketName)
  88. .file(key, fileOptions)
  89. .createWriteStream(writeOptions)
  90. await pipeline(readStream, observer, uploadStream)
  91. // if we didn't have an md5 hash, we should compare our computed one with Google's
  92. // as we couldn't tell GCS about it beforehand
  93. if (!sourceMd5) {
  94. sourceMd5 = observer.getHash()
  95. // throws on mismatch
  96. await PersistorHelper.verifyMd5(this, bucketName, key, sourceMd5)
  97. }
  98. } catch (err) {
  99. throw PersistorHelper.wrapError(
  100. err,
  101. 'upload to GCS failed',
  102. { bucketName, key, ifNoneMatch: opts.ifNoneMatch },
  103. WriteError
  104. )
  105. }
  106. }
  107. async getObjectStream(bucketName, key, opts = {}) {
  108. const observer = new PersistorHelper.ObserverStream({
  109. metric: 'gcs.ingress', // ingress to us from GCS
  110. bucket: bucketName,
  111. })
  112. const stream = this.storage
  113. .bucket(bucketName)
  114. .file(key)
  115. .createReadStream({ decompress: false, ...opts })
  116. let contentEncoding
  117. try {
  118. await new Promise((resolve, reject) => {
  119. stream.on('response', res => {
  120. switch (res.statusCode) {
  121. case 200: // full response
  122. case 206: // partial response
  123. contentEncoding = res.headers['content-encoding']
  124. return resolve()
  125. case 404:
  126. return reject(new NotFoundError())
  127. default:
  128. return reject(new Error('non success status: ' + res.statusCode))
  129. }
  130. })
  131. stream.on('error', reject)
  132. stream.read(0) // kick off request
  133. })
  134. } catch (err) {
  135. throw PersistorHelper.wrapError(
  136. err,
  137. 'error reading file from GCS',
  138. { bucketName, key, opts },
  139. ReadError
  140. )
  141. }
  142. // 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).
  143. const pass = new PassThrough()
  144. const transformer = []
  145. if (contentEncoding === 'gzip' && opts.autoGunzip) {
  146. transformer.push(zlib.createGunzip())
  147. }
  148. pipeline(stream, observer, ...transformer, pass).catch(() => {})
  149. return pass
  150. }
  151. async getRedirectUrl(bucketName, key) {
  152. if (this.settings.unsignedUrls) {
  153. // Construct a direct URL to the object download endpoint
  154. // (see https://cloud.google.com/storage/docs/request-endpoints#json-api)
  155. const apiEndpoint =
  156. this.settings.endpoint.apiEndpoint || 'https://storage.googleapis.com'
  157. return `${apiEndpoint}/download/storage/v1/b/${bucketName}/o/${key}?alt=media`
  158. }
  159. try {
  160. const [url] = await this.storage
  161. .bucket(bucketName)
  162. .file(key)
  163. .getSignedUrl({
  164. action: 'read',
  165. expires: Date.now() + this.settings.signedUrlExpiryInMs,
  166. })
  167. return url
  168. } catch (err) {
  169. throw PersistorHelper.wrapError(
  170. err,
  171. 'error generating signed url for GCS file',
  172. { bucketName, key },
  173. ReadError
  174. )
  175. }
  176. }
  177. async getObjectSize(bucketName, key) {
  178. try {
  179. const [metadata] = await this.storage
  180. .bucket(bucketName)
  181. .file(key)
  182. .getMetadata()
  183. return parseInt(metadata.size, 10)
  184. } catch (err) {
  185. throw PersistorHelper.wrapError(
  186. err,
  187. 'error getting size of GCS object',
  188. { bucketName, key },
  189. ReadError
  190. )
  191. }
  192. }
  193. async getObjectMd5Hash(bucketName, key) {
  194. try {
  195. const [metadata] = await this.storage
  196. .bucket(bucketName)
  197. .file(key)
  198. .getMetadata()
  199. return PersistorHelper.base64ToHex(metadata.md5Hash)
  200. } catch (err) {
  201. throw PersistorHelper.wrapError(
  202. err,
  203. 'error getting hash of GCS object',
  204. { bucketName, key },
  205. ReadError
  206. )
  207. }
  208. }
  209. async deleteObject(bucketName, key) {
  210. try {
  211. const file = this.storage.bucket(bucketName).file(key)
  212. if (this.settings.deletedBucketSuffix) {
  213. await file.copy(
  214. this.storage
  215. .bucket(`${bucketName}${this.settings.deletedBucketSuffix}`)
  216. .file(`${key}-${new Date().toISOString()}`)
  217. )
  218. }
  219. if (this.settings.unlockBeforeDelete) {
  220. await file.setMetadata({ eventBasedHold: false })
  221. }
  222. await file.delete()
  223. } catch (err) {
  224. // ignore 404s: it's fine if the file doesn't exist.
  225. if (err.code === 404) {
  226. return
  227. }
  228. throw PersistorHelper.wrapError(
  229. err,
  230. 'error deleting GCS object',
  231. { bucketName, key },
  232. WriteError
  233. )
  234. }
  235. }
  236. async deleteDirectory(bucketName, key) {
  237. const prefix = ensurePrefixIsDirectory(key)
  238. let query = { prefix, autoPaginate: false }
  239. do {
  240. try {
  241. const [files, nextQuery] = await this.storage
  242. .bucket(bucketName)
  243. .getFiles(query)
  244. // iterate over paginated results using the nextQuery returned by getFiles
  245. query = nextQuery
  246. if (Array.isArray(files) && files.length > 0) {
  247. await asyncPool(
  248. this.settings.deleteConcurrency,
  249. files,
  250. async file => {
  251. await this.deleteObject(bucketName, file.name)
  252. }
  253. )
  254. }
  255. } catch (err) {
  256. const error = PersistorHelper.wrapError(
  257. err,
  258. 'failed to delete directory in GCS',
  259. { bucketName, key },
  260. WriteError
  261. )
  262. if (error instanceof NotFoundError) {
  263. return
  264. }
  265. throw error
  266. }
  267. } while (query)
  268. }
  269. async #listDirectory(bucketName, prefix) {
  270. try {
  271. const [response] = await this.storage
  272. .bucket(bucketName)
  273. .getFiles({ prefix })
  274. return response
  275. } catch (err) {
  276. throw PersistorHelper.wrapError(
  277. err,
  278. 'failed to list objects in GCS',
  279. { bucketName, prefix },
  280. ReadError
  281. )
  282. }
  283. }
  284. async directorySize(bucketName, key) {
  285. const prefix = ensurePrefixIsDirectory(key)
  286. const files = await this.#listDirectory(bucketName, prefix)
  287. return files.reduce(
  288. (acc, file) => parseInt(file.metadata.size, 10) + acc,
  289. 0
  290. )
  291. }
  292. async listDirectoryKeys(bucketName, prefix) {
  293. const files = await this.#listDirectory(
  294. bucketName,
  295. ensurePrefixIsDirectory(prefix)
  296. )
  297. return files.map(file => file.name)
  298. }
  299. async listDirectoryStats(bucketName, prefix) {
  300. const files = await this.#listDirectory(
  301. bucketName,
  302. ensurePrefixIsDirectory(prefix)
  303. )
  304. return files.map(file => ({
  305. key: file.name,
  306. size: parseInt(file.metadata.size, 10),
  307. }))
  308. }
  309. async checkIfObjectExists(bucketName, key) {
  310. try {
  311. const [response] = await this.storage
  312. .bucket(bucketName)
  313. .file(key)
  314. .exists()
  315. return response
  316. } catch (err) {
  317. throw PersistorHelper.wrapError(
  318. err,
  319. 'error checking if file exists in GCS',
  320. { bucketName, key },
  321. ReadError
  322. )
  323. }
  324. }
  325. async copyObject(bucketName, sourceKey, destKey) {
  326. try {
  327. const src = this.storage.bucket(bucketName).file(sourceKey)
  328. const dest = this.storage.bucket(bucketName).file(destKey)
  329. await src.copy(dest)
  330. } catch (err) {
  331. // fake-gcs-server has a bug that returns an invalid response when the file does not exist
  332. if (err.message === 'Cannot parse response as JSON: not found\n') {
  333. err.code = 404
  334. }
  335. throw PersistorHelper.wrapError(
  336. err,
  337. 'failed to copy file in GCS',
  338. { bucketName, sourceKey, destKey },
  339. WriteError
  340. )
  341. }
  342. }
  343. }
  344. function ensurePrefixIsDirectory(key) {
  345. return key === '' || key.endsWith('/') ? key : `${key}/`
  346. }