Scaffold Pwned Passwords clone: React/wouter frontend, Koa/pg backend, import CLI

- Backend: Koa + @koa/router serving the HIBP-style /range/:prefix k-anonymity
  API, backed by Postgres via pg, with migrate/seed scripts run through tsx.
- Frontend: React + wouter, bundled with webpack (dev server + prod build),
  thin client that hashes passwords locally and only sends the hash prefix.
- Import package: CLI to bulk-load a wordlist or hash list into the database,
  plus a separate script to pull the full dataset from the HIBP range API.
  Both support parallelism (worker threads for wordlist hashing, concurrency
  limiter for DB/HTTP fan-out).
- Dev tooling: docker compose for a local Postgres, .env.local/.env.example,
  DATABASE_URL support as an alternative to discrete PG* vars.
This commit is contained in:
2026-07-01 21:35:19 +00:00
commit fee254e7e6
32 changed files with 7348 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch --env-file=../../.env.local src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node --env-file=../../.env.local dist/index.js",
"migrate": "tsx --env-file=../../.env.local src/migrate.ts",
"seed": "tsx --env-file=../../.env.local src/seed.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@koa/router": "^15.6.0",
"koa": "^3.2.1",
"pg": "^8.22.0"
},
"devDependencies": {
"@types/koa": "^3.0.3",
"@types/koa__router": "^12.0.5",
"@types/pg": "^8.20.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Pool } from "pg";
// DATABASE_URL (e.g. postgres://user:pass@host:5432/db) takes precedence over
// the discrete PG* vars, for connecting to an externally hosted database.
export const pool = new Pool(
process.env.DATABASE_URL
? { connectionString: process.env.DATABASE_URL }
: {
host: process.env.PGHOST,
port: process.env.PGPORT ? Number(process.env.PGPORT) : undefined,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE,
},
);
+23
View File
@@ -0,0 +1,23 @@
import Koa from "koa";
import { rangeRouter } from "./routes/range.ts";
const app = new Koa();
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = 500;
ctx.body = { error: "internal server error" };
console.error(err);
}
});
app.use(rangeRouter.routes());
app.use(rangeRouter.allowedMethods());
const port = process.env.BACKEND_PORT ? Number(process.env.BACKEND_PORT) : 3001;
app.listen(port, () => {
console.log(`backend listening on http://localhost:${port}`);
});
+10
View File
@@ -0,0 +1,10 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { pool } from "./db.ts";
const schemaPath = fileURLToPath(new URL("./schema.sql", import.meta.url));
const schema = readFileSync(schemaPath, "utf8");
await pool.query(schema);
console.log("migration applied");
await pool.end();
+26
View File
@@ -0,0 +1,26 @@
import Router from "@koa/router";
import { pool } from "../db.ts";
const HEX_PREFIX = /^[0-9A-Fa-f]{5}$/;
export const rangeRouter = new Router();
// Mirrors the HaveIBeenPwned Pwned Passwords range API:
// https://haveibeenpwned.com/API/v3#PwnedPasswords
rangeRouter.get("/range/:prefix", async (ctx) => {
const { prefix } = ctx.params;
if (!HEX_PREFIX.test(prefix)) {
ctx.status = 400;
ctx.body = { error: "prefix must be 5 hex characters" };
return;
}
const { rows } = await pool.query<{ suffix: string; count: string }>(
"SELECT suffix, count FROM pwned_passwords WHERE prefix = $1",
[prefix.toUpperCase()],
);
ctx.type = "text/plain";
ctx.body = rows.map((row) => `${row.suffix}:${row.count}`).join("\r\n");
});
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS pwned_passwords (
prefix CHAR(5) NOT NULL,
suffix CHAR(35) NOT NULL,
count BIGINT NOT NULL DEFAULT 1,
PRIMARY KEY (prefix, suffix)
);
CREATE INDEX IF NOT EXISTS pwned_passwords_prefix_idx ON pwned_passwords (prefix);
+34
View File
@@ -0,0 +1,34 @@
import { createHash } from "node:crypto";
import { pool } from "./db.ts";
// A handful of well-known weak passwords, seeded for local testing.
// The real HIBP dataset has ~900M entries; this is just enough to exercise the API.
const SAMPLE_PASSWORDS: Record<string, number> = {
"123456": 30000000,
password: 10000000,
"123456789": 9000000,
qwerty: 4000000,
letmein: 500000,
admin: 400000,
welcome: 300000,
"iloveyou": 250000,
};
function sha1(value: string): string {
return createHash("sha1").update(value).digest("hex").toUpperCase();
}
for (const [password, count] of Object.entries(SAMPLE_PASSWORDS)) {
const hash = sha1(password);
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
await pool.query(
`INSERT INTO pwned_passwords (prefix, suffix, count)
VALUES ($1, $2, $3)
ON CONFLICT (prefix, suffix) DO UPDATE SET count = EXCLUDED.count`,
[prefix, suffix, count],
);
console.log(`seeded "${password}" -> ${hash} (${count})`);
}
await pool.end();
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowImportingTsExtensions": false,
"rewriteRelativeImportExtensions": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"]
}