Will Browning.

7th Mar 19 #nuxt#cockpit 11 min read

Building a Static Blog with Nuxt.js and Cockpit Headless CMS - Part 6: Contact Forms

In this post which will likely be the last of our Nuxt Cockpit Static Blog series we'll be looking at how to add a basic contact form to our static site so we can receive enquiries from users. We'll also take a look at some basic spam protection measures we can add.

If you haven't read Parts 1, 2, 3, 4 and 5 of this guide you can find them here:

Handling Forms on Static Sites

There are a number of different ways you can go about handling forms on static sites, including:

  • Netlify
  • Google Forms
  • Formspree
  • 99 Inbound

Cockpit comes with its own solution that can help us add forms to our site using simple API POST requests to handle submissions.

Submissions made through the API can be viewed in the Cockpit dashboard and also notify you via email when a new submission is made.

You can read more about Cockpit forms in the documentation here.

Adding a New API Token

First things first we need to generate a new API token in Cockpit and also make sure it only has permissions to hit the forms endpoint.

So head over to Cockpit and go to Settings then API Access. Click the little plus icon to add a new key and add the following to the rules field: /api/forms/submit/.

Cockpit Forms API Key

Now this key will only be able to perform form submissions.

Creating a New Form in Cockpit

In order for Cockpit to handle submissions we first need to create a new form. So from the dashboard click on forms and then click 'Create one'.

Give it a name like contact and a label of Contact Form. Add your email if you wish to be notified when new submissions are made. Turn on 'Save form data' if you would like to be able to view submission entries from Cockpit.

Cockpit Contact Form

Now that we've created our contact form in Cockpit we can test it by sending a POST request to the right endpoint.

Updating Cockpit Mailer Config

Before we can test out our form we need to update our Mailer config and add some SMTP details. Go to settings and then click on settings and add SMTP details for the email address you'd like to use.

If you didn't enter an email when creating the form above you can skip adding SMTP details

Add the following inside config.yaml

# use smtp to send emails
mailer:
    from      : you@example.com
    transport : smtp
    host      : smtp.myhost.tld
    user      : you@example.com
    password  : yourpassword
    port      : 587
    auth      : true
    encryption: tls # '', 'ssl' or 'tls'

Cockpit Mailer Config

Testing Our Contact Form

If you have Postman or Insomnia installed you can easily send a POST request to your Cockpit endpoint.

The endpoint we need to use is cms.yourdomain.com/api/forms/submit/contact?token=xxx where token is the API Key we created above to use for our form.

Make sure to set a header with Content-Type as application/json and then set the request body as the following JSON:

{
    "form": {
        "name":"John Doe",
        "email":"johndoe@example.com",
        "message": "This is the message body!"
    }
}

The response returned if all was successful should just be the new form entry:

{
  "name": "John Doe",
  "email": "johndoe@example.com",
  "message": "This is the message body!"
}

If an error occurred (e.g. forgetting to update config.yaml) then the response will look something like this:

{
  "error": "Invalid address:  (From): root@localhost",
  "data": {
    "name": "John Doe",
    "email": "johndoe@example.com",
    "message": "This is the message body!"
  }
}

If you head to cms.yourdomain.com/forms/entries/contact you should now see the entry we just submitted via the API. You should also have received an email with the form submission details.

Updating Our Blogs Environment Variables

Open up your .env file for Nuxt and add a new variable called CONTACT_URL.

CONTACT_URL=https://cms.yourdomain.com/api/forms/submit/contact?token=xxx

Now we also need to update the env property in our nuxt.config.js. Add the following anywhere inside module.exports = { ... }

env: {
  contactUrl: process.env.CONTACT_URL
},

As mentioned in the previous post the reason we need to do this is because we will be making requests to our contactUrl on the client side which means we need to have this variable bundled up in our js files.

Warning: Do not add any secret or sensitive details/keys to the env property as they will be publicly exposed in our js files

Make sure you also update your create-env.js if deploying to Netlify. Also update your environment variables in Netlify.

const fs = require('fs')
fs.writeFileSync('./.env', `
BASE_URL=${process.env.BASE_URL}\n
POSTS_URL=${process.env.POSTS_URL}\n
URL=${process.env.URL}\n
PER_PAGE=${process.env.PER_PAGE}\n
SEARCH_URL=${process.env.SEARCH_URL}\n
CONTACT_URL=${process.env.CONTACT_URL}
`)

Adding the Contact Page

Now that we know our contact form is working as expected we can go and set it up in our blog.

First we'll just update our PageNav.vue component to add a link to the new page:

<template>
  <nav class="text-center my-4">
    <a href="/" class="p-2 text-sm sm:text-lg inline-block text-grey-darkest no-underline hover:underline">Blog</a>
    <a href="/about" class="p-2 text-sm sm:text-lg p-2 inline-block text-grey-darkest no-underline hover:underline">About</a>
    <a href="/search" class="p-2 text-sm sm:text-lg p-2 inline-block text-grey-darkest no-underline hover:underline">Search</a>
    <a href="/contact" class="p-2 text-sm sm:text-lg p-2 inline-block text-grey-darkest no-underline hover:underline">Contact</a>
  </nav>
</template>

Then create a new file in the pages directory called contact.vue and put the following inside.

<template>
  <section class="my-8">
    <div class="text-center">
      <h1 class="mb-6">Contact Page</h1>
      <p class="mb-8">
        This is a basic contact form working with Cockpit CMS!
      </p>
    </div>

    <form @submit="checkForm" method="post">
      <div v-if="errors.length" class="mb-4 text-red">
        <b>Please correct the following error(s):</b>
        <ul>
          <li v-for="error in errors" :key="error">
            {{ error }}
          </li>
        </ul>
      </div>
      <div class="mb-4">
        <label for="name">Name:</label>
        <input v-model="name" type="text" name="name" placeholder="Your Name" class="block mt-2 shadow rounded w-full py-2 px-3 text-dark-grey">
      </div>
      <div class="mb-4">
        <label for="mail">Email:</label>
        <input v-model="email" type="email" name="email" placeholder="Your Email" class="block mt-2 shadow rounded w-full py-2 px-3 text-dark-grey">
      </div>
      <div class="mb-4">
        <label for="msg">Message:</label>
        <textarea v-model="message" name="message" placeholder="Your Message" class="block mt-2 shadow rounded w-full py-2 px-3 text-dark-grey"></textarea>
      </div>
      <div class="mb-4">
        <input type="submit" value="Send message" :class="{ 'cursor-not-allowed opacity-50': loading }" class="cursor-pointer bg-blue hover:bg-blue-light text-white font-bold py-2 px-4 border-b-4 border-blue-dark hover:border-blue rounded">
      </div>
      <div v-if="formResponse">
        <b>{{ formResponse }}</b>
      </div>
    </form>
  </section>
</template>
<script>
import axios from 'axios'

export default {
  head () {
    return {
      title: 'Contact',
      meta: [
        { hid: 'description', name: 'description', content: 'This is the contact page!' }
      ]
    }
  },

  data: function () {
    return {
      errors: [],
      name: null,
      email: null,
      message: null,
      loading: false,
      formResponse: null
    }
  },

  methods: {
    checkForm: function (e) {
      this.errors = []
      this.formResponse = null

      if (!this.name) {
        this.errors.push("Name required")
      }
      if (!this.email) {
        this.errors.push('Email required')
      } else if (!this.validEmail(this.email)) {
        this.errors.push('Valid email required')
      }
      if (!this.message) {
        this.errors.push("Message required")
      }

      if (!this.errors.length) {
        this.submitForm()
      }

      e.preventDefault()
    },

    submitForm: function () {
      this.loading = true

      axios.post(process.env.contactUrl,
      JSON.stringify({
          form: {
            name: this.name,
            email: this.email,
            message: this.message
          }
        }),
      {
        headers: { 'Content-Type': 'application/json' }
      })
      .then(response => {
        this.loading = false

        if(response.data.error){
          this.formResponse = response.data.error
        } else {
          this.name = this.email = this.message = null
          this.formResponse = 'Your message has been sent succesfully'
        }
      })
    },

    validEmail: function (email) {
      let re = /^(([^<>()\[\]\\.,;:\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,}))$/
      return re.test(email)
    }
  }
}
</script>

As you can see we have some basic fields for our form and on submitting the form we perform some client side validation following the example in the Vue documentation here.

If there are no errors we then call the submitForm method and use axios to make a POST request to our contactUrl endpoint. Then we display some text with a success message or an error if there is one present.

You can fire up your local site using npm run dev and test this contact form out. You should receive an email notification and be able to see the entry in Cockpit.

Adding Server Side Validation

At the moment we only have validation for our form fields on the client side which can be circumvented, we need to also add validation for our fields in Cockpit.

We can add custom validation for our form fields in Cockpit by creating a new file with the same name as our form (in our case contact) in the config/forms directory. You will need to create the forms directory first.

Then make a new file called contact.php and put the following inside:

<?php

if (empty($data['name'])) {
    return false;
}

if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
    return false;
}

if (empty($data['message'])) {
    return false;
}

return true;

The form data is available in the $data variable. This is only really simple validation for an example.

To test out if this validation is working on the server we need to send a POST request using Postman/Insomnia with name set to null.

If you don't have Postman or Insomnia just comment out the following in contact.vue to temporarily disable to client side validation then submit the form on the front end without setting a value for the name field:

checkForm: function (e) {
  this.errors = []
  this.formResponse = null

  /* if (!this.name) {
    this.errors.push("Name required")
  }
  if (!this.email) {
    this.errors.push('Email required')
  } else if (!this.validEmail(this.email)) {
    this.errors.push('Valid email required')
  }
  if (!this.message) {
    this.errors.push("Message required")
  } */

  if (!this.errors.length) {
    this.submitForm()
  }

  e.preventDefault()
},

Now if you've added the contact.php file correctly you should notice that the response is returned without a body if validation fails on the server. You shouldn't receive a notification email and there should not be a new entry visible in Cockpit.

Ideally it would be nice if Cockpit returned more details such as which field failed validation on the server with a message we could use but we will have to work with what we have.

In order to handle this we can update our submitForm method to the following:

submitForm: function () {
  this.loading = true

  axios.post(process.env.contactUrl,
  JSON.stringify({
      form: {
        name: this.name,
        email: this.email,
        message: this.message
      }
    }),
  {
    headers: { 'Content-Type': 'application/json' }
  })
  .then(({ data }) => {
    this.loading = false

    if(data.error){
      this.formResponse = data.error
    } else if(data.name && data.email && data.message) {
      this.name = this.email = this.message = null
      this.formResponse = 'Your message has been sent succesfully'
    } else {
      this.formResponse = 'One or more fields are not valid, please try again'
    }
  })
},

We simply use object destructuring on the request response to get the data then we check if an error is present. If no error is present we check if an entry has been returned with name, email and message details (this is what happens when a form is succesfully submitted).

If an entry was not returned in the response data then it must be because a validation error occurred in Cockpit, we don't know which field called the validation to fail so we just display a generic message and ask the user to try again.

Spam Prevention

If you have any kind of contact form on your site it is very likely you will have received spam from automated bots.

To help prevent this you can add a Google reCAPTCHA to your site/form.

If you'd rather not use reCAPTCHA another simple method is available known as a Honeypot trap.

The idea is that you add a hidden text field or checkbox to your form that the user cannot see. A bot that is filling out the form will also accidently fill out this hidden field, in our server side validation we can check if this hidden field has been filled our (or checkbox ticked) and if it has we simply return false from our contact.php script.

Let's add a really simple honeypot field to our form. Above the input button add this new field:

<input type="text" name="website" v-model="website" class="hidden opacity-0 z-0" tabindex="-1" autocomplete="off">

We've given it a real looking name and set it to display: none, with 0 opacity and a z-index of 0. We've also set tabindex as -1 to prevent the user selecting the field by clicking tab and set autocomplete as off to prevent a user's browser accidently autocompleting and filling in the field.

Make sure to add website to the page's data:

data: function () {
  return {
    errors: [],
    name: null,
    email: null,
    message: null,
    website: null,
    loading: false,
    formResponse: null
  }
},

Also add it when posting the request to Cockpit:

submitForm: function () {
  this.loading = true

  axios.post(process.env.contactUrl,
  JSON.stringify({
      form: {
        name: this.name,
        email: this.email,
        message: this.message,
        website: this.website
      }
    }),
  {
    headers: { 'Content-Type': 'application/json' }
  })
  .then(({ data }) => {
    this.loading = false

    if(data.error){
      this.formResponse = data.error
    } else if(data.name && data.email && data.message) {
      this.name = this.email = this.message = null
      this.formResponse = 'Your message has been sent succesfully'
    } else {
      this.formResponse = 'One or more fields are not valid, please try again'
    }
  })
},

Now all that's left to do is to update contact.php in the config/forms directory.

<?php

if (isset($data['website'])) {
    return false;
}

if (empty($data['name'])) {
    return false;
}

if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
    return false;
}

if (empty($data['message'])) {
    return false;
}

return true;

We just add a check for the new website honeypot field, if it is not set to null then the submission will fail vailidation and be rejected.

The only potential downside to this method of spam prevention is if a real user someone manages to accidently fill in the website field and their legitimate submission is rejected.

To make sure we don't lose a genuine submission we should add logging for all entries that fail the honeypot field test. That way we can check every so often which submissions have been rejected and see if any are authentic.

This obviously doesn't prevent against a bot sending direct POST requests to our form's endpoint and omitting the website field but it should be fine for most situations.

You can always change the name of the honeypot field or update it to a checkbox if you notice spam coming through.

You should now have a contact form with client + server side validation and basic spam bot protection that looks like this:

Contact Form

You can check out the GitHub repo of the finished blog here.

Like what you've seen here?

Fire over your email and I'll keep you updated once a month about any new posts. No Spam.