1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-09-13 07:35:35 -04:00

Remove Cloudflare Turnstile integration and replace it with Vercel's BotID for spam protection in the contact form. Update environment variables and dependencies accordingly.

This commit is contained in:
2025-08-28 18:08:05 -04:00
parent d4f2b812ed
commit ab6b188a99
12 changed files with 533 additions and 500 deletions

View File

@@ -11,10 +11,8 @@ GITHUB_TOKEN=
RESEND_API_KEY=
RESEND_FROM_EMAIL=
RESEND_TO_EMAIL=
TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_GITHUB_REPO=
NEXT_PUBLIC_ONION_DOMAIN=
NEXT_PUBLIC_SITE_LOCALE=
NEXT_PUBLIC_SITE_TZ=
NEXT_PUBLIC_TURNSTILE_SITE_KEY=

View File

@@ -42,13 +42,11 @@ Required server environment variables:
- `GITHUB_TOKEN`: GitHub API token for projects page
- `RESEND_API_KEY`: Resend API key for contact form
- `RESEND_TO_EMAIL`: Destination email for contact form
- `TURNSTILE_SECRET_KEY`: Cloudflare Turnstile secret key
Required client environment variables:
- `NEXT_PUBLIC_GITHUB_REPO`: Repository in format "username/repo"
- `NEXT_PUBLIC_GITHUB_USERNAME`: GitHub username
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`: Cloudflare Turnstile site key
## Architecture

73
CLAUDE.md Normal file
View File

@@ -0,0 +1,73 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
```bash
# Start development server
pnpm dev
# Build for production
pnpm build
# Type check
pnpm typecheck
# Lint code
pnpm lint
# Generate database migrations
pnpm db:generate
# Apply database migrations
pnpm db:migrate
```
## Environment Setup
Environment variables are strictly type-checked in `lib/env.ts`. Copy `.env.example` to `.env` or `.env.local` and populate required values.
Key required variables:
- `DATABASE_URL`: PostgreSQL connection string (Neon)
- `AUTH_SECRET`: Random value for authentication encryption
- `AUTH_GITHUB_CLIENT_ID` / `AUTH_GITHUB_CLIENT_SECRET`: GitHub OAuth App credentials
- `RESEND_API_KEY` / `RESEND_TO_EMAIL`: For contact form emails
- `NEXT_PUBLIC_GITHUB_REPO` / `NEXT_PUBLIC_GITHUB_USERNAME`: For projects page
## Architecture
### Tech Stack
- **Next.js App Router**: Server components with TypeScript
- **Tailwind CSS v4**: Utility-first styling with shadcn/ui components
- **Drizzle ORM**: Type-safe database interactions with Neon PostgreSQL
- **Better Auth**: Authentication with GitHub OAuth
- **MDX**: Enhanced markdown for blog posts with custom plugins
### Key Structure
- `/app`: App Router pages and API routes
- `/components`: Feature-organized React components
- `/lib`: Core utilities, configs, and database schema
- `/notes`: MDX blog content files
- Database schema: `lib/db/schema.ts` with users, sessions, comments, and page views
### Content & Features
- Blog posts (`/notes`) are MDX files with frontmatter
- Comments system with nested threads
- Hit counter API (`/api/hits`)
- GitHub projects showcase
- Contact form with Vercel BotID protection
- RSS/Atom feeds
- Theme toggle (dark/light mode)
## Development Notes
- Always prefer React Server Components over client components
- Database operations use Drizzle ORM with Neon's serverless client
- MDX processing includes custom remark/rehype plugins configured in `next.config.ts`
- Deployment assumes Vercel with specific environment handling
- Strict ESLint and TypeScript configuration enforced

View File

@@ -0,0 +1,10 @@
import { initBotId } from "botid/client/core";
initBotId({
protect: [
{
path: "/contact",
method: "POST",
},
],
});

View File

@@ -43,6 +43,4 @@ Occasionally, embedded content from third-party services is included in posts, a
## Fighting Spam
Using [**Cloudflare Turnstile**](https://www.cloudflare.com/products/turnstile/) to fight bot spam on the [contact form](/contact) was an easy choice over seemingly unavoidable alternatives like [reCAPTCHA](https://developers.google.com/recaptcha/).
You can refer to Cloudflare's [privacy policy](https://www.cloudflare.com/privacypolicy/) and [terms of service](https://www.cloudflare.com/website-terms/) for more details. While some information is sent to the Turnstile API about your behavior (on the contact page only), at least you won't be helping a certain internet conglomerate [train their self-driving cars](https://blog.cloudflare.com/moving-from-recaptcha-to-hcaptcha/). 🚗
Form submissions on this site are protected by Vercel's [BotID](https://vercel.com/blog/introducing-botid) deep analysis. Refer to [Vercel's](https://vercel.com/legal/privacy-policy) and [Kasada's](https://www.kasada.io/privacy-policy/) privacy policies for details.

View File

@@ -1,8 +1,6 @@
"use client";
import { env } from "@/lib/env";
import { useActionState, useState } from "react";
import { Turnstile } from "@marsidev/react-turnstile";
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
import Link from "@/components/link";
import Input from "@/components/ui/input";
@@ -91,15 +89,6 @@ const ContactForm = () => {
</div>
</div>
<div>
<Turnstile siteKey={env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
{formState.errors?.["cf-turnstile-response"] && (
<span className="text-destructive text-[0.8rem] font-semibold">
{formState.errors["cf-turnstile-response"][0]}
</span>
)}
</div>
<div className="flex min-h-16 items-center space-x-4">
{!formState.success && (
<Button type="submit" size="lg" disabled={pending}>

View File

@@ -14,7 +14,9 @@ const compat = new FlatCompat({
/** @type {import("eslint").Linter.Config[]} */
export default [
{ ignores: ["README.md", ".next", ".vercel", "node_modules", "lib/db/migrations"] },
{
ignores: ["README.md", "next-env.d.ts", ".next", ".vercel", "node_modules", "lib/db/migrations"],
},
...compat.config({
plugins: ["react-compiler", "css-modules"],
extends: [

View File

@@ -60,13 +60,6 @@ export const env = createEnv({
/** Required. The destination email for contact form submissions. */
RESEND_TO_EMAIL: z.string().email(),
/**
* Required. Secret for Cloudflare `siteverify` API to validate a form's turnstile result on the backend.
*
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*/
TURNSTILE_SECRET_KEY: z.string().default("1x0000000000000000000000000000000AA"),
},
client: {
/**
@@ -146,14 +139,6 @@ export const env = createEnv({
* @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
*/
NEXT_PUBLIC_SITE_TZ: z.string().default("America/New_York"),
/**
* Required. Site key must be prefixed with NEXT_PUBLIC_ since it is used to embed the captcha widget. Falls back to
* testing keys if not set or in dev environment.
*
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
*/
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().default("1x00000000000000000000AA"),
},
experimental__runtimeEnv: {
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
@@ -163,7 +148,6 @@ export const env = createEnv({
NEXT_PUBLIC_ONION_DOMAIN: process.env.NEXT_PUBLIC_ONION_DOMAIN,
NEXT_PUBLIC_SITE_LOCALE: process.env.NEXT_PUBLIC_SITE_LOCALE,
NEXT_PUBLIC_SITE_TZ: process.env.NEXT_PUBLIC_SITE_TZ,
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
},
emptyStringAsUndefined: true,
skipValidation: !!process.env.SKIP_ENV_VALIDATION,

View File

@@ -1,17 +1,16 @@
"use server";
import { env } from "@/lib/env";
import { headers } from "next/headers";
import { Resend } from "resend";
import { z } from "zod";
import siteConfig from "@/lib/config/site";
import { checkBotId } from "botid/server";
const ContactSchema = z
.object({
name: z.string().trim().min(1, { message: "Your name is required." }),
email: z.string().email({ message: "Your email address is required." }),
message: z.string().trim().min(15, { message: "Your message must be at least 15 characters." }),
"cf-turnstile-response": z.string().min(1, { message: "Are you sure you're not a robot...? 🤖" }),
})
.readonly();
@@ -28,6 +27,12 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
console.debug("[server/resend] received payload:", payload);
try {
// BotID server-side verification
const verification = await checkBotId();
if (verification.isBot) {
return { success: false, message: "Bot detection failed. 🤖" };
}
const data = ContactSchema.safeParse(Object.fromEntries(payload));
if (!data.success) {
@@ -38,37 +43,6 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
};
}
// try to get the client IP (for turnstile accuracy, not logging!) but no biggie if we can't
let remoteip;
try {
remoteip = (await headers()).get("x-forwarded-for");
} catch {} // eslint-disable-line no-empty
// validate captcha
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: data.data["cf-turnstile-response"],
remoteip,
}),
cache: "no-store",
});
if (!turnstileResponse || !turnstileResponse.ok) {
throw new Error(`[server/resend] turnstile validation failed: ${turnstileResponse.status}`);
}
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
if (!turnstileData.success) {
return {
success: false,
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
};
}
if (env.RESEND_FROM_EMAIL === "onboarding@resend.dev") {
// https://resend.com/docs/api-reference/emails/send-email
console.warn("[server/resend] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");

View File

@@ -182,6 +182,8 @@ const nextPlugins: Array<
],
},
}),
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("botid/next/config").withBotId,
];
// eslint-disable-next-line import/no-anonymous-default-export

View File

@@ -22,12 +22,11 @@
"dependencies": {
"@date-fns/tz": "^1.4.1",
"@date-fns/utc": "^2.1.1",
"@marsidev/react-turnstile": "^1.3.0",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@neondatabase/serverless": "^1.0.1",
"@next/bundle-analyzer": "15.5.1-canary.9",
"@next/mdx": "15.5.1-canary.9",
"@next/bundle-analyzer": "15.5.1-canary.16",
"@next/mdx": "15.5.1-canary.16",
"@octokit/graphql": "^9.0.1",
"@octokit/graphql-schema": "^15.26.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -46,6 +45,7 @@
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"better-auth": "1.3.7",
"botid": "^1.5.4",
"cheerio": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -57,7 +57,7 @@
"geist": "^1.4.2",
"html-entities": "^2.6.0",
"lucide-react": "0.542.0",
"next": "15.5.1-canary.9",
"next": "15.5.1-canary.16",
"react": "19.1.1",
"react-activity-calendar": "^2.7.13",
"react-countup": "^6.5.3",
@@ -87,13 +87,13 @@
"remark-strip-mdx-imports-exports": "^1.0.1",
"resend": "^6.0.1",
"server-only": "0.0.1",
"shiki": "^3.11.0",
"shiki": "^3.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"tw-animate-css": "^1.3.7",
"unified": "^11.0.5",
"zod": "4.1.3"
"zod": "4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
@@ -102,14 +102,14 @@
"@tailwindcss/postcss": "^4.1.12",
"@types/mdx": "^2.0.13",
"@types/node": "^24.3.0",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.8",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"cross-env": "^10.0.0",
"dotenv": "^17.2.1",
"drizzle-kit": "^0.31.4",
"eslint": "^9.34.0",
"eslint-config-next": "15.5.1-canary.9",
"eslint-config-next": "15.5.1-canary.16",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-drizzle": "^0.2.3",

849
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff