Self hosted captcha
Background
Last week, on the heels of leaving my job, I migrated this website from a 12 year old Grunt-based static site generator to Astro. LOL on multiple levels. As part of this move, I made a new contact form using Astro’s node.js server adapter, and an open-source self-hostable captcha alternative caled altcha.
I’ve been working with React Router FKA remix.run for a few years. When it came time to build a (mostly) static site, with only one dynamic page for a contact form, I reached for Atro for it’s static-site generater orientation with a sprinkle of JS runtime when you need it.
Astro is really well documented, but the documentation steers you toward using cloud service platforms instead of self-hosting. Altcha is similarly well documented, including this great guide on integrating with Astro, but that documentation also steers you toward using the Altcha paid service. I’m trying to build software that doesn’t leak data to third parties these days, so I wanted to take a minute to share a fully self-hosted implementation of Altcha in an Astro project.
This post will walk through adding the <altcha-widget>
web component to a form on a /contact
page, and self-hosting a /contact/challenge
endpoint using the Astro node.js adapter
on your own server. We’ll also add on diy-emailing using nodemailer
and your email hosts’ credentials. At the end of the walk through you’ll have a fully-self-hostable project that you can deploy to your own server.
Walkthrough
To follow along you can npm create astro@latest
. Or follow the very thorough Astro installation docs. Once you have a working Astro site setup, run the following install.
npm install --save altcha altcha-lib nodemailer
Setup your environment
Create a .env
directory in the root of your new astro project and fill it up with these variables:
STMPUSER="your user name"STMPPASS="your stmp password"STMPHOST="stmp.youremailhost.com"STMPPORT="your hosts' stmp port"ALTCHAHMAC="an random string"EMAILFROM="noreply@example.com"EMAILTO="you@example.com"EMAILSUBJECT="hi from my website 👋"
You can get stmp credentials from your email provider (e.g. proton or gmail). The HMAC key is a random ~128 bit string, which you can get from openssl rand -base64 32
or similar.
Make a /contact
page
Make a new directory at src/pages/contact
with an index.astro
file in it. I’m going to paste in my whole src/pages/contact/index.astro
file with comments here so you can just copy it. The following page has:
- Form handling logic
- Form validation
- Altcha challenge validtion
- Email sending code
- A form with the
<altcha-widget>
in it
---// Turn off prerendering since this is going to be served by node.js later.export const prerender = false;// Bring in the tool from altcha that we'll use to confirm the captcha.import { verifySolution } from 'altcha-lib'// Import nodemailer to send an email if everything is ok with the submission.import nodemailer from "nodemailer"
// Import the other bits that came with this template.import BaseHead from '../../components/BaseHead.astro';import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';
// Pull in the secret from our environment.const ALTCHA_HMAC_KEY = import.meta.env.ALTCHAHMAC;
// Declare a handy email address validator.const validateEmail = (email: string) => { return String(email) .toLowerCase() .match( /^(([^<>()[\]\\.,;:\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,}))$/ );};
// Start with an object of empty strings to use for errors if we got em.const errors = { email: "", body: "", altcha: "" };
// Only respond to post messages.if (Astro.request.method === "POST") {
// What we're about to do could fail. try { // Read form data from the request const data = await Astro.request.formData();
// Get the 'altcha' field containing the verification payload from the form data. const altcha = data.get('altcha')
// If the 'altcha' field is missing, return an error. if (!altcha) { errors.altcha += "Spam filter data missing."; }
// Verify the server signature using the API secret. const verified = await verifySolution(String(altcha), ALTCHA_HMAC_KEY)
// If verification fails or no verification data is returned, return an error. if (!verified) { errors.altcha += "Invalid spam filter data.";
// Everything is okay, process the submission. } else {
// Setup a mail transporter. const transporter = nodemailer.createTransport({ host: import.meta.env.STMPHOST, port: import.meta.env.STMPPORT, secure: false, auth: { user: import.meta.env.STMPUSER, pass: import.meta.env.STMPPASS, }, })
// What we're about to do could fail. try { // Grab the email and body. const email = data.get("email"); const body = data.get("body");
// If the email isn't right then say oops. if (typeof email !== "string" || !validateEmail(email)) { errors.email += "Oops, the robot can't email this address "; }
// If the body is blank then say oops. if (typeof body !== "string" || body.length < 1) { errors.body += "Oops, the message can't be blank. "; }
// Check if there are any errors. const hasErrors = Object.values(errors).some(msg => msg)
// If there are no errors send an email with the nodemailer transporter. if (!hasErrors) { await transporter.sendMail({ from: import.meta.env.EMAILFROM, to: import.meta.env.EMAILTO, subject: import.meta.env.EMAILSUBJECT, text: ` Email: ${email} Body: ${body} `, html: ` <h2>Email:</h2> <p>${email}</p> <h2>Body:</h2> <p>${body}</p> `, })
// That's it, the email is sent. // Send the person submitting the form to the confirm page. // This can be whatever you want. return Astro.redirect("/confirm"); }
// If there's an error sending the email, catch it an log it } catch (error) { if (error instanceof Error) { console.error(error.message); } } } // } catch (error: any) { errors.altcha += "Failed to process submission with spam filter."; }
}---<script> import('altcha');</script><!doctype html><html lang="en"> <head> <BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} /> </head> <body> <form method="POST"> <label for="email"> From email </label> <span>Your email</span> <input type="email" name="email" placeholder="me@example.com"/> <span class="error">{errors.email && errors.email}</span>
<label for="body"> Email Body </label> <textarea name="body" rows="12" placeholder="hi!"/> {errors.email && <span class="error">{errors.body}</span>}
<!-- Here's the critical UI for the altcaha. It needs an endpoit that will give it a challenge. --> <altcha-widget challengeurl={`/contact/challenge`}></altcha-widget> {errors.email && <span class="error">{errors.altcha}</span>}
<button>Send email</button> </form> </body></html>
Add a /contact/challenge
endpoint
Next create a challenge endpoint to generate Altcha challenges src/pages/contact/challenge
. The <altcha-widget/>
will go to this endpoint asyncronously from the client to get the challenge. Pasting the full implementation of this in here as well:
---import { createChallenge } from 'altcha-lib'
const ALTCHA_HMAC_KEY = import.meta.env.ALTCHAHMAC;
try { // Generate a new random challenge with a specified complexity const challenge = await createChallenge({ hmacKey: ALTCHA_HMAC_KEY, maxNumber: 50_000 })
// Return the generated challenge as JSON return new Response( JSON.stringify(challenge), { status: 200 } );} catch (error: any) { // Handle any errors that occur during challenge creation return new Response( JSON.stringify({ error: 'Failed to create challenge', details: error.message }), { status: 500 } );}---
Self-hosting with the Astro node.js adapter
Now we’ll pull in the node.js adapter for astro, add start script, and deploy to our own server. First we’ll follow this astro tutorial and npx astro add node
. That will add the node renderer to your project. Our /contact
and /contact/challenge
resources are already setup to use this with the export const prerender = false;
on the former, and the server only code in the latter.
You can now add a start script to your package.json
file:
"start": "node dist/server/entry.mjs"
Now you have a project that you can npm start. Deploy this anywhere you’d like with rsync or similar and then npm install && npm start
to get going.
I wrote a tutorial for how to self host a node app in this guide on Bocoup.com. You can follow that to deploy this. The only difference I would recommend is to use nginx to serve the static parts of your Astro site, rather than the node server. That has been set up in the code above (where server rendering is opt in). You’d have to adjust your web server reverse proxy configuration accordingly. My nginx config looks something like this, for reference:
server { server_name example.com;}
server { listen 80; listen [::]:80; server_name example.com; access_log /var/log/nginx/example.com.log; error_log /var/log/nginx/example.com-error.log error;
location / { root /var/www/example.com/dist/client; index index.html; }
location /contact { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $http_host; proxy_pass http://127.0.0.1:4321; proxy_redirect off; client_max_body_size 10M; }}
And that’s it. Good luck hosting your own captcha!