GcsPersistor.js 9.8 KB

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