GcsPersistor.js 10 KB

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