From 57ee827474e359db6d7f748cbb2c2226f30d962a Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 29 Jun 2025 13:15:26 +0200 Subject: [PATCH 1/4] feat: Add captcha --- apps/docs/content/docs/dev/captcha.mdx | 276 ++++++++++++++++++ apps/docs/content/docs/dev/index.mdx | 4 + apps/docs/content/docs/dev/meta.json | 1 + apps/docs/content/docs/ui/auto-form.mdx | 9 +- apps/web/src/vitnode.api.config.ts | 5 + packages/vitnode/src/api/config.ts | 1 + packages/vitnode/src/api/lib/route.ts | 3 + .../src/api/middlewares/captcha.middleware.ts | 74 +++++ .../src/api/middlewares/global.middleware.ts | 8 +- .../src/api/modules/middleware/route.ts | 33 ++- .../api/modules/users/routes/sign-up.route.ts | 1 + .../vitnode/src/components/form/auto-form.tsx | 37 ++- packages/vitnode/src/hooks/use-captcha.ts | 73 +++++ packages/vitnode/src/lib/fetcher-client.ts | 9 + packages/vitnode/src/lib/fetcher.ts | 20 +- .../src/views/auth/sign-in/form/use-form.ts | 4 +- .../src/views/auth/sign-up/form/form.tsx | 12 +- .../views/auth/sign-up/form/mutation-api.ts | 6 +- .../src/views/auth/sign-up/form/use-form.ts | 13 +- .../src/views/auth/sign-up/sign-up-view.tsx | 4 +- packages/vitnode/src/vitnode.config.ts | 5 + .../actions/create-edit/create-edit.tsx | 13 +- .../posts/actions/create-edit/create-edit.tsx | 13 +- 23 files changed, 576 insertions(+), 48 deletions(-) create mode 100644 apps/docs/content/docs/dev/captcha.mdx create mode 100644 packages/vitnode/src/api/middlewares/captcha.middleware.ts create mode 100644 packages/vitnode/src/hooks/use-captcha.ts diff --git a/apps/docs/content/docs/dev/captcha.mdx b/apps/docs/content/docs/dev/captcha.mdx new file mode 100644 index 000000000..99aaae471 --- /dev/null +++ b/apps/docs/content/docs/dev/captcha.mdx @@ -0,0 +1,276 @@ +--- +title: Captcha +description: Protect your forms and API call with captcha validation. +--- + +## Usage + +In this example, we will show you how to use captcha in your forms. We will use the `AutoForm` component to render the form and handle the captcha validation. + +import { Step, Steps } from 'fumadocs-ui/components/steps'; + + + + +### Activate captcha in route + +Add `withCaptcha` to your route config to enable captcha validation for this route. + +```ts title="plugins/{plugin_name}/src/routes/example.ts" +import { buildRoute } from '@vitnode/core/api/lib/route'; + +export const exampleRoute = buildRoute({ + ...CONFIG_PLUGIN, + route: { + method: 'post', + description: 'Create a new user', + path: '/sign_up', + withCaptcha: true, // [!code ++] + }, + handler: async c => {}, +}); +``` + + + + +### Get config from middleware API + +Get captcha config from middleware API in your view and pass it to your `'use client';` component. + +```tsx title="plugins/{plugin_name}/src/app/sing_up/page.tsx" +import { getMiddlewareApi } from '@vitnode/core/lib/api/get-middleware-api'; // [!code ++] + +export const SignUpView = async () => { + const { captcha } = await getMiddlewareApi(); // [!code ++] + + return ; +}; +``` + + + + +### Use in form + +Get the `captcha` config from the props and pass it to the `AutoForm` component. This will render the captcha widget in your form. + +```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" +'use client'; + +import { AutoForm } from '@vitnode/core/components/form/auto-form'; + +export const FormSignUp = ({ + captcha, // [!code ++] +}: { + captcha: z.infer['captcha']; // [!code ++] +}) => { + return ( + + captcha={captcha} // [!code ++] + fields={[]} + formSchema={formSchema} + /> + ); +}; +``` + + + + + + +### Submit form with captcha + +In your form submission handler, you can get the `captchaToken` from the form submission context and pass it to your mutation API. + +```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" +'use client'; + +import { + AutoForm, + type AutoFormOnSubmit, // [!code ++] +} from '@vitnode/core/components/form/auto-form'; + +export const FormSignUp = ({ + captcha, +}: { + captcha: z.infer['captcha']; +}) => { + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + { captchaToken }, // [!code ++] + ) => { + // Call your mutation API with captcha token + await mutationApi({ + ...values, + captchaToken, // [!code ++] + }); + + // Handle success or error + }; + + return ( + + captcha={captcha} + fields={[]} + onSubmit={onSubmit} // [!code ++] + formSchema={formSchema} + /> + ); +}; +``` + +Next, you need to set `captchaToken` in your mutation API call. This token is provided by the `AutoForm` component when the form is submitted. + +```tsx title="plugins/{plugin_name}/src/components/form/sign-up/mutation-api.ts" +'use server'; + +import type { z } from 'zod'; + +import { fetcher } from '@vitnode/core/lib/fetcher'; + +export const mutationApi = async ({ + captchaToken, // [!code ++] + ...input + // [!code ++] +}: z.infer & { captchaToken }) => { + const res = await fetcher(usersModule, { + path: '/sign_up', + method: 'post', + module: 'users', + captchaToken, // [!code ++] + args: { + body: input, + }, + }); + + if (res.status !== 201) { + return { error: await res.text() }; + } + + const data = await res.json(); + + return { data }; +}; +``` + + + + +## Custom Usage + +If you want to use captcha in your custom form or somewhere else, follow these steps. + + + + +### Activate captcha in route + +```ts title="plugins/{plugin_name}/src/routes/example.ts" +import { buildRoute } from '@vitnode/core/api/lib/route'; + +export const exampleRoute = buildRoute({ + ...CONFIG_PLUGIN, + route: { + method: 'post', + description: 'Create a new user', + path: '/sign_up', + withCaptcha: true, // [!code ++] + }, + handler: async c => {}, +}); +``` + + + + +### Get config from middleware API + +```tsx title="plugins/{plugin_name}/src/app/sing_up/page.tsx" +import { getMiddlewareApi } from '@vitnode/core/lib/api/get-middleware-api'; // [!code ++] + +export const SignUpView = async () => { + const { captcha } = await getMiddlewareApi(); // [!code ++] + + return ; +}; +``` + + + + +### Use `useCaptcha` hook + +Inside your client component, use the `useCaptcha` hook to handle captcha rendering and validation. Remember to add `div` with `id="vitnode_captcha"` where you want the captcha widget to appear. + +```tsx title="plugins/{plugin_name}/src/components/form/sign-up/sign-up.tsx" +'use client'; + +import { AutoForm } from '@vitnode/core/components/form/auto-form'; + +export const FormSignUp = ({ + captcha, // [!code ++] +}: { + captcha: z.infer['captcha']; // [!code ++] +}) => { + // [!code ++] + const { isReady, token, onReset } = useCaptcha(captcha); + + const onSubmit = async () => { + await mutationApi({ + // ...other values, + captchaToken: token, // [!code ++] + }); + + // Handle success or error + // [!code ++] + onReset(); // Reset captcha after submission + }; + + return ( +
+ {/* Render captcha widget */} + {/* [!code ++] */} +
+ + + + ); +}; +``` + + + + +### Submit form with captcha + +```tsx title="plugins/{plugin_name}/src/components/form/sign-up/mutation-api.ts" +'use server'; + +import type { z } from 'zod'; + +import { fetcher } from '@vitnode/core/lib/fetcher'; + +export const mutationApi = async ({ + captchaToken, // [!code ++] +}: { + // [!code ++] + captchaToken; +}) => { + await fetcher(usersModule, { + path: '/test', + method: 'post', + module: 'blog', + captchaToken, // [!code ++] + }); +}; +``` + + + diff --git a/apps/docs/content/docs/dev/index.mdx b/apps/docs/content/docs/dev/index.mdx index fd066bb90..214cbeaea 100644 --- a/apps/docs/content/docs/dev/index.mdx +++ b/apps/docs/content/docs/dev/index.mdx @@ -27,3 +27,7 @@ npx create-vitnode-app@canary ``` + +## Why VitNode? + +something here diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index 8dbb14c8c..f60c036b0 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -21,6 +21,7 @@ "---Framework---", "config", "logging", + "captcha", "---Advanced---", "..." ] diff --git a/apps/docs/content/docs/ui/auto-form.mdx b/apps/docs/content/docs/ui/auto-form.mdx index 8f26ba47c..a17ece443 100644 --- a/apps/docs/content/docs/ui/auto-form.mdx +++ b/apps/docs/content/docs/ui/auto-form.mdx @@ -131,11 +131,12 @@ The `onSubmit` callback provides access to the React Hook Form instance as a sec You can also define the submission handler separately: +```ts +import type { AutoFormOnSubmit } from '@vitnode/core/components/form/auto-form'; +``` + ```tsx -const onSubmit = async ( - values: z.infer, - form: UseFormReturn>, -) => { +const onSubmit: AutoFormOnSubmit = async (values, form) => { try { await saveData(values); toast.success('Form submitted successfully'); diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index e4b7d3537..1a4dceb94 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -17,6 +17,11 @@ export const POSTGRES_URL = process.env.POSTGRES_URL || 'postgresql://root:root@localhost:5432/vitnode'; export const vitNodeApiConfig = buildApiConfig({ + captcha: { + type: 'cloudflare_turnstile', + siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY, + secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY, + }, plugins: [blogApiPlugin()], dbProvider: drizzle({ connection: POSTGRES_URL, diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index be3e25d23..bb8795796 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -65,6 +65,7 @@ export function VitNodeAPI({ metadata: vitNodeConfig.metadata, authorization: vitNodeApiConfig.authorization, dbProvider: vitNodeApiConfig.dbProvider, + captcha: vitNodeApiConfig.captcha, }), ); app.use(async (c, next) => { diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts index a63e72d0b..b29681fc7 100644 --- a/packages/vitnode/src/api/lib/route.ts +++ b/packages/vitnode/src/api/lib/route.ts @@ -2,6 +2,7 @@ import type { RouteConfig, RouteHandler } from '@hono/zod-openapi'; import { createRoute as createRouteHono } from '@hono/zod-openapi'; +import { captchaMiddleware } from '../middlewares/captcha.middleware'; import { type EnvVitNode, pluginMiddleware, @@ -21,6 +22,7 @@ export const buildRoute = < P extends string, R extends Omit & { path: P; + withCaptcha?: boolean; }, H extends ValidHandler, >({ @@ -50,6 +52,7 @@ export const buildRoute = < tags, middleware: [ pluginMiddleware(pluginId), + ...(route.withCaptcha ? [captchaMiddleware()] : []), ...(Array.isArray(route.middleware) ? route.middleware : route.middleware diff --git a/packages/vitnode/src/api/middlewares/captcha.middleware.ts b/packages/vitnode/src/api/middlewares/captcha.middleware.ts new file mode 100644 index 000000000..8a4708291 --- /dev/null +++ b/packages/vitnode/src/api/middlewares/captcha.middleware.ts @@ -0,0 +1,74 @@ +import type { Context, Next } from 'hono'; + +import { HTTPException } from 'hono/http-exception'; + +import type { VitNodeApiConfig } from '../../vitnode.config'; + +const getResFromReCaptcha = async ({ + token, + userIp, + captchaConfig, +}: { + captchaConfig: NonNullable['captcha']>; + token: string; + userIp: string; +}): Promise<{ 'error-codes'?: string[]; score: number; success: boolean }> => { + if (captchaConfig.type === 'cloudflare_turnstile') { + const res = await fetch( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + { + method: 'POST', + body: JSON.stringify({ + secret: captchaConfig.secretKey, + response: token, + remoteip: userIp, + }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + const data: { + 'error-codes'?: string[]; + success: boolean; + } = await res.json(); + + return { + success: data.success, + score: data.success ? 1 : 0, + 'error-codes': data['error-codes'], + }; + } + + return { + success: false, + score: 0, + }; +}; + +export const captchaMiddleware = () => { + return async (c: Context, next: Next) => { + const token = c.req.header('x-vitnode-captcha-token'); + const captchaConfig = c.get('core').captcha; + if (!token || !captchaConfig) { + throw new HTTPException(400, { + message: 'Captcha token is required', + }); + } + + const res = await getResFromReCaptcha({ + token, + userIp: c.get('ipAddress'), + captchaConfig, + }); + + if (!res.success || res.score < 0.5) { + throw new HTTPException(400, { + message: 'Captcha validation failed', + }); + } + + await next(); + }; +}; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 50b47c731..8fa55d51c 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -45,6 +45,7 @@ interface EnvVariablesVitNode { deviceCookieName: string; ssoAdapters: SSOApiPlugin[]; }; + captcha?: Pick['captcha']; emailAdapter?: EmailApiPlugin; metadata: { shortTitle?: string; @@ -81,7 +82,11 @@ export const globalMiddleware = ({ metadata, emailAdapter, dbProvider, -}: Pick & + captcha, +}: Pick< + VitNodeApiConfig, + 'authorization' | 'captcha' | 'dbProvider' | 'emailAdapter' +> & Pick) => { return async (c: Context, next: Next) => { // Collect possible IP header keys in order of trust/preference @@ -146,6 +151,7 @@ export const globalMiddleware = ({ authorization?.adminCookieExpires ?? 1000 * 60 * 60 * 24 * 1, // 1 day cookieSecure: authorization?.cookieSecure ?? true, }, + captcha, }); const user = await new SessionModel(c).getUser(); diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts index 9777d7611..fde23134b 100644 --- a/packages/vitnode/src/api/modules/middleware/route.ts +++ b/packages/vitnode/src/api/modules/middleware/route.ts @@ -3,6 +3,17 @@ import { z } from 'zod'; import { buildRoute } from '@/api/lib/route'; import { CONFIG_PLUGIN } from '@/config'; +export const routeMiddlewareSchema = z.object({ + sso: z.array(z.object({ id: z.string(), name: z.string() })), + isEmail: z.boolean(), + captcha: z + .object({ + siteKey: z.string(), + type: z.literal('cloudflare_turnstile'), + }) + .optional(), +}); + export const routeMiddleware = buildRoute({ ...CONFIG_PLUGIN, route: { @@ -13,10 +24,7 @@ export const routeMiddleware = buildRoute({ 200: { content: { 'application/json': { - schema: z.object({ - sso: z.array(z.object({ id: z.string(), name: z.string() })), - isEmail: z.boolean(), - }), + schema: routeMiddlewareSchema, }, }, description: 'Middleware route', @@ -26,9 +34,18 @@ export const routeMiddleware = buildRoute({ handler: c => { const sso = c.get('core').authorization.ssoAdapters; - return c.json({ - isEmail: !!c.get('core').emailAdapter, - sso: sso.map(s => ({ id: s.id, name: s.name })), - }); + return c.json( + { + isEmail: !!c.get('core').emailAdapter, + sso: sso.map(s => ({ id: s.id, name: s.name })), + captcha: c.get('core').captcha + ? { + siteKey: c.get('core').captcha?.siteKey ?? '', + type: c.get('core').captcha?.type ?? 'cloudflare_turnstile', + } + : undefined, + }, + 200, + ); }, }); diff --git a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts index 98c191179..2fc877a46 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts @@ -32,6 +32,7 @@ export const signUpRoute = buildRoute({ method: 'post', description: 'Create a new user', path: '/sign_up', + withCaptcha: true, request: { body: { required: true, diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 8414e52d8..da57f45d5 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -9,13 +9,23 @@ import { useForm } from 'react-hook-form'; import { getDefaultValues, getObjectFormSchema } from '@/lib/helpers/auto-form'; +import type { routeMiddlewareSchema } from '../../api/modules/middleware/route'; import type { ItemAutoFormProps } from './fields/item'; +import { useCaptcha } from '../../hooks/use-captcha'; import { Button } from '../ui/button'; import { DialogClose, DialogFooter, useDialog } from '../ui/dialog'; import { Form } from '../ui/form'; import { ItemAutoForm } from './fields/item'; +export type AutoFormOnSubmit = ( + values: z.infer, + form: UseFormReturn>, + options: { + captchaToken: string; + }, +) => Promise | void; + export function AutoForm< T extends | z.ZodEffects> @@ -27,20 +37,24 @@ export function AutoForm< fields, submitButtonProps, mode, + captcha, ...props }: Omit, 'onSubmit'> & { + captcha?: z.infer['captcha']; fields: ItemAutoFormProps[]; formSchema: T; mode?: Mode; - onSubmit?: ( - values: z.infer, - form: UseFormReturn>, - ) => Promise | void; + onSubmit?: AutoFormOnSubmit; submitButtonProps?: Omit< React.ComponentProps, 'isLoading' | 'type' >; }) { + const { + isReady, + token: captchaToken, + onReset: onResetCaptcha, + } = useCaptcha(captcha); const { setIsDirty } = useDialog(); const objectFormSchema = getObjectFormSchema(formSchema); const defaultValues = getDefaultValues(objectFormSchema) as DefaultValues< @@ -56,13 +70,23 @@ export function AutoForm< const onSubmit = async (values: z.infer) => { const parsedValues = formSchema.safeParse(values); if (parsedValues.success) { - await onSubmitProp?.(parsedValues.data as z.infer, form); + await onSubmitProp?.(parsedValues.data as z.infer, form, { + captchaToken, + }); + + if (captcha) { + onResetCaptcha(); + } } }; const submitButton = (