Make your Firebase Storage files available on upload

Ever uploaded a file to Firebase Storage, only to be hit with a 403 error when trying to access it? Here's how to get a publicly accessible URL for the file you just uploaded.

Make your Firebase Storage files available on upload

Introduction

Remember how we managed to upload remote files to Firebase Storage? This is what it looked like.

import admin from "firebase-admin";
import request from "request";
import Stream from "stream";


export interface FirebaseStorageGatewayInterface {
	uploadNewFile: (id: string, stream: Stream) => Promise<string>;
	uploadNewFileFromUrl: (id: string, url: string) => Promise<string>;
}

const uploadNewFileFromStream = (bucketName: string) => (id: string) => (readStream: Stream) => {
	const fileName = `folder/${id}.jpg`
	const bucket = admin.storage().bucket(bucketName)
	const file = bucket.file(`/${fileName}`);

	const writeStream = file.createWriteStream({
		metadata: {
			contentType: 'image/jpeg'
		}
	})
	
	const result: Promise<string> = new Promise((resolve, reject) => {
		writeStream.on('error', function (err) {
			reject(err);
		});
		writeStream.on('finish', function () {	
			const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${(encodeURI(fileName)).replace(/\//g, "%2F")}`;
			resolve(publicUrl);
    	});
	})

	readStream.pipe(writeStream)
	return result
}

const getStreamFromUrl = (url: string) => {
	return request(url);
}

export default (bucketName: string) => {
	return {
		uploadNewFile: (id: string, stream: Stream) => uploadNewFileFromStream(bucketName)(id)(stream),
		uploadNewSpotPictureFromUrl: (id: string, url: string) => uploadNewFileFromStream(bucketName)(id)(getStreamFromUrl(url))
	}

There's one issue with it, though. It returns the file's URL, for sure. Something along the lines of https://firebasestorage.googleapis.com/v0/b/bucketName/o/fileName.extension. But if you try to access your file through the URL, say by pasting it in your browser, you'll quickly notice there is an issue.

{
  "error": {
    "code": 403,
    "message": "Permission denied. Could not perform this operation"
  }
}
Oups

The issue at hand

If this happens, it means your Firebase Storage bucket's files arent publicly available, this specific file isn't publicly readable, and that the url you used to try to acces the file doesn't contain the required credentials to grant you permission to read it.

Solutions

How do we solve that? How do we make our content accessible using the URL There are three ways.

Get Download URL from file uploaded with Cloud Functions for Firebase
After uploading a file in Firebase Storage with Functions for Firebase, I’d like to get the download url of the file. I have this : ... return bucket .upload(fromFilePath, {destination: toFi...

Make our files publicly available

This, unless we actually want your files to be public, is a bad idea. There have been countless leaks from public AWS S3 buckets, from Netflix (indirectly) to Verizon, often leaking massive amounts of important, private, information and photos. It happens so often that Corey Quinn regurlarly hands out S3 Bucket Negligence Awards in his newsletter. And perhaps we aren't hosting anything sensitive, or even remotely personal, today. But we have no idea what we, or a new dev who won't know any better, will put in there tomorrow.

Make temporary signed URLs

Temporary signed URLs have an expiration date. Hence the name. So we can make it work, if we only need them for a known (and preferably short) period of time, or if we cache them, along with their expiration date, so we can generate new ones when needed, but... This is additional work. And I'd rather not build something I'll later have to maintain and adapt if I don't need to.

Use the frontend SDK to get the 'download URL'

We can also use the frontend JavaScrip SDK to get a 'download URL'. Which means, yes, that we'd have to use both the admin and the client SDKs. Yet another dependency to keep up to date. Another client to setup and authenticate with. Just to get an URL through which our files would be available. So... you can do it, but I'd rather not.

Set a permanent download token

What I ended up doing, and we'll see how it's done shortly, is set a permanent "download token" for the file, in the file's "nested metadata". Just like the Firebase Storage console does it.

import admin from "firebase-admin";
import request from "request";
import Stream from "stream";
import { v4 } from "uuid";


export interface FirebaseStorageGatewayInterface {
	uploadNewFile: (id: string, stream: Stream) => Promise<string>;
	uploadNewFileFromUrl: (id: string, url: string) => Promise<string>;
}

const uploadNewFileFromStream = (bucketName: string) => (id: string, setDownloadToken: boolean = false) => (readStream: Stream) => {
	const fileName = `folder/${id}.jpg`
	const bucket = admin.storage().bucket(bucketName);
	const file = bucket.file(`/${fileName}`);
    
    // Create a random download token, using a v4 uuid, but that's just personal preference
	const downloadToken = setDownloadToken ? v4 : null;

	const writeStream = file.createWriteStream({
		metadata: {
			contentType: 'image/jpeg',
            
            // Set the download token in the file's metadata's metadata. Because we're using object destructuring, the property won't be set if it's null
            ...(() => setDownloadToken ? null : {
            	metadata: {
                	firebaseStorageDownloadTokens: downloadToken
                }
            })()
		}
	})
	
	const result: Promise<string> = new Promise((resolve, reject) => {
		writeStream.on('error', function (err) {
			reject(err);
		});
		writeStream.on('finish', function () {	
			const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${(encodeURI(fileName)).replace(/\//g, "%2F")}?alt=media&token=${downloadToken}`;
			resolve(publicUrl);
    	});
	})

	readStream.pipe(writeStream)
	return result
}

const getStreamFromUrl = (url: string) => {
	return request(url);
}

export default (bucketName: string) => {
	return {
		uploadNewFile: (id: string, stream: Stream) => uploadNewFileFromStream(bucketName)(id)(stream),
		uploadNewSpotPictureFromUrl: (id: string, url: string) => uploadNewFileFromStream(bucketName)(id)(getStreamFromUrl(url))
	}

If you want to know more about destructuring in Javascript, please check this out! :-)

Conclusion

Well, there are many solutions to this problem. This is merely the one that I felt had the least potential for trouble and maintenance requirements in the future. I hope you find it helpful, and wish you all a great day!