Add support for cap, a pow captcha, and implement on contact form

This commit is contained in:
badblocks 2026-01-06 19:55:56 -08:00
parent 22c2bc1492
commit bdf4f9a051
No known key found for this signature in database
6 changed files with 172 additions and 8 deletions

20
db/config.ts Normal file
View file

@ -0,0 +1,20 @@
import { defineDb, defineTable, column } from "astro:db";
const Cap_Challenges = defineTable({
columns: {
token: column.text({ primaryKey: true }),
data: column.json(),
expires: column.number(),
},
});
const Cap_Tokens = defineTable({
columns: {
key: column.text({ primaryKey: true }),
expires: column.number(),
},
});
export default defineDb({
tables: { Cap_Challenges, Cap_Tokens },
});

85
src/lib/cap.ts Normal file
View file

@ -0,0 +1,85 @@
import Cap, { type ChallengeData } from "@cap.js/server";
import { db, eq, and, gt, lte, Cap_Challenges, Cap_Tokens } from "astro:db";
const cap = new Cap({
storage: {
challenges: {
store: async (token: string, challengeData: ChallengeData) => {
const expires = challengeData.expires;
const data = challengeData.challenge;
await db
.insert(Cap_Challenges)
.values({ token: token, data: data, expires: expires })
.onConflictDoUpdate({
target: Cap_Challenges.token,
set: { data: data, expires: expires },
});
},
read: async (token) => {
const result = await db
.select({
challenge: Cap_Challenges.data,
expires: Cap_Challenges.expires,
})
.from(Cap_Challenges)
.where(
and(
eq(Cap_Challenges.token, token),
gt(Cap_Challenges.expires, Date.now()),
),
)
.limit(1);
const data = result[0] as ChallengeData;
return result ? data : null;
},
delete: async (token) => {
await db.delete(Cap_Challenges).where(eq(Cap_Challenges.token, token));
},
deleteExpired: async () => {
await db
.delete(Cap_Challenges)
.where(lte(Cap_Challenges.expires, Date.now()));
},
},
tokens: {
store: async (tokenKey, expires) => {
await db
.insert(Cap_Tokens)
.values({ key: tokenKey, expires: expires })
.onConflictDoUpdate({
target: Cap_Tokens.key,
set: { expires: expires },
});
},
get: async (tokenKey) => {
const result = await db
.select({ expires: Cap_Tokens.expires })
.from(Cap_Tokens)
.where(
and(
eq(Cap_Tokens.key, tokenKey),
gt(Cap_Tokens.expires, Date.now()),
),
)
.limit(1);
return result ? result[0].expires : null;
},
delete: async (tokenKey) => {
await db.delete(Cap_Tokens).where(eq(Cap_Tokens.key, tokenKey));
},
deleteExpired: async () => {
await db.delete(Cap_Tokens).where(lte(Cap_Tokens.expires, Date.now()));
},
},
},
});
export default cap;

View file

@ -0,0 +1,13 @@
import type { APIRoute } from "astro";
import cap from "@lib/cap";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
return new Response(JSON.stringify(await cap.createChallenge()), {
status: 200,
});
} catch {
return new Response(JSON.stringify({ success: false }), { status: 400 });
}
};

14
src/pages/cap/redeem.ts Normal file
View file

@ -0,0 +1,14 @@
import type { APIRoute } from "astro";
import cap from "@lib/cap";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const { token, solutions } = await request.json();
if (!token || !solutions) {
return new Response(JSON.stringify({ success: false }), { status: 400 });
}
return new Response(
JSON.stringify(await cap.redeemChallenge({ token, solutions })),
{ status: 200 },
);
};

View file

@ -1,6 +1,7 @@
--- ---
import Layout from "@layouts/BaseLayout.astro"; import Layout from "@layouts/BaseLayout.astro";
import SmsClient from "@lib/SmsGatewayClient.ts"; import SmsClient from "@lib/SmsGatewayClient.ts";
import CapServer from "@lib/cap";
export const prerender = false; export const prerender = false;
const errors = { name: "", phone: "", msg: "", form: "" }; const errors = { name: "", phone: "", msg: "", form: "" };
@ -9,9 +10,14 @@ if (Astro.request.method === "POST") {
try { try {
const data = await Astro.request.formData(); const data = await Astro.request.formData();
const name = data.get("name")?.toString(); const name = data.get("name")?.toString();
const capToken = data.get("cap-token")?.toString();
const phone = data.get("phone")?.toString(); const phone = data.get("phone")?.toString();
const msg = data.get("msg")?.toString(); const msg = data.get("msg")?.toString();
if (typeof capToken !== "string" || !(await CapServer.validateToken(capToken)).success) {
throw new Error("invalid cap token");
}
if (typeof name !== "string" || name.length < 1) { if (typeof name !== "string" || name.length < 1) {
errors.name += "Please enter a name. "; errors.name += "Please enter a name. ";
} }
@ -38,7 +44,29 @@ if (Astro.request.method === "POST") {
} }
} }
--- ---
<script>import CapWidget from "@cap.js/widget";</script>
<style>
cap-widget {
--cap-background: #fdfdfd;
--cap-border-color: var(--border-color);
--cap-border-radius: 4px;
--cap-widget-height: 100%;
--cap-widget-width: 100%;
--cap-widget-padding: 10px 20px;
--cap-gap: 14px;
--cap-color: #212121;
--cap-checkbox-size: 25px;
--cap-checkbox-border: 1px solid #aaaaaad1;
--cap-checkbox-border-radius: 4px;
--cap-checkbox-background: #fafafa91;
--cap-checkbox-margin: 2px;
--cap-font: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular", "San Francisco",
"Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
--cap-spinner-color: #000;
--cap-spinner-background-color: #eee;
--cap-spinner-thickness: 5px;
}
</style>
<Layout> <Layout>
<title slot="head">Contact</title> <title slot="head">Contact</title>
<style> <style>
@ -48,10 +76,10 @@ if (Astro.request.method === "POST") {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
grid-template-areas: grid-template-areas:
"header header " "header header header header "
"name phone" "name name phone phone"
"msg msg" "msg msg msg msg"
" . submit"; "captcha captcha submit submit";
} }
div { div {
grid-area: header; grid-area: header;
@ -65,6 +93,10 @@ if (Astro.request.method === "POST") {
label[for="msg"] { label[for="msg"] {
grid-area: msg; grid-area: msg;
} }
label[for="captcha"] {
grid-area: captcha;
margin-bottom: 0;
}
button#submit { button#submit {
grid-area: submit; grid-area: submit;
} }
@ -91,8 +123,8 @@ if (Astro.request.method === "POST") {
<textarea id="msg" name="msg" placeholder="I think badblocks rocks!"></textarea> <textarea id="msg" name="msg" placeholder="I think badblocks rocks!"></textarea>
{errors.msg && <p>{errors.msg}</p>} {errors.msg && <p>{errors.msg}</p>}
</label> </label>
<label for="cap"> <label for="captcha">
<cap-widget data-cap-api-endpoint="<your cap endpoint>"></cap-widget> <cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
</label> </label>
<button id="submit" type="submit">Submit</button> <button id="submit" type="submit">Submit</button>
</form> || <p>Your message has been sent successfully!</p>} </form> || <p>Your message has been sent successfully!</p>}

View file

@ -1,5 +1,5 @@
{ {
"extends": "astro/tsconfigs/strictest", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"], "include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"], "exclude": ["dist"],
"compilerOptions": { "compilerOptions": {