Firebase Functions Image Resizing

Cover image

Firebase is a collection of multiple different services available for app developers to build their apps quickly without having to worry too much about the backend, the infrastructure and all. It provides a realtime database, serverless cloud function, a cloud storage for your assets, analytics for all your events, a crash reporting tool, authentication, and many many more. Firebase services are backed by latest Google technology so stability and performance are almost guaranteed. I have used their service for a long time now and I can happily recommend to anybody that wants to quickly have their MVP developed. In my own app users can upload a picture to update their profile picture. The picture they upload can be quite huge in file size but on the screen it’s only 48 by 48 pixels big. I would not want to show a 5 MB picture in that size, it will be such a waste in network bandwidth. Firebase is really nice to provide sample code on how to make this happen.

const functions = require('firebase-functions');
const mkdirp = require('mkdirp');
const admin = require('firebase-admin');
admin.initializeApp();
const spawn = require('child-process-promise').spawn;
const path = require('path');
const os = require('os');
const fs = require('fs');

// Max height and width of the thumbnail in pixels.
const THUMB_MAX_HEIGHT = 200;
const THUMB_MAX_WIDTH = 200;
// Thumbnail prefix added to file names.
const THUMB_PREFIX = 'thumb_';

/**
 * When an image is uploaded in the Storage bucket We generate a thumbnail automatically using
 * ImageMagick.
 * After the thumbnail has been generated and uploaded to Cloud Storage,
 * we write the public URL to the Firebase Realtime Database.
 */
exports.generateThumbnail = functions.storage.object().onFinalize(async (object) => {
  // File and directory paths.
  const filePath = object.name;
  const contentType = object.contentType; // This is the image MIME type
  const fileDir = path.dirname(filePath);
  const fileName = path.basename(filePath);
  const thumbFilePath = path.normalize(path.join(fileDir, `${THUMB_PREFIX}${fileName}`));
  const tempLocalFile = path.join(os.tmpdir(), filePath);
  const tempLocalDir = path.dirname(tempLocalFile);
  const tempLocalThumbFile = path.join(os.tmpdir(), thumbFilePath);

  // Exit if this is triggered on a file that is not an image.
  if (!contentType.startsWith('image/')) {
    return console.log('This is not an image.');
  }

  // Exit if the image is already a thumbnail.
  if (fileName.startsWith(THUMB_PREFIX)) {
    return console.log('Already a Thumbnail.');
  }

  // Cloud Storage files.
  const bucket = admin.storage().bucket(object.bucket);
  const file = bucket.file(filePath);
  const thumbFile = bucket.file(thumbFilePath);
  const metadata = {
    contentType: contentType,
    // To enable Client-side caching you can set the Cache-Control headers here. Uncomment below.
    // 'Cache-Control': 'public,max-age=3600',
  };
  
  // Create the temp directory where the storage file will be downloaded.
  await mkdirp(tempLocalDir)
  // Download file from bucket.
  await file.download({destination: tempLocalFile});
  console.log(‘The file has been downloaded to’, tempLocalFile);
  // Generate a thumbnail using ImageMagick.
  await spawn(‘convert’, [tempLocalFile,-thumbnail’, `${THUMB_MAX_WIDTH}x${THUMB_MAX_HEIGHT}>`, tempLocalThumbFile], {capture: [‘stdout’, ‘stderr’]});
  console.log(‘Thumbnail created at’, tempLocalThumbFile);
  // Uploading the Thumbnail.
  await bucket.upload(tempLocalThumbFile, {destination: thumbFilePath, metadata: metadata});
  console.log(‘Thumbnail uploaded to Storage at’, thumbFilePath);
  // Once the image has been uploaded delete the local files to free up disk space.
  fs.unlinkSync(tempLocalFile);
  fs.unlinkSync(tempLocalThumbFile);
  // Get the Signed URLs for the thumbnail and original image.
  const config = {
    action: ‘read’,
    expires:03-01-2500,
  };
  const results = await Promise.all([
    thumbFile.getSignedUrl(config),
    file.getSignedUrl(config),
  ]);
  console.log(‘Got Signed URLs.);
  const thumbResult = results[0];
  const originalResult = results[1];
  const thumbFileUrl = thumbResult[0];
  const fileUrl = originalResult[0];
  // Add the URLs to the Database
  await admin.database().ref('images').push({path: fileUrl, thumbnail: thumbFileUrl});
  return console.log('Thumbnail URLs saved to database.');
});

The code is taken from functions-samples/generate-thumbnail at master · firebase/functions-samples · GitHub. Firebase cloud functions can act as triggers on certain events and in this case, this function gets called when there is a new item being added to Firebase Storage. The rest of the code is to utilize ImageMagick which is available in Cloud Function environment for you to manipulate your image. After you finish with your image, in this case would be generating a thumbnail, then you upload it back to Firebase Storage, get the url to that image, and put it back to your Firebase Database or Cloud Firestore.

Problem

Everything works great and your app can get the thumbnail that you just created. But this is when I encountered my problem. After roughly a week, those thumbnail urls stop working with an error that is something like 403 permission denied. I have checked all of the settings and I finally found the reasons. In the code there is this line:

const bucket = admin.storage().bucket(object.bucket);

This line allows you to access your Firebase Storage and interact with your files. Behind the scene there is a default service account provided by Firebase. This isn’t usually a problem but later in the stage where you upload the image and retrieve the url, you need to sign the url. The signed url will include a token generated by this service account. It’s not documented anywhere except mentioned in a GitHub issue, but this default service account gets “rotated” every week. Since the service account that originally signed the url with is not available anymore, that link is no longer usable and returns a 403 permission denied error. The solution for me is to create a service account for Firebase to use instead of the default one that is out of our control. First go to Google Cloud Platform and you should be able to see your Firebase project there since Firebase uses the same infrastructure as GCP. On the left hand side on the side menu you should see /Service Accounts/. It shows a list of service accounts that handles your GCP project or in this case is your Firebase project. You can either create a new service account or use the existing ones to generate a key by clicking the actions in the right most column. You should be able to download a JSON file that has all the info about this service account including private key, project id, client id, etc. Assuming you have the file in the same directory as your JavaScript file. We first need to run

npm install @google-cloud/storage

And import it and create a new variable:

const { Storage } = require(“@google-cloud/storage");

const storage = new Storage({
  keyFilename:<path of your JSON file>,
});

We have just created a Storage object that we can use to interface with Firebase Storage using this service account we just created.

// const bucket = admin.storage().bucket(object.bucket);
const bucket = storage.bucket(object.bucket);

It has been over a month since I have made this change the thumbnail is still available. I hope this helps you solve your problem or avoid this problem when you implement this feature for your users.

Cover photo credit goes to unsplash-logoKarl Lee