Limiting the Firebase Storage space used by each customer

Limiting the amount of Storage space used by your customers can be tricky. In this article, we show how to set a quota for each of your customers.

Limiting the disk space used by an organization or a user is a popular tier restriction used in SaaS. The reason is simple: the more space your customers use, the more they pay.

In this article, we learn how to keep track of the disk space used in Firebase Storage and how to prevent your customers to go over their plan limits.

Setting up Firebase Functions

First, we need to setup the Firebase Pubsub emulator by running the following command:

firebase setup:emulators:pubsub

Now, we need to setup the Firebase Functions emulators:

firebase init functions

After completing the CLI prompts, you should have a few more files in your project:

✔ Wrote functions/package.json
✔ Wrote functions/tsconfig.json
✔ Wrote functions/src/index.ts
✔ Wrote functions/.gitignore

Also, it will generate other files such as tsconfig.json and package.json Let's copy some of the generated commands to the root package.json:

"build:functions": "tsc -P functions/tsconfig.json",
"build:functions:watch": "tsc -P functions/tsconfig.json --watch",
"deploy:functions": "firebase deploy --only functions"

Let's run the Typescript watcher to build the functions:

npm run build:functions:watch

If you are using Makerkit, run the emulators with the following command:

npm run firebase:emulators:start

And that's it! If everything's good, you will see in the console the following output:

✔ functions: Loaded functions definitions from source: helloWorld.

Tracking space with Cloud Storage triggers

To track how much disk space a user or an organization is using, we will write a Cloud Storage trigger that receives a request each time an item is uploaded or deleted.

To understand which user (or which organization) is uploading the item, we will look at the path of the file. For example, we know that an organization's folder is at the path organizations/${organizationId}.

The simplest Cloud Storage trigger we can write is the following function:

import { storage } from 'firebase-functions';
export const onItemCreated =
storage.object().onFinalize(async (object) => {
console.log(object);
});

Our strategy is the following:

  1. Collect the size of the item uploaded
  2. Update the user/organization record with the size of each item uploaded
  3. Use Storage Security Rules to prevent writes above an organization's quota

Updating an Organization's record with the item's size

Let's continue writing our function to collect the item's size, and then we proceed to update the Firestore record of the organization by summing the disk space used.

Initializing the Firebase Admin

First, we want to initialize the Firebase Admin. Below, it's a simple function to initialize the admin in the emulator:

async function initializeFirebaseAdminApp() {
const projectId = process.env.GCLOUD_PROJECT;
const options: AppOptions = { projectId };
const { getApps, getApp, initializeApp }
= await import('firebase-admin/app');
if (getApps().length) {
return getApp();
}
return initializeApp(options);
}

Increasing Storage used when an object is uploaded

Now, we can continue writing the logic to increase the storage space counter:

import { storage } from 'firebase-functions';
import { getFirestore, DocumentReference } from 'firebase-admin/firestore';
import { AppOptions } from 'firebase-admin/app';
export const onItemCreated = storage
.object()
.onFinalize(async (object) => {
const paths = object.name?.split('/');
if (!paths || paths[0] !== 'organizations') {
return;
}
// we are aware that the path is /organizations/{uid}/{filename}
const organizationId = paths[1];
// convert size to a number
const size = Number(object.size);
try {
// initialize admin so we can access firestore
await initializeFirebaseAdminApp();
const firestore = getFirestore();
// we get the document reference in Firestore
const documentPath = `organizations/${organizationId}`;
const organizationRef = firestore.doc(documentPath);
// get current space used and we sum with the object's size
const currentStorageSpaceUsed =
await getCurrentSpaceUsed(organizationRef);
const storageSpaceUsed = currentStorageSpaceUsed + size;
// update the Firestore document with the new "storageSpaceUsed"
await organizationRef.update({
storageSpaceUsed,
});
console.log(`Organization successfully updated`);
} catch (e) {
console.error(e);
}
});
function getCurrentSpaceUsed(organizationRef: DocumentReference) {
return organizationRef
.get()
.then((ref) => ref.data())
.then((organization) => organization?.storageSpaceUsed ?? 0);
}

Decreasing Storage used when an object is uploaded

The process for decreasing the storage space used is very similar to the above function, with the difference that we need to create a new API function to listen to Storage triggers when an item is deleted.

As you can imagine, the code could be greatly simplified given its similarity, but for simplicity reasons we're showing by repeating the same procedures:

export const onItemDeleted = storage.object().onDelete(async (object) => {
const paths = object.name?.split('/');
if (!paths || paths[0] !== 'organizations') {
return;
}
const organizationId = paths[1];
const size = Number(object.size);
try {
await initializeFirebaseAdminApp();
const firestore = getFirestore();
const documentPath = `organizations/${organizationId}`;
const organizationRef = firestore.doc(documentPath);
const currentStorageSpaceUsed = await getCurrentSpaceUsed(organizationRef);
const storageSpaceUsed = currentStorageSpaceUsed - size;
await organizationRef.update({
storageSpaceUsed,
});
console.log(`Organization successfully updated`);
} catch (e) {
console.error(e);
}
});

Using Firestore Storage Rules to reject requests above the quota

Now that we know how much Storage space used by each organization, we can accept or reject new file uploads using the Storage Security rules:

function isMember(organizationId) {
if getUserId() in getOrganization(organizationId).data
.members
}
function getSpaceUsed(organizationId) {
return getOrganization(organizationId).data
.storageSpaceUsed
}
match /organizations/{organizationId}/{fileName=**} {
match /organizations/{organizationId}/{fileName=**} {
allow create, update: if isMember(organizationId) && getSpaceUsed(organizationId) < 4000;
allow read, delete: if isMember(organizationId);
}
}

Thanks to cross-service communication, we can access Firestore data from our Storage rules, which makes it easy to write conditions against data in an organization. In this case, we check that the property storageSpaceUsed is less than a specific quota.