| .forgejo/workflows | ||
| .github | ||
| .vscode | ||
| docs | ||
| src | ||
| static | ||
| .gitignore | ||
| .npmrc | ||
| .prettierignore | ||
| .prettierrc | ||
| bun.lock | ||
| eslint.config.js | ||
| jsr.json | ||
| LICENSE | ||
| mod.ts | ||
| package.json | ||
| README.md | ||
| svelte.config.js | ||
| tsconfig.json | ||
| vite.config.ts | ||
| wrangler.jsonc | ||
pow-reaction
proof-of-work reactions for your Svelte blogs
demo: pow-reaction.pages.dev
How POW captcha works
- You generate a
challengewhich consists of a.difficulty, b. number ofrounds - You generate a unique random string of characters for each round called
id - User now has to find a hash so that
hash(id + nonce)-> translated to binary (000111010101011) starts fromdifficultynumber of consecutive zeroes by iteratingnoncestarting from 0 and until they find the hash - They send their
solutions(nonces) back with thechallengesigned by you (to retrieve parameters for captcha and keep this lib stateless) - All you have to do is verify their solutions by checking if
hash(id + nonce)with their providednonce-> translated to binary really starts fromdifficultynumber of consecutive zeroes
Add progressively increasing difficulty with each subsequent request, and you get a pretty good stateless, privacy friendly rate limiter.
Not only this is a secure way of stopping flood but also a fair way for users to express their reaction. More reactions = more time to spend = those who appreciate the page's content more will send more reactions.
Couple of qurks:
- Instead of setting
difficultyto 100 androundsto 1, setdifficultyto 1 androundsto 100- more rounds = more equal average time of solving the challenge
- more rounds = real progress bar for user
- more rounds = run several workers to seek solution in parallel
- Instead of mining on single core, use WebWorkers
- web workers run in a separate thread = no UI freezes
- several web workers = several times faster to find all solutions
- use
navigator.hardwareConcurrencywhich is supported by every browser
Install
NPM:
bun add pow-reaction
JSR is blocked (see #1)
In your Svelte UI component (client-side):
<script>
import z from 'zod';
import ReactionButton from 'pow-reaction';
import { invalidate } from '$app/navigation';
let { data } = $props();
const emoji = '๐';
async function onclick() {
const req = await fetch('/api/reactions/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reaction: emoji })
});
if (!req.ok) throw new Error('Failed to get challenge');
return z.object({ challenge: z.string() }).parse(await req.json());
}
async function onreact({ challenge, solutions }) {
const req = await fetch('/api/reactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge, solutions, reaction: emoji })
});
if (!req.ok) throw new Error('Failed to add reaction');
z.object({ success: z.literal(true) }).parse(await req.json());
invalidate('post:reactions');
}
</script>
<!-- Optionally pass i18n={{ reactButton: {{ loading: 'Loading...', reactWith: 'React with', jsRequired: 'JavaScript is required in order to add reactions' }} }} to translate strings -->
<ReactionButton reaction={emoji} value={data.reactions[emoji]} {onclick} {onreact} />
In your server side initialize PowReaction class (lib/server/reactions.ts):
import { PowReaction } from 'pow-reaction';
// load from process.env or something, it should be 32 bytes long
const secret = new TextEncoder().encode('HESOYAM_HESOYAM_HESOYAM_HESOYAM!');
type ClientParams = { ip: string; pageId: string };
export const reaction = new PowReaction<ClientParams>({
// secret is used to cryptographically sign challenge
secret,
// reaction can be any string, emoji or enum value
reaction: '๐',
// optional but decreases the chance of dehashing client params in case of database breach
clientParamsSalt: 'my-app-name',
difficulty: {
// how many ms (1/1000 of a second) should be checked when generating a challenge
windowMs: 1000 * 60,
// min. starting difficulty, optional, defaults to 4
minDifficulty: 4,
// floor(challenges generated in last `windowMs` * `multiplier`) = number of leading zero bytes in the challenge
multiplier: 1,
async getEntries({ clientId, since }) {
// return number of entries in your persistant storage
// a client with `clientId` has been added to it
// starting from `since` Date
},
async putEntry({ clientId }) {
// put an entry for client with `clientId` to your
// persistant storage and assign current
// date `new Date()` to the entry
}
},
// how long should a signed challenge be valid
// this is mainly to prevent bots from requesting a lot of challenges
// in advance, easily solving, and then submitting in batch.
// too small values will cause low-end devices not to be able
// to submit solutions within this time frame
// optional, defaults to 60000 (60 seconds)
ttl: 1000 * 60,
async isRedeemed({ challengeId }) {
// return whether the `challengeId` was submitted previously
},
async setRedeemed({ challengeId }) {
// put the successfully submitted `challengeId`
}
});
In your server challenge generator API handler (POST /api/reactions/challenge):
import z from 'zod';
import { json } from '@sveltejs/kit';
import { reaction } from '$lib/server/reactions';
export async function POST({ request }) {
const body = await z
.object({
reaction: z.literal('๐')
})
.safeParseAsync(await request.json());
if (!body.success) {
return json({ success: false }, { status: 400 });
}
// get from headers or event.getClientAddress(), see https://github.com/sveltejs/kit/pull/4289
const ip = '1.2.3.4';
// by passing pageId you're binding this challenge to this page
// meaning the solution won't work for other pages but also that
// the rate limit of this challenge will only count for this page
const client = { ip, pageId: '/demo' };
const challenge = await reaction.getChallenge(client);
return json({ challenge });
}
In your server solution submitter API handler (POST /api/reactions):
import z from 'zod';
import { json } from '@sveltejs/kit';
import { reaction } from '$lib/server/reactions';
export async function POST({ request }) {
const body = await z
.object({
reaction: z.literal('๐'),
challenge: z.string().min(1),
solutions: z.array(z.number().int().nonnegative())
})
.safeParseAsync(await request.json());
if (!body.success) {
return json({ success: false }, { status: 400 });
}
const { challenge, solutions } = body.data;
// get from headers or event.getClientAddress(), see https://github.com/sveltejs/kit/pull/4289
const ip = '1.2.3.4';
// client must be exactly the same as in getChallenge
const client = { ip, pageId: '/demo' };
const success = await reaction.verifySolution({ challenge, solutions }, client);
if (success) {
// increase number of reactions by +1 in your database
}
return json({ success });
}
Important
Vite deps optimizer does not work with WebWorkers (see vitejs/vite#11672, vitejs/vite#15547, vitejs/vite#15618). You must add
pow-reactiontooptimizeDeps.excludein your vite.config.ts in order for WebWorker to load:optimizeDeps: { exclude: ['pow-reaction']; }
Theme
| CSS variable | Description | Default |
|---|---|---|
--reaction-button-text-color |
Value text color | rgba(0, 0, 0, 0.6), rgba(212, 212, 212, 0.6) when (prefers-color-scheme: dark) |
--reaction-button-highlight-color |
Button highlight color (focus & circular progress bar) | rgba(0, 0, 0, 0.1), rgba(161, 161, 161, 0.3) when (prefers-color-scheme: dark) |
Demo
You can find example & demo source code in src/routes directory.
Demo works with Cloudflare Pages and Cloudflare KV for IP rate limiting.
Tested in Firefox 142, Chrome 139, Safari macOS 18.5, Safari iOS 18.0, Tor Browser 14.5.6
Credits
Thanks to Paul Miller for the amazing noble project!
And thanks to Pilcrow for the awesome Oslo project!