We launched our website a few days days ago and within hours, we were getting bounce notifications from our SMTP server.
That was odd because we weren't actually sending anything out. It turns out we already had bots scraping our website and signing up to our free newsletter.
As we didn't have any anti-spam CAPTCHA on the page (by design), we were automatically sending out emails to welcome the recipient and giving them a link to unsubscribe should they wish (GDPR).
As these bounce notifications weren't stopping anytime soon, we decided to add a simple anti-spam CAPTCHA.
And, as we're developers, we'd roll our own because our idea was simple enough.
Our idea was to ask a simple Maths question and put the question in an image so that it would be invisible for 99% of the bots. And, for good measure, we'd draw a few lines and distort the image.
Let's create a file called captcha.js. And give it a function called generateCaptcha and then export that function so that it can be called by other functions.
const generateCaptcha = () => { // Our code will go here... } module.exports = { generateCaptcha, };
We'd need to make the Math question super easy to solve otherwise it would become a barrier. We decided to stick with addition and subtraction. And we knew we'd need to make sure the numbers were easy enough too - say, small number being added or subtracted from big number.
const options = [ 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, ];
We wanted to randomly select a number from the list of numbers.
options[Math.floor(Math.random() * options.length)];
Let's turn this into a function.
const pickBigNumber = () => { const options = [ 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, ]; return options[Math.floor(Math.random() * options.length)]; };
Let's take a similar approach for the smaller number...
const pickSmallNumber = () => { const options = [10, 5, 2, 3, 1, 4]; return options[Math.floor(Math.random() * options.length)]; };
Let's now think about the operator - that is, the addition and subtraction. People are better at adding than taking away so let's add a bias towards addition.
const options = ["+", "-", "+", "+"];
And let's wrap this up into a function too.
const pickOperator = () => { const options = ["+", "-", "+", "+"]; return options[Math.floor(Math.random() * options.length)]; };
Let's now start pulling this altogether. Let's generate a random equation.
const equation = pickBigNumber() + " " + pickOperator() + " " + pickSmallNumber();
JavaScript has a function called eval which allows us to evaluate code and in this case the equation.
const solution = eval(equation);
Okay. We're now at the point where we can generate a random equation and know the solution too. We've got our underlying CAPTCHA "algorithm" working and we just need to do everything else!
There's a package from npm called canvas we can use to help generate the image. Let's install it and start using it.
npm install canvas
At the top of our file, let's import the package so that it's in scope.
const { createCanvas } = require("canvas");
We need to create an instance of the canvas and then work with its context. We pass into the createCanvas function the image's width and height and then ask for the 2d context because we're creating a 2d image.
const canvas = createCanvas(200, 100); const ctx = canvas.getContext("2d");
Let's start configuring it. The background colour.
ctx.fillStyle = "#333333"; ctx.fillRect(0, 0, 200, 100);
And now the font.
ctx.fillStyle = "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
Okay, let's look at how we can draw the text. We call the fillText function, pass it the text and the starting x and y coords. Like this:
ctx.fillText(equation, x, y);
However, we want to distort the text by offsetting each character a random amount.
const y = 100 / 2; let text = `${equation} = ?`; let x = 10; for (let i = 0; i < text.length; i++) { const randomOffset = Math.random() * 15 - 1; ctx.save(); ctx.translate(0, randomOffset); ctx.fillText(text[i], x + 6 + i * 13, y); ctx.restore(); }
There's a lot going on so let me break it down. We're looping the length of the equation so that we can draw one letter at a time and specify its x and y position. We're also centering it vertically - that's the 100 / 2 bit. The translate call applies the random offset.
For our purposes, we want a base64-encoded string of the image so that we can return it along with other details in a JSON object.
const buffer = canvas.toBuffer("image/png"); const base64String = buffer.toString("base64");
And let's return everything in a JSON object.
return { image: "data:image/png;base64," + base64String, solution, equation: text, };
And here's an example of our hand-rolled CAPTCHA.
I've skipped a few things like how we plumbed it all in and how we used sessions to store the solution and check against the submitted version. If you're interested in that, let us know in the comments below.