From bdf4f9a0514607f873b825746ceea581c1632fae Mon Sep 17 00:00:00 2001
From: badbl0cks <4161747+badbl0cks@users.noreply.github.com>
Date: Tue, 6 Jan 2026 19:55:56 -0800
Subject: [PATCH] Add support for cap, a pow captcha, and implement on contact
form
---
db/config.ts | 20 +++++++++
src/lib/cap.ts | 85 ++++++++++++++++++++++++++++++++++++++
src/pages/cap/challenge.ts | 13 ++++++
src/pages/cap/redeem.ts | 14 +++++++
src/pages/contact.astro | 46 +++++++++++++++++----
tsconfig.json | 2 +-
6 files changed, 172 insertions(+), 8 deletions(-)
create mode 100644 db/config.ts
create mode 100644 src/lib/cap.ts
create mode 100644 src/pages/cap/challenge.ts
create mode 100644 src/pages/cap/redeem.ts
diff --git a/db/config.ts b/db/config.ts
new file mode 100644
index 0000000..dc78187
--- /dev/null
+++ b/db/config.ts
@@ -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 },
+});
diff --git a/src/lib/cap.ts b/src/lib/cap.ts
new file mode 100644
index 0000000..81fe97f
--- /dev/null
+++ b/src/lib/cap.ts
@@ -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;
diff --git a/src/pages/cap/challenge.ts b/src/pages/cap/challenge.ts
new file mode 100644
index 0000000..65a4105
--- /dev/null
+++ b/src/pages/cap/challenge.ts
@@ -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 });
+ }
+};
diff --git a/src/pages/cap/redeem.ts b/src/pages/cap/redeem.ts
new file mode 100644
index 0000000..d66f189
--- /dev/null
+++ b/src/pages/cap/redeem.ts
@@ -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 },
+ );
+};
diff --git a/src/pages/contact.astro b/src/pages/contact.astro
index 4bb6347..3ecbe81 100644
--- a/src/pages/contact.astro
+++ b/src/pages/contact.astro
@@ -1,6 +1,7 @@
---
import Layout from "@layouts/BaseLayout.astro";
import SmsClient from "@lib/SmsGatewayClient.ts";
+import CapServer from "@lib/cap";
export const prerender = false;
const errors = { name: "", phone: "", msg: "", form: "" };
@@ -9,9 +10,14 @@ if (Astro.request.method === "POST") {
try {
const data = await Astro.request.formData();
const name = data.get("name")?.toString();
+ const capToken = data.get("cap-token")?.toString();
const phone = data.get("phone")?.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) {
errors.name += "Please enter a name. ";
}
@@ -38,7 +44,29 @@ if (Astro.request.method === "POST") {
}
}
---
-
+
+
Contact