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:
@@ -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=
|
||||
|
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -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
73
CLAUDE.md
Normal 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
|
10
app/instrumentation.client.ts
Normal file
10
app/instrumentation.client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { initBotId } from "botid/client/core";
|
||||
|
||||
initBotId({
|
||||
protect: [
|
||||
{
|
||||
path: "/contact",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
});
|
@@ -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.
|
||||
|
@@ -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}>
|
||||
|
@@ -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: [
|
||||
|
16
lib/env.ts
16
lib/env.ts
@@ -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,
|
||||
|
@@ -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.");
|
||||
|
@@ -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
|
||||
|
20
package.json
20
package.json
@@ -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
849
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user