Using Fauna with Gatsby Serverless Functions: Part Two

Rodney Lab - Sep 1 '21 - - Dev Community

Blog Post Comments

This is the second part in a series of articles in which you see how you can use a Fauna database coupled with Gatsby serverless functions to provide a comment system for a blog. The database can scale making it equally suitable for a hobby blog which receives only occasional comments and a more professional one. In the first post, focussing on user experience, we:

  • set up a Fauna account
  • built out a demo static blog site
  • added features to allow users to add new comments to blog posts and also see comments left by other site visitors.

In this follow up we use Fauna to create a backend to aid anyone responsible for maintaining the site content. We will see how you can:

  • use Fauna to authorize users and get an authorization secret which we use in a session token,
  • verify a user with Fauna before performing actions in a serverless function,
  • update database documents in Fauna.

We will create a special comments dashboard. Only registered users will be able to log into the comments dashboard, using their email and password. Once logged in, users will be able to mark comments as spam or remove a spam flag from a comment — this will be helpful if our spam filter incorrectly classifies a comment. On top we will let authorized users delete messages, so they do not appear to regular users. We will lean heavily on Gatsby serverless functions to add these features. If you are ready to see how to do all of that, then let's start by creating a new users collection in our database. Note that you will need to complete steps in the previous article if you want to follow along.

Fauna user Collection Setup

The first thing we will do is create a new users collection to store authenticated user emails. When we created the comments collection (in the previous article), we used the dashboard user interface. For our new users collection, we will use the Fauna Shell, just so you can see an alternative way of creating a collection. You might remember we also used the Shell to create our index in the last article. The process we follow here is similar. Start by logging into the Fauna dashboard, if you are not already logged in. Next find the gatsby-serverless-functions-comments database and click to open it up. From the menu on the left, select Shell.

Screen capture of Fauna Shell showing menu on left and two panes stacked vertically on the right

The main window has two panes. Paste this code into the bottom pane (replacing any existing content). This will create a new collection called users:

CreateCollection({ name: "users" })
Enter fullscreen mode Exit fullscreen mode

As an alternative you can use the Fauna CLI to execute these commands from your computer Terminal, though we will continue using shell from the Fauna dashboard.

Fauna users_by_email Index

Now we will create an index for the users collection. Like the get_comments index we created earlier, this index is used in a client query to filter values from the collection, only returning the data the client is looking for. Let's create the users_by_email index by pasting this code into the Fauna Shell:

CreateIndex({
  name: "users_by_email",
  // permissions: { read: "public"},
  source: Collection("users"),
  terms: [{field: ["data", "email"]}],
  unique: true,
})
Enter fullscreen mode Exit fullscreen mode

If you are are reusing this code for a client application, rather than a serverless one, you will probably want to make the index public readable. In our case, since we will log in our user with an authorized serverless function, we do not need to make the index public.

Ultimately, you will add all authorized user details to this users collection. When a user tries to log in via the serverless function (which we will create), we will check their email is in the users collection and then Fauna will hash the user's provided password and compare that hashed value to the hashed value stored by Fauna. We never store the user password itself. This improves security.

Creating a Fauna User

Next create our first user in the Fauna Shell:

Create(
  Collection("users"),
  {
    credentials: { password: "enter_password_here" },
    data: {
      email: "blake@example.com",
    },
  }
)
Enter fullscreen mode Exit fullscreen mode

Don't forget to change the email and add a strong password in place of the placeholder text. Store your credentials in a password manager as you will be using them shortly for testing.

Finally do a test login in the console by pasting in this command (swap the credentials below for the ones you just entered):

Login(
  Match(Index("users_by_email"), "blake@example.com"),
  { password: "enter_password_here" },
)
Enter fullscreen mode Exit fullscreen mode

If all went well, you should see a response something like this:

Login(
  Match(Index("users_by_email"), "blake@site.example"),
    { password: "your_strong_password" },
)

{
  ref: Ref(Ref("tokens"), "306735031977508908"),
  ts: 1628784171956000,
  instance: Ref(Collection("users"), "306646718418518308"),
  secret: "fnAaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxY"
}
>> Time elapsed: 68ms
Enter fullscreen mode Exit fullscreen mode

The secret near the bottom, is the token we will use in the client to authenticate the user. In our app however, we will call this Login method from our Gatsby serverless function. The serverless function then returns the token to the client, so it can be used for authentication in subsequent operations. Speaking of the client, let's build the front end.

Comments Dashboard Front End

We are going to create a private Comments Dashboard, which is not accessible to regular site visitors. From the dashboard, authorized users will be able to change comment spam flags, delete comments and trigger site rebuilds.

User Login

We will start with a basic front end and then add functionality and related serverless functions one by one. Let's start by creating a login page. Open up the project in your code editor and make a new folder src/pages/comments-dashboard. Create a new login.jsx file for the login page in that folder and add the following content:

import axios from 'axios';
import { graphql, navigate } from 'gatsby';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useForm } from 'react-hook-form';
import FormInput from '../../components/FormInput';
import {
  getSessionStorageOrDefault,
  isBrowser,
  setSessionStorage,
} from '../../utilities/utilities';
import {
  container,
  content,
  formButton,
  formContainer,
  formError,
  formInput,
} from './login.module.scss';

export default function CommentsDashboardLogin({ data }) {
  const [serverState, setServerState] = useState({ ok: true, message: '' });
  const [sessionSecret, setSessionSecret] = useState(getSessionStorageOrDefault('token', false));
  const [submitting, setSubmitting] = useState(false);
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  useEffect(() => {
    setSessionStorage('token', sessionSecret);
  }, [sessionSecret]);

  const handleServerResponse = (ok, message) => {
    setServerState({ ok, message });
  };

  const onSubmit = async (formData, event) => {
    try {
      setSubmitting(true);
      const { Email: email, Password: password } = formData;
      const response = await axios({
        url: '/api/db-login',
        method: 'POST',
        data: {
          email,
          password,
        },
      });
      const { secret } = response.data;
      setSessionSecret(secret);
      event.target.reset();
      navigate('/comments-dashboard/');
    } catch (error) {
      handleServerResponse(false, 'There was an error logging in.  Please try again.');
    }
    setSubmitting(false);
  };

  const emailRegex =
    /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

  if (sessionSecret && isBrowser) {
    navigate('/comments-dashboard/');
  }

  const { siteLanguage } = data.site.siteMetadata;

  return (
    <>
      <Helmet title="Comments dashboard login" htmlAttributes={{ lang: siteLanguage }} />
      <Helmet>
        <meta name="robots" content="noindex, nofollow" />
      </Helmet>
      <main className={container}>
        <div className={content}>
          <h1>Log In</h1>
          <form className={formContainer} onSubmit={handleSubmit(onSubmit)}>
            <h2>Log in to the Comments dashboard:</h2>
            <div className={formInput}>
              <FormInput
                ariaInvalid={!!errors.Email}
                ariaLabel="Enter your email address"
                id="user-email"
                label="Email"
                maxLength={64}
                pattern={emailRegex}
                register={register}
                required
              />
              {errors.Email ? (
                <span id="user-email-error" className={formError}>
                  <small>Please check your email address.</small>
                </span>
              ) : null}
            </div>
            <div className={formInput}>
              <FormInput
                ariaInvalid={!!errors.Password}
                ariaLabel="Enter your password"
                id="user-password"
                label="Password"
                maxLength={72}
                register={register}
                type="password"
                required
              />
              {errors.Password ? (
                <span className={formError}>
                  <small>Please enter your password.</small>
                </span>
              ) : null}
            </div>
            <div className={formButton}>
              <input type="submit" aria-disabled={submitting} disabled={submitting} value="Login" />
              {serverState.message ? (
                <small className={serverState.ok ? '' : formError}>{serverState.message}</small>
              ) : null}
            </div>
          </form>
        </div>
      </main>
    </>
  );
}

CommentsDashboardLogin.propTypes = {
  data: PropTypes.shape({
    site: PropTypes.shape({
      siteMetadata: PropTypes.shape({
        siteLanguage: PropTypes.string,
      }),
    }),
  }).isRequired,
};

export const query = graphql`
  query commentsDashboardLoginQuery {
    site {
      siteMetadata {
        siteLanguage
      }
    }
  }
`;

Enter fullscreen mode Exit fullscreen mode

This will be a private page so we add a meta robots tags with the noindex and nofollow directives set. This discourages search engines from indexing the page. We also add a page title and set the HTML lang attribute for the page. This is to improve accessibility. The page won't work just yet. Before completing it let's have a look at Session storage, used in this file.

About Session Storage

Typically the user will log in and then perform a few operations, for example remove a spam flag from three posts. It would be poor user experience if they had to log in (providing email and password) before performing each one of these operations. The secret token helps here. Once the user log in, we make a local note of it.

Once we have the user's token stored, when they need to perform an operation, we send the token along with the details of the operation. The serverless function then only performs the requested operation if the token is valid. We store the token in Session Storage. This is similar to local storage, which you are probably already familiar with. The difference is that session storage is cleared when the page session ends. This means the when the user closes the browser tab or the browser itself, the token disappears. The user has to login once more if they want to access the comments dashboard. Let's look at how we can implement it.

Session Storage in React

We have a sessionSecret variable in our component's state. Initially we set it to a value of false. When the user logs in successfully, the onSubmit method sets this state variable. Finally we have this useEffect hook:

  useEffect(() => {
    setSessionStorage('token', sessionSecret);
  }, [sessionSecret]);
Enter fullscreen mode Exit fullscreen mode

As with any React useEffect hook, the function runs whenever the variable in the square brackets in the last line changes. In our case, this is when the onSubmit method updates the sessionSecret state variable. This hook's only function is to call a utility function which stores the new token to session storage.

Session storage will not be the ideal solution for every use case. The token is accessible to other JavaScript running in the same tab. A more secure alternative implementation might be to store the token in a cookie sent via an HTTP response header from a login serverless function. That cookie could be sent with the HTTPOnly attribute meaning it is inaccessible to JavaScript code running in the browser. We won't look at that approach in detail here.

Session Storage Utility Functions

Let's code up that utility functions now, edit src/utilities/utilities.js so it looks like this:

export const isBrowser = typeof window !== 'undefined';
export const isProduction = process.env.NODE_ENV === 'production';

export function getSessionStorageOrDefault(key, defaultValue) {
  if (isBrowser) {
    const stored = sessionStorage.getItem(key);
    if (!stored) {
      return defaultValue;
    }
    return JSON.parse(stored);
  }
  return defaultValue;
}

export function setSessionStorage(key, value) {
  if (isBrowser) {
    sessionStorage.setItem(key, JSON.stringify(value));
  }
}
Enter fullscreen mode Exit fullscreen mode

In Gatsby, we need to check the code is running in the client browser (and not the build server) when using the sessionStorage API. That is just because it will not be defined on the build server and the build will fail when the server executes this line.

Serverless Login Function

Let's go server side now and create a the login Gatsby Serverless function in src/api.db-login.js:

import faunadb from 'faunadb';

const dbLogin = async ({ email, password }) => {
  try {
    const client = new faunadb.Client({
      secret: process.env.FAUNA_SECRET,
      domain: 'db.us.fauna.com',
      scheme: 'https',
    });
    const q = faunadb.query;
    const response = await client.query(
      q.Login(q.Match(q.Index('users_by_email'), email), { password }),
    );
    const { secret } = response;
    return { successful: true, secret };
  } catch (error) {
    return { successful: false, message: error.message };
  }
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    res.status(405).send('Method not allowed');
  } else {
    const { email, password } = req.body;
    const dbloginResult = await dbLogin({ email, password });
    if (!dbloginResult.successful) {
      res.status(400).send('Error logging in.');
    } else {
      res.status(200).json(dbloginResult);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In the dbLogin function, as before, we create a Fauna client, which lets us perform the operation we need (login in this case). Remember to change the domain to match the region selected when you created your database. The Login method which we call is what generates the secret that we need to send back to the user. The first part of the function call is a match statement which uses the index we just created to generate a reference. The second part is just the user password, this will be hashed and them compared to the hashed value of the password stored by Fauna. You can learn more about the Login function, for example limiting the validity of the session secret in the Fauna docs.

Note that we are able to return JSON objects as well as string messages from out Gatsby Serverless function.

Screen capture showing Session storage in Firefox. The user token is displayed

Try logging in on the front end, using the credentials you created earlier. If the login is successful, nothing interesting will happen in the browser window itself yet (we still have a little more to implement). However open Developer Tools (in Firefox go to Tools menu then Browser Tools and finally Web Developer Tools or View, Developer, Developer Tools in Chrome). Open up Storage (Application in Chrome) and within Session Storage you should see your token created by the serverless function, store in the browser. This is stored unencrypted, just like a JSON Web Token would be, a JWT adds a mechanism for token validation. We will use Fauna for validation.

Let's build out the comments console now. From there authorized users will be able to delete comments, change spam flags and even trigger a site rebuild.

Comments Dashboard React Component

We will start with a basic shell and build out the features one by one, first in the front end and then adding the new Gatsby Serverless function for the feature. To get going create a new style file: src/pages/comments-dashboard/index.jsx and paste in the following code:

Now let's create the React code for page:

import axios from 'axios';
import dayjs from 'dayjs';
import 'dayjs/locale/en-gb';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import { graphql, Link, navigate } from 'gatsby';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import Card from '../../components/Card';
import {
  FlagIcon,
  LogOutIcon,
  ToggleLeftIcon,
  ToggleRightIcon,
  TrashIcon,
  UploadCloudIcon,
} from '../../components/Icons';
import { M_SPACE_ENTITY } from '../../constants/entities';
import {
  getSessionStorageOrDefault,
  isBrowser,
  setSessionStorage,
} from '../../utilities/utilities';
import {
  buttonContent,
  commentFooter,
  commentHeader,
  container,
  content,
  dateText,
  headerContent,
  headerTitle,
  rebuildContainer,
  rebuildContent,
  title,
} from './index.module.scss';

dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);
dayjs.locale('en-gb');

export default function CommentsDashboard({ data }) {
  const [comments, setComments] = useState([]);
  const [databaseUpdated, setDatabaseUpdated] = useState(false);
  const [loggingOut, setLoggingOut] = useState(false);
  const [rebuildMessage, setRebuildMessage] = useState('');
  const [sessionSecret, setSessionSecret] = useState(getSessionStorageOrDefault('token', false));
  const [showSpam, setShowSpam] = useState(true)

  if (!sessionSecret && isBrowser) {
    navigate('/comments-dashboard/login');
  }

  const getComments = async () => {
    try {
      const response = await axios({
        url: '/api/get-comments',
        method: 'POST',
        data: {
          token: sessionSecret,
          showSpam,
        },
      });
      const { comments: fetchedComments } = response.data;
      setComments(fetchedComments);
    } catch (error) {
      console.log(error);
    }
  };

  const deleteComment = async ({ commentId }) => { };

  const logout = async () => { };

  const rebuild = async () => { };

  const toggleMarkedSpam = async ({ commentId }) => { };

  useEffect(() => {
    setSessionStorage('token', sessionSecret);
  }, [sessionSecret]);

  useEffect(async () => {
    if (sessionSecret) {
      await getComments();
    }
  }, [sessionSecret, showSpam]);

  const slugs = Object.keys(comments);
  const { siteLanguage } = data.site.siteMetadata;

  return (
    <>
      <Helmet title="Comments dashboard" htmlAttributes={{ lang: siteLanguage }} />
      <Helmet>
        <meta name="robots" content="noindex" />
      </Helmet>
      <div className={container}>
        <header>
          <div className={headerContent}>
            <h1 className={headerTitle}>Comments Console</h1>
            <button type="button" onClick={logout}>
              <span className={buttonContent}>
                Log out{M_SPACE_ENTITY}
                <LogOutIcon />
              </span>
            </button>
          </div>
        </header>
        <main className={content}>
          <div className={rebuildContainer}>
            {databaseUpdated ? (
              <div className={rebuildContent}>
                {rebuildMessage === '' ? (
                  <>
                    Rebuild the site to reflect recent changes?
                    <button type="button" onClick={rebuild}>
                      <span className={buttonContent}>
                        Rebuild{M_SPACE_ENTITY}
                        <UploadCloudIcon />
                      </span>
                    </button>
                  </>
                ) : (
                  rebuildMessage
                )}
              </div>
            ) : null}
          </div>
          <div className={title}>
            {showSpam ? <h2>Comments marked spam</h2> : <h2>Comments not marked spam</h2>}
            <button type="button" onClick={() => setShowSpam(!showSpam)}>
              {showSpam ? <ToggleLeftIcon /> : <ToggleRightIcon />}
            </button>
          </div>
          {slugs.length > 0 ? (
            <ul>
              {slugs.map((key) => (
                <li key={key}>
                  <h3>
                    <Link aria-label={`Open post with slug ${key}`} to={`/${key}`}>
                      {key}
                    </Link>
                  </h3>
                  <ul>
                    {comments[key].map((element) => {
                      const { commentId, date, name, text } = element;
                      const dayjsDate = dayjs(date);
                      const dateString = dayjsDate.fromNow();
                      return (
                        <li key={commentId}>
                          <Card>
                            <div className={commentHeader}>
                              <h4>{name}</h4>
                              <button type="button" onClick={() => toggleMarkedSpam({ commentId })}>
                                {showSpam ? (
                                  <>
                                    <FlagIcon /> clear spam flag
                                  </>
                                ) : (
                                  'mark spam'
                                )}
                              </button>
                            </div>
                            <p>{text}</p>
                            <div className={commentFooter}>
                              <div className={dateText}>
                                <small>{dateString}</small>
                              </div>
                              <button type="button" onClick={() => deleteComment({ commentId })}>
                                <TrashIcon />
                              </button>
                            </div>
                          </Card>
                        </li>
                      );
                    })}
                  </ul>
                </li>
              ))}
            </ul>
          ) : (
            <p>No comments to show!</p>
          )}
        </main>
      </div>
    </>
  );
}

CommentsDashboard.propTypes = {
  data: PropTypes.shape({
    site: PropTypes.shape({
      siteMetadata: PropTypes.shape({
        siteLanguage: PropTypes.string,
      }),
    }),
  }).isRequired,
};

export const query = graphql`
  query commentsDashboardQuery {
    site {
      siteMetadata {
        siteLanguage
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

You might have noticed in the login component that the onSubmit function tells the browser to navigate to /comments-dashboard/, which is this page that we are working on now. Similarly, in this page, if there is no valid session token, we tell the browser to redirect to the login page. Just above that, using useState we retrieve the sessionSecret from session storage, via our getSessionStorage utility function which we defined earlier.

Calling the Serverless Function to Get Comments

Further down the getComments method is used to pull comments from Fauna, using the get-comments Gatsby Serverless Function. We will define that function in a moment. Note that we include the sessionSecret in the data we pass to the serverless function. This is used by the serverless function to authenticate the user before actually getting the comments. We also send a showSpam boolean state variable. This tells the function whether to send us comments marked spam or comments marked not spam — we will be able to see either in our dashboard.

Just below getComments we have the other methods which trigger serverless functions. We will fill those out shortly. The next interesting block in the file is the useEffect hook:

  useEffect(async () => {
    if (sessionSecret) {
      await getComments();
    }
  }, [sessionSecret, showSpam]);
Enter fullscreen mode Exit fullscreen mode

All this does is call the getComments method whenever the sessionSecret changes (this happens as the component loads for the first time) and also when we toggle whether to show spam or non spam comments.

We will set up our serverless function to return comments grouped by the slug of the post they appear on, in the format:

{
  "best-medium-format-camera-for-starting-out/": [
    {
      "commentId": "306552151776165954",
      "date": "2021-08-10T15:36:06.630Z",
      "name": "John",
      "slug": "best-medium-format-camera-for-starting-out/",
      "text": "Test comment"
    },
    {
      "commentId": "306805246485594176",
      "date": "2021-08-13T10:39:05.926Z",
      "name": "Mary",
      "slug": "best-medium-format-camera-for-starting-out/",
      "text": "Lovely article, thanks for sharing this!"
    }
  ],
  "folding-camera/": [
    {
      "commentId": "306709641640804418",
      "date": "2021-08-12T09:19:27.938Z",
      "name": "Spam test",
      "slug": "folding-camera/",
      "text": "Spam test"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

So the slugs variable will just be an array of all the post slugs. We will iterate over all of the slugs and then, in turn all of the comments for each slug. Essentially, that is what the remainder of the code takes care of. For now, we have no comments, so the output isn't too interesting. Let's fix that next by creating the get-comments serverless function.

get-comments Gatsby Serverless Function

If we want to call a Gatsby serverless function by posting data to the route /api/get-comments, we need the function code to be in the file src/api/get-comments.js within our project. Create that file and add the following content:

import axios from 'axios';
import faunadb from 'faunadb';

const FAUNA_COMMENTS_INDEX = 'get-comments';

function groupCommentsBySlug(comments) {
  return comments.reduce((accumulator, object) => {
    const key = object.slug;
    if (!accumulator[key]) {
      accumulator[key] = [];
    }
    accumulator[key].push(object);
    return accumulator;
  }, {});
}

async function checkCredentials(secret) {
  try {
    const authorizationToken = Buffer.from(`${secret}:`, 'utf-8').toString('base64');
    const response = await axios({
      url: 'https://db.us.fauna.com/tokens/self',
      method: 'GET',
      headers: {
        Authorization: `Basic ${authorizationToken}`,
      },
    });
    return { successful: true, message: response };
  } catch (error) {
    return { successful: false, message: error.message };
  }
}

const getComments = async ({ showSpam }) => {
  try {
    const client = new faunadb.Client({
      secret: process.env.FAUNA_SECRET,
      domain: 'db.us.fauna.com',
      scheme: 'https',
    });
    const q = faunadb.query;
    const results = await client.query(
      q.Paginate(q.Match(q.Index(FAUNA_COMMENTS_INDEX), showSpam, undefined)),
    );
    const comments = results.data.map(([ref, date, name, slug, text]) => ({
      commentId: ref.id,
      date,
      name,
      slug,
      text,
    }));
    return { successful: true, comments: groupCommentsBySlug(comments) };
  } catch (error) {
    return { successful: false, message: error.message };
  }
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    res.status(405).send('Method not allowed');
  } else {
    const { token: secret, showSpam } = req.body;
    const { successful: validCredentials } = await checkCredentials(secret);
    if (!validCredentials) {
      res.status(400).send('Unauthorized.');
    } else {
      const { comments, message, successful } = await getComments({ showSpam });
      if (!successful) {
        res.status(400).send(`Error retreiving comments${message ? `: ${message}` : '.'}`);
      } else {
        res.status(200).json({ comments });
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When called, this function checks the supplied user secret token is valid. If it is, it pulls comments from our Fauna database and sends them back to the client's browser. Let's have a quick look in more detail. When we query Fauna for our comments (using the index we created last time), we get an array of objects, one object for each comment. groupCommentsBySlug as the name suggests is a utility function to rearrange the array of comments, into the format we described above, just to make the client code more straightforward. We use the JavaScript array reduce method to help here.

Fauna Basic Access Authentication in Gatsby Serverless Functions

You will see we do a little manipulation of the secret token to check the user is authentic. We are using basic access authentication. Using this function, we authenticate the user before executing an operation on our Fauna database. Later, in the trigger-rebuild.js serverlerless function, we will see that we can also use this check for authenticating the user before performing an action on an external service.

In this serverless function we want to check the user's credentials to make sure our system administrator has already authorized them to get comments in the checkCredentials function. The first line of the try block creates a Base64 encoding of the secret token, which is needed for basic access authentication. We send that Base64 encoded string as an Authorization HTTP header to the Fauna server. If the token is invalid, the server responds with an error, so if there is no error, we know the user is authentic.

Next, the getComments function is not that different to the function we coded in gatsby-node.js. Finally we have the handler function, which is the main function, first called when we receive a call on the endpoint. Here it takes in the received secret token and calls the other functions to get the comments the user wanted from Fauna before returning them.

Screen capture showing the Comments Dashboard. There is a log out button at the top. Lower down, a title reading Comments not marked spam, with a toggle switch beside it.  Further down still text shows the slug of a blog post and below that are two boxes each containing details of blog post comments, only the top one is completely visible.  It shows a marked spam and trash can button

The other serverless functions we create will share a lot in common with this one, so take a moment to review it, to make sure everything is clear. When you're ready, go to localhost:8000/comments-dashboard/. If you already logged in successfully, you should go straight to the dashboard and see the test comments you created last time. Otherwise you will see the login screen and once logged in, the browser will redirect you to the dashboard.

Updating our Fauna Database

Next we will add functionality to delete comments and also change the spam flag on a comment. Instead of actually deleting a comment, we will add a movedToTrash flag. A database admin can clean up trash comments periodically in the Fauna dashboard. This mechanism helps prevent accidental deletion. Any comments for which this flag is defined will not be included in our index when we retrieve comments from Fauna in Gatsby. Let's fill out the method body for the deleteComment and toggleMarkedSpam methods in src/pages/comments-dashboard/index.js:

  const deleteComment = async ({ commentId }) => {
    try {
      await axios({
        url: '/api/update-comment',
        method: 'POST',
        data: {
          token: sessionSecret,
          commentId,
          moveToTrash: true,
        },
      });
      setDatabaseUpdated(true);
      setRebuildMessage('');
      getComments();
    } catch (error) {
      console.log(error);
    }
  };
...
  const toggleMarkedSpam = async ({ commentId }) => {
    try {
      await axios({
        url: '/api/update-comment',
        method: 'POST',
        data: {
          token: sessionSecret,
          commentId,
          setMarkedSpamTo: !showSpam,
        },
      });
      setDatabaseUpdated(true);
      setRebuildMessage('');
      getComments();
    } catch (error) {
      console.log(error);
    }
  };
Enter fullscreen mode Exit fullscreen mode

These methods aren't too different from the ones we have already created. After making the call to the serverless function, we update a local state variable with setDatabaseUpdated(true). This is used to show a panel to the user asking them if they want to rebuild the site. This is needed because although we have dynamic content on our comments dashboard, we keep the main site static to optimize speed. Just like we can automatically trigger a site rebuild each time a visitor leaves a comment so that the public site is up-to-date, we will want to rebuild the site after deleting comments or changing spam flags. Rebuilding makes sure the static site served to users reflects the changes we make in the Comments Dashboard.

setRebuildMessage('') is just there to reset state, we will look at this again once we have added the rebuild method body. The final line in the try block will trigger a refetch of comments. This will mean the changes will be reflected in the Comments Dashboard. As we just mentioned however, the main site is static so the authorised user will have to trigger a rebuild for changes to be reflected on the public site.

update-comment Gatsby Serverless Function

You will see both of these methods call the same serverless function, update-comment, but with different parameters. Let's write out that function now. Create the file src/api/update-comment.js and add the following content:

import axios from 'axios';
import faunadb from 'faunadb';

async function checkCredentials(secret) {
  try {
    const authorizationToken = Buffer.from(`${secret}:`, 'utf-8').toString('base64');
    const response = await axios({
      url: 'https://db.us.fauna.com/tokens/self',
      method: 'GET',
      headers: {
        Authorization: `Basic ${authorizationToken}`,
      },
    });
    return { successful: true, message: response };
  } catch (error) {
    return { successful: false, message: error.message };
  }
}

const moveCommentToTrash = async ({ commentId }) => {
  try {
    const client = new faunadb.Client({
      secret: process.env.FAUNA_SECRET,
      domain: 'db.us.fauna.com',
      scheme: 'https',
    });
    const q = faunadb.query;
    await client.query(
      q.Update(q.Ref(q.Collection(process.env.FAUNA_COLLECTION), commentId), {
        data: {
          movedToTrash: true,
        },
      }),
    );
    return { successful: true };
  } catch (error) {
    return { successful: false, message: error.message };
  }
};

const setMarkedSpam = async ({ commentId, setMarkedSpamTo }) => {
  try {
    const client = new faunadb.Client({
      secret: process.env.FAUNA_SECRET,
      domain: 'db.us.fauna.com',
      scheme: 'https',
    });
    const q = faunadb.query;
    await client.query(
      q.Update(q.Ref(q.Collection(process.env.FAUNA_COLLECTION), commentId), {
        data: {
          markedSpam: setMarkedSpamTo,
        },
      }),
    );
    return { successful: true };
  } catch (error) {
    return { successful: false, message: error.message };
  }
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    res.status(405).send('Method not allowed');
  } else {
    const { commentId, token: secret, moveToTrash, setMarkedSpamTo } = req.body;
    const { successful: validCredentials } = await checkCredentials(secret);
    if (!validCredentials) {
      res.status(400).send('Unauthorized.');
    } else if (moveToTrash !== undefined) {
      const { message, successful } = await moveCommentToTrash({ commentId });
      if (!successful) {
        res.status(400).send(`Error retreiving comments${message ? `: ${message}` : '.'}`);
      } else {
        res.status(200).send('Moved to trash.');
      }
    } else if (setMarkedSpamTo !== undefined) {
      const { message, successful } = await setMarkedSpam({ commentId, setMarkedSpamTo });
      if (!successful) {
        res.status(400).send(`Error changing marked spam flag${message ? `: ${message}` : '.'}`);
      } else {
        res.status(200).send(`Marked ${setMarkedSpamTo ? '' : 'not'} spam.`);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The logic here is similar to what we have seen before. However what is new is that we have Update method calls so, let's take a look at the first one:

      q.Update(q.Ref(q.Collection(process.env.FAUNA_COLLECTION), commentId), {
        data: {
          movedToTrash: true,
        },
      })
Enter fullscreen mode Exit fullscreen mode

You see updating a document in our database is not at all difficult. As with the Login method, there are two argument to Update. The first is a reference to the document we want to update (we also had a reference as the first argument for Login). The second argument is an object containing all the fields we want to update and tells Fauna what the new value should be for each field. Although we only change one field here, we could change multiple fields when needed, just by adding them to the object. You will find links to Update method and other popular methods in the Fauna FQL Cheat Sheet.

Testing Fauna Database Updates

We are almost at the end now, with just two features to add: logout and rebuild. Before we continue, test changing the spam flag on a comment. You can toggle whether you see spam comments or non spam comments by pressing the toggle button beside the “Comments marked spam” heading.

On one of the comments click the “mark spam” or “clear spam flag” button. You should see the view refresh and that comment disappear. If you then press the toggle button (to toggle between displaying comments marked spam and those not), you will see that same comment you just updated. Next we will delete a comment. The heading which displays the slug (above a group of comments) is a link. Click it to get taken to a blog post and then enter a new comment.

Next click your browser's back button to return to the Comments Dashboard. Find the comment you just entered (you will need to refresh the page). Press the delete button and it will disappear. The comment will still be in the database, though our app is not aware it exists. You can go into the dashboard and find the document for the comment and delete the line movedToTrash: true to have the comment displayed in our app again. You might do this if you ever delete a comment by mistake.

Logging Out

Because we are using Session Tokens if you close the browser tab or close the browser itself (after logging in to the Comments Dashboard), the browser will forget your token and you are effectively logged out. The session token will still be valid though. We will see now how you can cancel all existing tokens for a user. As with the other features, we will use serverless functions. First, fill out the logout method body in src/pages/comments-dashboard/index.jsx:

  const logout = async () => {
    try {
      setLoggingOut(true);
      await axios({
        url: '/api/db-logout',
        method: 'POST',
        data: {
          token: sessionSecret,
        },
      });
      setSessionSecret('');
      navigate('/');
    } catch (error) {
      console.log(error);
    }
  };
Enter fullscreen mode Exit fullscreen mode

Note that we clear the session token from the browser after calling the serverless function. This just prevents inconsistencies in the user interface; it is the serverless function which actually invalidates the token, not clearing it from the browser's session storage. Finally, the browser navigates to the site home page. Let's jump to the serverless function next.

Logout Gatsby Serverless Function

Create a file in our project at src/api/db-logout.js and add the following content:

import faunadb from 'faunadb';

const dbLogout = async ({ secret }) => {
  try {
    const client = new faunadb.Client({
      secret,
      domain: 'db.us.fauna.com',
      scheme: 'https',
    });
    const q = faunadb.query;
    await client.query(q.Logout(true));
    return { successful: true };
  } catch (error) {
    return { successful: false, message: error.message };
  }
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    res.status(405).send('Method not allowed');
  } else {
    const { token: secret } = req.body;
    const dblogoutResult = await dbLogout({ secret });
    if (!dblogoutResult.successful) {
      res.status(400).send('Error logging out.');
    } else {
      res.status(200).json(dblogoutResult);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The Fauna Logout method clears all session tokens for that user (you can customize the Logout method behavior). This means if you logged in on your laptop and then logged in on your desktop computer and then later log out on the desktop (by calling this method), both sessions (laptop and desktop) will become invalid.

You might have noticed we did one thing differently when we set up our client in this function. Instead of using our server secret from the .env FAUNA_SECRET variable, we actually used the client secret token. This is necessary so we can log out the right user. Note that the user secret token is not authorized to perform the other Fauna database operations we performed earlier. That is why we authenticated the user and then used our API key actually to perform the actions.

Trigger Rebuild

We saw in the earlier article how to trigger a rebuild on Netlify from a Gatsby Serverless Function. We will use the same method here. The only difference here is that we will check the user is logged in. Let's make the final change to src/pages/comments-dashboard/index.jsx:

  const rebuild = async () => {
    try {
      await axios({
        url: '/api/trigger-rebuild',
        method: 'POST',
        data: {
          token: sessionSecret,
        },
      });
      setRebuildMessage(`Rebuild started at ${dayjs().format('lll')}.`);
    } catch (error) {
      console.log(error);
    }
  };
Enter fullscreen mode Exit fullscreen mode

The rebuild message only appears in the console when the user makes a Fauna database change. The logic is set up so that after the user makes a change, we show a message asking the user if they want to rebuild and display a rebuild button. If the user clicks the rebuild button, React updates the rebuildMessage local variable from an empty string to a message with the build start time. The logic is set up so when the rebuildMessage is no longer an empty string, the browser displays the build start time message, instead of asking the user if they want to rebuild.

To keep the user interface consistent, we need to reset the rebuildMessage to an empty string when the user makes further updates to the Fauna database. Why? So that instead of showing the previous build start time, the browser shows the rebuild button and asks if they want to rebuild again. If that's not 100% clear, test out this functionality once we have the new serverless function coded up and it should fall into place.

Rebuild Gatsby Serverless Function

Create a new serverless function at src/api/trigger-rebuild.js and add this content:

import axios from 'axios';

async function checkCredentials(secret) {
  try {
    const authorizationToken = Buffer.from(`${secret}:`, 'utf-8').toString('base64');
    const response = await axios({
      url: 'https://db.us.fauna.com/tokens/self',
      method: 'GET',
      headers: {
        Authorization: `Basic ${authorizationToken}`,
      },
    });
    return { successful: true, message: response };
  } catch (error) {
    return { successful: false, message: error.message };
  }
}

const triggerRebuild = async () => {
  if (!process.env.NETLIFY_BUILD_HOOK_ID) {
    return { successful: false, message: 'Netlify build hook ID is not defined.' };
  }
  try {
    const response = await axios({
      url: `https://api.netlify.com/build_hooks/${process.env.NETLIFY_BUILD_HOOK_ID}`,
      method: 'POST',
    });
    return { successful: true, message: response };
  } catch (error) {
    let message;
    if (error.response) {
      message = `Server responded with non 2xx code: ${error.response.data}`;
    } else if (error.request) {
      message = `No response received: ${error.request}`;
    } else {
      message = `Error setting up response: ${error.message}`;
    }
    return { successful: false, message };
  }
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    res.status(405).send('Method not allowed');
  } else {
    const { token: secret } = req.body;
    const { successful: validCredentials } = await checkCredentials(secret);
    if (!validCredentials) {
      res.status(400).send('Unauthorized.');
    } else {
      await triggerRebuild();
      res.status(200).send('Triggered rebuild.');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Here you see we use Fauna to authenticate the user, even though we are performing an operation on a third party service. There are endless opportunities for using this pattern in other apps. That's the final piece of functionality in place now. Well done for making it through to the end. I hope have learned a lot about Fauna and Gatsby Serverless Functions. Before you go though, let's do a final test.

Screen capture of the Comments Dashboard. A question asked if the site should be rebuilt.  Besides appears a Rebuild button

Change the spam flag on a comment in the Comments Dashboard. You will see a message asking if you want to rebuild the site. Click the rebuild button.

Similar to the previous screen shot, this time the question is replaced by text stating when the site rebuild started

The message updates to show the rebuild time. Now make another change to the database. The browser will prompt you to rebuild again.

What Next?

We have just covered the basics here. Here are some ideas for extra features you can add to this project.

  • create a page which lets users update their password,
  • email a notification to the site admin when a new comment is marked as spam so a human can verify it,
  • add a trash comments page to make it easier to restore accidentally deleted comments,
  • add input validation to the serverless functions,
  • perform a security audit on the app tailored to your threat model,
  • add an accessible CAPTCHA to the comment form,
  • for a popular site, with many comments, use the Fauna Paginate method in the get-comments serverless function to allow the user to view comments page-by-page.
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .