jahed.dev

Creating a Forum with Firebase

So you've decided to create a forum... using Firebase. Let's go through the entire process. This guide assumes you already have some knowledge using Firebase so it's mainly focused around modeling the data to work with Firebase's limited access controls.

Unlike traditional databases, Firebase's Real-time Database is accessed directly from the browser by users. This allows real-time push updates whenever the data changes without the user needing to manually reload.

Data is written individually by users. A user can attempt to write into the database, and the database can say who's allowed to write there and restrict the data with simple rules. For example, "a property can only be changed by User A and it must be a number".

However, due to the nature of Firebase, it cannot enforce complex rules. For example, "you can only add a new post if you haven't created a post in the last minute". This generally requires going through all posts for a given user and finding the timestamp of the most recent one. Not possible in Firebase.

Initial Model

Here's our initial model for a simple forum. I'll be using TypeScript syntax to represent models.

type Timestamp = number;
type UserID = string;
type PostID = string;

type User = {
  id: UserID;
  name: string;
  created_at: Timestamp;
};

type Post = {
  id: PostID;
  createdAt: Timestamp;
  updatedAt: Timestamp;
  userId: UserID;
  body: string;
};

Simple enough.

When did the user last post?

By knowing when the user last posted, we can prevent them from posting until a certain period of time has passed. It avoids spam and encourages users to put more effort into their posts.

In Firebase, there are two solutions to answer this query:

  1. Add a server in the middle for complex access controls
  2. Find an approach that works with existing access control limitations

The first would be a traditional approach. If a database can't provide the feature, do it in the application level. However, it removes a major benefit of Firebase: not having to maintain an application server. To get the full benefits of Firebase, we need to change our mindset and go with the second approach.

If we can't query when the user last created a post, we need to store that information in a single place so that it can be plucked out without querying.

  1. User creates a post
  2. Database checks lastPostTime
  3. User adds post to the Database
  4. User updates lastPostTime in the Database

Will this work? No. Why? We're relying on the user to maintain the lastPostTime. We can't trust the user. They could just as easily skip the final step and post over and over again. The database would be none the wiser.

  1. User makes a postRequest with a createdAt and postId
  2. Database checks the current postRequest.createdAt against the new one.
  3. User updates postRequest in the Database
  4. User creates a post with the postId
  5. Database checks the postId against the current postRequest
  6. User adds post to the Database

With this order, the user is forced to state their action before they do it. If they end up not doing the action (creating a new post), it's on them and they'll need to wait before they can try again.

// ...
type PostRequest = {
  userId: UserID;
  postId: PostID;
  createdAt: Timestamp;
};

Replies

Currently, we only have the ability to create posts. What about replying to exist posts? When creating a reply, we can mark it as such by giving it a parent. A post without a parent is an opening post.

// ...
type Post = {
  // ...
  parentId?: PostID;
};

Timelines

When we want to show the current posts, we don't always want to show all the posts, which includes replies. For a Q&A forum, the question is the entry point, not the discussion. So to do this, we can create a separate list and add a new step.

  1. Create a post (see above for steps involved)
  2. Add the postId to any relevant timelines.

There is a chance that the second step doesn't happen. This can be considered a user choice, to not add a post to a public timeline. It can also be an error, if the user disconnects. Both scenarios can be resolved by allowing the user to add/remove their own posts to/from the timeline at their own discretion.

The database can ensure only certain posts are added by checking the posts' properties. For example, a post can only be added to the opening post timeline if it doesn't have a parentId. For a user timeline however, both opening posts and replies can be allowed, but only for the specific user.

There is an additional benefit to this approach. It prevents users from downloading all the posts at once which is expensive. Instead, we can get a list of postIds and only download the ones in the current view.

// ...
type Timeline = PostID[];

Putting it all together

So, we have our user flow and model. That's pretty much everything. How you implement the client-side flow depends on your application so let's leave it at that. As long as you follow the general flow, it will work out.

Final Model

type Timestamp = number;
type UserID = string;
type PostID = string;

type User = {
  id: UserID;
  name: string;
  created_at: Timestamp;
};

type PostRequest = {
  userId: UserID;
  postId: PostID;
  createdAt: Timestamp;
};

type Post = {
  id: PostID;
  createdAt: Timestamp;
  updatedAt: Timestamp;
  userId: UserID;
  parentId?: PostID;
  body: string;
};

type Timeline = PostID[];

Final Post Flow

  1. User makes a postRequest with a createdAt and postId
  2. Database checks the current postRequest.createdAt against the new one.
  3. User updates postRequest in the Database
  4. User creates a post with the postId, and parentId (if it's a reply)
  5. Database checks the postId against the current postRequest
  6. User adds post to the Database
  7. User adds the postId to any relevant timelines.

Firebase Security Rules

All that's left Firebase-wise is writing up the security rules based on what we've said.

{
  "rules": {
    "postDelay": {
      ".validate": "newData.isNumber()"
    },
    "postRequests": {
      "$userId": {
        ".read": "$userId === auth.uid",
        ".write": "$userId === auth.uid",
        ".validate": "newData.hasChildren(['createdAt']) && (!data.exists() || (data.child('createdAt').val() < (now - root.child('postDelay').val())))",
        "createdAt": {
          ".validate": "newData.val() === now"
        },
        "postId": {
          ".validate": "!root.child('posts/'+newData.val()).exists()"
        },
        "$other": {
          ".validate": false
        }
      }
    },
    "posts": {
      "$postId": {
        ".read": true,
        ".write": "newData.child('userId').val() === auth.uid && root.child('postRequests/'+auth.uid+'/postId').val() === $postId",
        ".validate": "!data.exists() && newData.hasChildren(['createdAt', 'updatedAt', 'userId', 'body'])",
        "createdAt": {
          ".validate": "data.exists() ? newData.val() === data.val() : newData.val() === now"
        },
        "updatedAt": {
          ".validate": "newData.val() === now"
        },
        "userId": {
          ".validate": "data.exists() ? newData.val() === data.val() : root.child('users/'+newData.val()).exists()"
        },
        "parentId": {
          ".validate": "root.child('posts/'+$postId).exists() ? newData.val() === data.val() : !newData.exists() || root.child('posts/'+newData.val()).exists()"
        },
        "body": {
          ".validate": "newData.val().length >= 1 && newData.val().length <= 1023"
        },
        "$other": {
          ".validate": false
        }
      }
    },
    "timeline": {
      ".read": true,
      "$postId": {
        ".write": "root.child('posts/'+$postId+'/userId').val() === auth.uid && !root.child('posts/'+$postId+'/parentId').exists()",
        ".validate": "!data.exists() && newData.val() === now"
      }
    }
  }
}