GcsPersistor.js 8.6 KB

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