GcsPersistor.js 8.6 KB

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