Share resume with humans, not bots
I always wanted to make my resume publicly downloadable, so I could put it on my profile pages, etc. However, I didn't want to get spammed by scammers. A few years ago, I came up with a solution called cgar, but it was quite complicated and required Kubernetes to host. I stopped using it once my free credit at Linode ran out.
Recently, I picked up job search again and recreated the resume dispenser (github), which is freely hosted on CloudFlare.
Step 1: CloudFlare Turnstile
I noticed that OpenAI is using a human checker from CloudFlare, so I decided to try it. It's free and quite easy to use, and they provide great examples like server-side validation (github).
// This is the demo secret key. In production, we recommend
// you store your secret key(s) safely.
const SECRET_KEY = '1x0000000000000000000000000000000AA';
async function handlePost(request) {
const body = await request.formData();
// Turnstile injects a token in "cf-turnstile-response".
const token = body.get('cf-turnstile-response');
const ip = request.headers.get('CF-Connecting-IP');
// Validate the token by calling the
// "/siteverify" API endpoint.
let formData = new FormData();
formData.append('secret', SECRET_KEY);
formData.append('response', token);
formData.append('remoteip', ip);
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await fetch(url, {
body: formData,
method: 'POST',
});
const outcome = await result.json();
if (outcome.success) {
// ...
}
}
Comments:
- Remember to add the domains after deploying.
Step 2: CloudFlare Workers
Let's take a step back and look at the big picture with some reductionist logic.
- We need to host a file (resume) for download. By itself, this is trivial, and we can just put it on GitHub.
- We need to check for a human, which dictates that we provide a page with a CAPTCHA. Once the human passes the CAPTCHA, we authorize them to download.
- As the host serving the download, how do we know if the CAPTCHA is successful? Typically, we put the CAPTCHA on the login page, and once the login is successful, the user will then request the download with a signed token.
- Since we are dealing with a public link (resume) and user login is not possible, we can give out signed tokens to anyone passing the CAPTCHA.
This is an acceptable solution. We can implement a GET and a POST, with the following specification:
- GET: If the signed token is valid, return the file. If the signed token is missing or invalid, return the CAPTCHA form.
- POST: Validate the CAPTCHA form and return a signed token as a cookie.
I spotted that the token part can be omitted if we modify the specification:
- GET: Return the CAPTCHA form.
- POST: Validate the CAPTCHA form and return the file.
I'm not sure if it breaks any laws of the internet to serve a file from a POST call, but oh well.
My favorite free hosting service used to be Netlify, which also provides workers, but with one deadly weakness: the worker hooks are wrapped and can only use JSON as input/output, meaning it's impossible to initiate a download on the browser. I could return the file content base64 encoded and then have some client-side logic to decode it (there are a bunch of other tricks we can do if we commit to a full-blown frontend), but my new favorite CloudFlare workers are versatile and save me the trouble. CloudFlare also comes with a great server-side example for Turnstile (github).
Comments on modifying the example (github):
wrangler dev
is great, it sets up a local server for testing.- Use
env.TURNSTILE_SECRET
to fetch secrets in the code. - Use
wrangler secret put TURNSTILE_SECRET
to put secrets on CloudFlare. - Use
.dev.vars
to provide secrets for the local server. "1x0000000000000000000000000000000AA"
is a special secret key in Turnstile for development.wrangler publish
guess what this does.- Modify the HTML page with the correct Turnstile public key.
Step 3: CloudFlare Asset & KV
We're almost done! Now that the control structure is in place, we just need to serve the file (it's CloudFlare, if it does one thing, this is the thing). I found a good example (github).
import { getAssetFromKV } from '@cloudflare/kv-asset-handler';
import manifestJSON from '__STATIC_CONTENT_MANIFEST';
const assetManifest = JSON.parse(manifestJSON);
export default {
async fetch(request, env, ctx) {
try {
// Add logic to decide whether to serve an asset or run your original Worker code
return await getAssetFromKV(
{
request,
waitUntil: ctx.waitUntil.bind(ctx),
},
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: assetManifest,
}
);
} catch (e) {
let pathname = new URL(request.url).pathname;
return new Response(`"${pathname}" not found`, {
status: 404,
statusText: 'not found',
});
}
},
};
Comments on modifying the example (github):
- Put assets in the
./public
folder. Note that I didn't put the assets on GitHub, because it would defeat the purpose of protecting them with a human checker. getAssetFromKV
does the heavy lifting for us. We just need to provide it with arequest
object that has a URL with a path that maps to an asset. Note that I had to recreate therequest
object withGET
as the method.- Our assets are cached in KV, which is a database. This means we can go to the CloudFlare console and manually modify the file served (until it is overridden by the next deployment).