diff --git a/etc/firebase-admin.storage.api.md b/etc/firebase-admin.storage.api.md index 204c033a6b..f4859bc5f1 100644 --- a/etc/firebase-admin.storage.api.md +++ b/etc/firebase-admin.storage.api.md @@ -8,6 +8,10 @@ import { Agent } from 'http'; import { Bucket } from '@google-cloud/storage'; +import { File } from '@google-cloud/storage'; + +// @public +export function getDownloadUrl(file: File): Promise; // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts // diff --git a/src/storage/index.ts b/src/storage/index.ts index bbab751584..b016aec27d 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -20,12 +20,16 @@ * @packageDocumentation */ +import { File } from '@google-cloud/storage'; import { App, getApp } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { Storage } from './storage'; +import { FirebaseError } from '../utils/error'; +import { getFirebaseMetadata } from './utils'; export { Storage } from './storage'; + /** * Gets the {@link Storage} service for the default app or a given app. * @@ -53,3 +57,34 @@ export function getStorage(app?: App): Storage { const firebaseApp: FirebaseApp = app as FirebaseApp; return firebaseApp.getOrInitService('storage', (app) => new Storage(app)); } + + + +/** + * Gets the download URL for the given {@link @google-cloud/storage#File}. + * + * @example + * ```javascript + * // Get the downloadUrl for a given file ref + * const storage = getStorage(); + * const myRef = ref(storage, 'images/mountains.jpg'); + * const downloadUrl = await getDownloadUrl(myRef); + * ``` + */ +export async function getDownloadUrl(file: File): Promise { + const endpoint = + (process.env.STORAGE_EMULATOR_HOST || + 'https://firebasestorage.googleapis.com') + '/v0'; + const { downloadTokens } = await getFirebaseMetadata(endpoint, file); + if (!downloadTokens) { + throw new FirebaseError({ + code: 'storage/no-download-token', + message: + 'No download token available. Please create one in the Firebase Console.', + }); + } + const [token] = downloadTokens.split(','); + return `${endpoint}/b/${file.bucket.name}/o/${encodeURIComponent( + file.name + )}?alt=media&token=${token}`; +} diff --git a/src/storage/storage.ts b/src/storage/storage.ts index bf1fdfb790..6e155b379e 100644 --- a/src/storage/storage.ts +++ b/src/storage/storage.ts @@ -117,7 +117,6 @@ export class Storage { 'explicitly when calling the getBucket() method.', }); } - /** * Optional app whose `Storage` service to * return. If not provided, the default `Storage` service will be returned. diff --git a/src/storage/utils.ts b/src/storage/utils.ts new file mode 100644 index 0000000000..bb6711521b --- /dev/null +++ b/src/storage/utils.ts @@ -0,0 +1,43 @@ +import { File } from '@google-cloud/storage'; +export interface FirebaseMetadata { + name: string; + bucket: string; + generation: string; + metageneration: string; + contentType: string; + timeCreated: string; + updated: string; + storageClass: string; + size: string; + md5Hash: string; + contentEncoding: string; + contentDisposition: string; + crc32c: string; + etag: string; + downloadTokens?: string; +} + +export function getFirebaseMetadata( + endpoint: string, + file: File +): Promise { + const uri = `${endpoint}/b/${file.bucket.name}/o/${encodeURIComponent( + file.name + )}`; + + return new Promise((resolve, reject) => { + file.storage.makeAuthenticatedRequest( + { + method: 'GET', + uri, + }, + (err, body) => { + if (err) { + reject(err); + } else { + resolve(body); + } + } + ); + }); +} diff --git a/test/integration/storage.spec.ts b/test/integration/storage.spec.ts index f7467ac9fc..0dd4045468 100644 --- a/test/integration/storage.spec.ts +++ b/test/integration/storage.spec.ts @@ -19,7 +19,9 @@ import * as chaiAsPromised from 'chai-as-promised'; import { Bucket, File } from '@google-cloud/storage'; import { projectId } from './setup'; -import { getStorage } from '../../lib/storage/index'; +import { getDownloadUrl, getStorage } from '../../lib/storage/index'; +import { getFirebaseMetadata } from '../../src/storage/utils'; +import { FirebaseError } from '../../src/utils/error'; chai.should(); chai.use(chaiAsPromised); @@ -27,6 +29,13 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('admin.storage', () => { + let currentRef: File | null = null; + afterEach(async () => { + if (currentRef) { + await currentRef.delete(); + } + currentRef = null; + }); it('bucket() returns a handle to the default bucket', () => { const bucket: Bucket = getStorage().bucket(); return verifyBucket(bucket, 'storage().bucket()') @@ -39,6 +48,35 @@ describe('admin.storage', () => { .should.eventually.be.fulfilled; }); + it('getDownloadUrl returns a download URL', async () => { + const bucket = getStorage().bucket(projectId + '.appspot.com'); + currentRef = await verifyBucketDownloadUrl(bucket, 'testName'); + // Note: For now, this generates a download token when needed, but in the future it may not. + const metadata = await getFirebaseMetadata( + 'https://firebasestorage.googleapis.com/v0', + currentRef + ); + if (!metadata.downloadTokens) { + expect(getDownloadUrl(currentRef)).to.eventually.throw( + new FirebaseError({ + code: 'storage/invalid-argument', + message: + 'Bucket name not specified or invalid. Specify a valid bucket name via the ' + + 'storageBucket option when initializing the app, or specify the bucket name ' + + 'explicitly when calling the getBucket() method.', + }) + ); + return; + } + const downloadUrl = await getDownloadUrl(currentRef); + + const [token] = metadata.downloadTokens.split(','); + const storageEndpoint = `https://firebasestorage.googleapis.com/v0/b/${ + bucket.name + }/o/${encodeURIComponent(currentRef.name)}?alt=media&token=${token}`; + expect(downloadUrl).to.equal(storageEndpoint); + }); + it('bucket(non-existing) returns a handle which can be queried for existence', () => { const bucket: Bucket = getStorage().bucket('non.existing'); return bucket.exists() @@ -46,6 +84,7 @@ describe('admin.storage', () => { expect(data[0]).to.be.false; }); }); + }); function verifyBucket(bucket: Bucket, testName: string): Promise { @@ -66,3 +105,10 @@ function verifyBucket(bucket: Bucket, testName: string): Promise { expect(data[0], 'File not deleted').to.be.false; }); } + +async function verifyBucketDownloadUrl(bucket: Bucket, testName: string): Promise { + const expected: string = 'Hello World: ' + testName; + const file: File = bucket.file('data_' + Date.now() + '.txt'); + await file.save(expected) + return file; +} diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index 4239f6eebf..a528dd5497 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -104,8 +104,8 @@ export class MockComputeEngineCredential extends ComputeEngineCredential { } } -export function app(): FirebaseApp { - return new FirebaseApp(appOptions, appName); +export function app(altName?: string): FirebaseApp { + return new FirebaseApp(appOptions, altName || appName); } export function mockCredentialApp(): FirebaseApp { diff --git a/test/unit/storage/index.spec.ts b/test/unit/storage/index.spec.ts index 8251207677..5671e31f4e 100644 --- a/test/unit/storage/index.spec.ts +++ b/test/unit/storage/index.spec.ts @@ -19,11 +19,13 @@ import * as chai from 'chai'; import * as sinonChai from 'sinon-chai'; +import { createSandbox, SinonSandbox } from 'sinon'; import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import { App } from '../../../src/app/index'; -import { getStorage, Storage } from '../../../src/storage/index'; +import * as StorageUtils from '../../../src/storage/utils'; +import { getStorage, Storage, getDownloadUrl } from '../../../src/storage/index'; chai.should(); chai.use(sinonChai); @@ -35,13 +37,19 @@ describe('Storage', () => { let mockApp: App; let mockCredentialApp: App; - const noProjectIdError = 'Failed to initialize Google Cloud Storage client with the ' - + 'available credential. Must initialize the SDK with a certificate credential or ' - + 'application default credentials to use Cloud Storage API.'; + const noProjectIdError = + 'Failed to initialize Google Cloud Storage client with the ' + + 'available credential. Must initialize the SDK with a certificate credential or ' + + 'application default credentials to use Cloud Storage API.'; + let sandbox: SinonSandbox; beforeEach(() => { mockApp = mocks.app(); mockCredentialApp = mocks.mockCredentialApp(); + sandbox = createSandbox(); + }); + afterEach(() => { + sandbox.restore(); }); describe('getStorage()', () => { @@ -69,5 +77,72 @@ describe('Storage', () => { const storage2: Storage = getStorage(mockApp); expect(storage1).to.equal(storage2); }); + + it('should return an error when no metadata is available', async () => { + sandbox + .stub(StorageUtils, 'getFirebaseMetadata') + .returns(Promise.resolve({} as StorageUtils.FirebaseMetadata)); + const storage1 = getStorage(mockApp); + const fileRef = storage1.bucket('gs://mock').file('abc'); + await expect(getDownloadUrl(fileRef)).to.be.rejectedWith( + 'No download token available. Please create one in the Firebase Console.' + ); + }); + + it('should return an error when unable to fetch metadata', async () => { + const error = new Error('Could not get metadata'); + sandbox + .stub(StorageUtils, 'getFirebaseMetadata') + .returns(Promise.reject(error)); + const storage1 = getStorage(mockApp); + const fileRef = storage1.bucket('gs://mock').file('abc'); + await expect(getDownloadUrl(fileRef)).to.be.rejectedWith( + error + ); + }); + it('should return the proper download url when metadata is available', async () => { + const downloadTokens = ['abc', 'def']; + sandbox + .stub(StorageUtils, 'getFirebaseMetadata') + .returns( + Promise.resolve({ + downloadTokens: downloadTokens.join(','), + } as StorageUtils.FirebaseMetadata) + ); + const storage1 = getStorage(mockApp); + const fileRef = storage1.bucket('gs://mock').file('abc'); + await expect(getDownloadUrl(fileRef)).to.eventually.eq( + `https://firebasestorage.googleapis.com/v0/b/${fileRef.bucket.name}/o/${encodeURIComponent(fileRef.name)}?alt=media&token=${downloadTokens[0]}` + ); + }); + it('should use the emulator host name when either envs are set', async () => { + const HOST = 'localhost:9091'; + const envsToCheck = [ + { envName: 'FIREBASE_STORAGE_EMULATOR_HOST', value: HOST }, + { envName: 'STORAGE_EMULATOR_HOST', value: `http://${HOST}` }, + ]; + const downloadTokens = ['abc', 'def']; + sandbox.stub(StorageUtils, 'getFirebaseMetadata').returns( + Promise.resolve({ + downloadTokens: downloadTokens.join(','), + } as StorageUtils.FirebaseMetadata) + ); + for (const { envName, value } of envsToCheck) { + + delete process.env.STORAGE_EMULATOR_HOST; + delete process.env[envName]; + process.env[envName] = value; + + // Need to create a new mock app to force `getStorage`'s checking of env vars. + const storage1 = getStorage(mocks.app(envName)); + const fileRef = storage1.bucket('gs://mock').file('abc'); + await expect(getDownloadUrl(fileRef)).to.eventually.eq( + `http://${HOST}/v0/b/${fileRef.bucket.name}/o/${encodeURIComponent( + fileRef.name + )}?alt=media&token=${downloadTokens[0]}` + ); + delete process.env[envName]; + } + }); }); });