diff --git a/apps/docs/content/docs/dev/captcha.mdx b/apps/docs/content/docs/dev/captcha.mdx new file mode 100644 index 000000000..a84db8596 --- /dev/null +++ b/apps/docs/content/docs/dev/captcha.mdx @@ -0,0 +1,295 @@ +--- +title: Captcha +description: Protect your forms and API call with captcha validation. +--- + +## Support + +VitNode supports multiple captcha providers. You can choose the one that fits your needs. Currently, we support: + + + + + + +If you need more providers, feel free to open a **Feature Request** on our [GitHub repository](https://github.com/aXenDeveloper/vitnode/issues) :) + +## 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, getToken, onReset } = useCaptcha(captcha); + + const onSubmit = async () => { + await mutationApi({ + // ...other values, + captchaToken: await getToken(), // [!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/guides/captcha/cloudflare.mdx b/apps/docs/content/docs/guides/captcha/cloudflare.mdx index e8c054a63..daa076466 100644 --- a/apps/docs/content/docs/guides/captcha/cloudflare.mdx +++ b/apps/docs/content/docs/guides/captcha/cloudflare.mdx @@ -31,9 +31,11 @@ Follow the instructions to add your domain. If you have any trouble, you can che Go to the `Turnstile` section and create a new widget. -// import homeTurnstile from '@/assets/guides/captcha/cloudflare.png'; +import homeTurnstile from './cloudflare.png'; -{/* */} +import { ImgDocs } from '@/components/fumadocs/img'; + + @@ -62,8 +64,8 @@ After you create the widget, you will get the `Site Key` and `Secret Key`. Save Add the keys to your `.env` file. ```bash -CAPTCHA_SECRET_KEY=XXX -CAPTCHA_SITE_KEY=XXX +CLOUDFLARE_TURNSTILE_SITE_KEY=XXX +CLOUDFLARE_TURNSTILE_SECRET_KEY=XXX ``` @@ -72,35 +74,17 @@ CAPTCHA_SITE_KEY=XXX ## Provide keys to VitNode -Edit your `app.module.ts` file and add `captcha` object to the `VitNodeModule` configuration. - -```ts -@Module({ - imports: [ - VitNodeCoreModule.register({ - database: { - config: DATABASE_ENVS, - schemaDatabase, - }, - // [!code ++] - captcha: { - // [!code ++] - type: 'cloudflare_turnstile', - // [!code ++] - site_key: process.env.CAPTCHA_SITE_KEY, - // [!code ++] - secret_key: process.env.CAPTCHA_SECRET_KEY, - // [!code ++] - }, - }), - DatabaseModule, - PluginsModule, - CacheModule.register({ - isGlobal: true, - }), - ], -}) -export class AppModule {} +```ts title="src/vitnode.api.config.ts" +import { buildApiConfig } from '@vitnode/core/vitnode.config'; + +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:5] + captcha: { + type: 'cloudflare_turnstile', + siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY, + secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY, + }, +}); ``` diff --git a/apps/docs/content/docs/guides/captcha/cloudflare.png b/apps/docs/content/docs/guides/captcha/cloudflare.png new file mode 100644 index 000000000..8698d160c Binary files /dev/null and b/apps/docs/content/docs/guides/captcha/cloudflare.png differ diff --git a/apps/docs/content/docs/guides/captcha/google.mdx b/apps/docs/content/docs/guides/captcha/google.mdx deleted file mode 100644 index ba2179316..000000000 --- a/apps/docs/content/docs/guides/captcha/google.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Google reCAPTCHA -description: How to integrate Google reCAPTCHA in your application. ---- - -import { Step, Steps } from 'fumadocs-ui/components/steps'; - - - - -## Sign in to Google reCAPTCHA - -Go into [Google reCAPTCHA](https://www.google.com/recaptcha) and sign in. - - - - - -## Register a new site - -Select `Create` button inside the `Register a new site` section. - -// import homeRecaptcha from '@/assets/guides/captcha/google.png'; - -{/* */} - -Add your domain and select the reCAPTCHA type you want to use. - - - - - -## Add keys to .env - -Add the keys to your `.env` file. - -```bash -CAPTCHA_SECRET_KEY=XXX -CAPTCHA_SITE_KEY=XXX -``` - - - - - -## Provide keys to VitNode - -Edit your `app.module.ts` file and add `captcha` object to the `VitNodeModule` configuration. - -```ts -@Module({ - imports: [ - VitNodeCoreModule.register({ - database: { - config: DATABASE_ENVS, - schemaDatabase, - }, - // [!code ++] - captcha: { - // [!code ++] - type: 'recaptcha_v3', // or 'recaptcha_v2_invisible' or 'recaptcha_v2_checkbox' - // [!code ++] - site_key: process.env.CAPTCHA_SITE_KEY, - // [!code ++] - secret_key: process.env.CAPTCHA_SECRET_KEY, - // [!code ++] - }, - }), - DatabaseModule, - PluginsModule, - CacheModule.register({ - isGlobal: true, - }), - ], -}) -export class AppModule {} -``` - - - - diff --git a/apps/docs/content/docs/guides/captcha/index.mdx b/apps/docs/content/docs/guides/captcha/index.mdx index cf8c1c0b0..b377a228f 100644 --- a/apps/docs/content/docs/guides/captcha/index.mdx +++ b/apps/docs/content/docs/guides/captcha/index.mdx @@ -9,7 +9,17 @@ Captcha is a security feature that protects your application from spam, abuse, a VitNode provides a build-in way to integrate Captcha in your application for: -- [Google reCAPTCHA](/docs/guides/captcha/google) -- [Cloudflare Turnstile](/docs/guides/captcha/cloudflare) + + + + Choose one of the providers and follow the guide to integrate it into your application. diff --git a/apps/docs/content/docs/guides/captcha/recaptcha.mdx b/apps/docs/content/docs/guides/captcha/recaptcha.mdx new file mode 100644 index 000000000..b1ccd6175 --- /dev/null +++ b/apps/docs/content/docs/guides/captcha/recaptcha.mdx @@ -0,0 +1,74 @@ +--- +title: Google reCAPTCHA +description: How to integrate Google reCAPTCHA in your application. +--- + +import { Step, Steps } from 'fumadocs-ui/components/steps'; + + + + +## Sign in to Google reCAPTCHA + +Go into [Google reCAPTCHA](https://www.google.com/recaptcha/admin/) and sign in. + + + + + +## Register a new site + +Select `Create` button inside the `Register a new site` section. + +import homeRecaptcha from './recaptcha.png'; + +import { ImgDocs } from '@/components/fumadocs/img'; + + + +Add your domain and select the `reCAPTCHA v3` type. + + + In VitNode, we want to move forward with the latest technologies and + standards. Therefore, we do not support reCAPTCHA v2. + + + + + + +## Add keys to .env + +Add the keys to your `.env` file. + +```bash +RECAPTCHA_SITE_KEY=XXX +RECAPTCHA_SECRET_KEY=XXX +``` + + + + + +## Provide keys to VitNode + +```ts title="src/vitnode.api.config.ts" +import { buildApiConfig } from '@vitnode/core/vitnode.config'; + +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:5] + captcha: { + type: 'recaptcha_v3', + siteKey: process.env.RECAPTCHA_SITE_KEY, + secretKey: process.env.RECAPTCHA_SECRET_KEY, + }, +}); +``` + + + + diff --git a/apps/docs/content/docs/guides/captcha/recaptcha.png b/apps/docs/content/docs/guides/captcha/recaptcha.png new file mode 100644 index 000000000..0247cfda1 Binary files /dev/null and b/apps/docs/content/docs/guides/captcha/recaptcha.png differ 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/docs/src/app/global.css b/apps/docs/src/app/global.css index 94a49a750..5c1187fe1 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -22,7 +22,7 @@ --accent: oklch(0.95 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.6 0.2 24.45); - --warn: oklch(0.57 0.13 82.37); + --warn: oklch(0.54 0.12 82.58); --border: oklch(0.9 0 0); --input: oklch(0.9 0 0); --ring: oklch(0.7 0.16 262.61); diff --git a/apps/docs/src/components/fumadocs/img.tsx b/apps/docs/src/components/fumadocs/img.tsx index 6592436eb..dabff1d56 100644 --- a/apps/docs/src/components/fumadocs/img.tsx +++ b/apps/docs/src/components/fumadocs/img.tsx @@ -7,9 +7,13 @@ export const ImgDocs = ({ ...props }: React.ComponentProps) => { return ( - +
+ +
); }; diff --git a/apps/docs/src/content/docs/guides/captcha/cloudflare.mdx b/apps/docs/src/content/docs/guides/captcha/cloudflare.mdx deleted file mode 100644 index e8c054a63..000000000 --- a/apps/docs/src/content/docs/guides/captcha/cloudflare.mdx +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Cloudflare Turnstile -description: How to integrate Cloudflare Turnstile in your application. ---- - -import { Step, Steps } from 'fumadocs-ui/components/steps'; - - - - - -## Sign in to Cloudflare - -Go into [Cloudflare](https://dash.cloudflare.com/) and sign in. - - - - - -## Add a domain - -Select `Add a domain` button and add your domain. - -Follow the instructions to add your domain. If you have any trouble, you can check the [Cloudflare documentation](https://developers.cloudflare.com/fundamentals/setup/manage-domains/add-site/). - - - - - -## Create a widget - -Go to the `Turnstile` section and create a new widget. - -// import homeTurnstile from '@/assets/guides/captcha/cloudflare.png'; - -{/* */} - - - - - -## Configure the widget - -Configure the widget to your needs. We support all the mods that Cloudflare provides like `Managed`, `Non-interactive` and `Invisible`. - -Remember to add your hostname to the `Hostname Management` list. - - - - - -## Get the Site Key & Secret Key - -After you create the widget, you will get the `Site Key` and `Secret Key`. Save it, You will need these keys to integrate the widget into your application. - - - - - -## Add keys to .env - -Add the keys to your `.env` file. - -```bash -CAPTCHA_SECRET_KEY=XXX -CAPTCHA_SITE_KEY=XXX -``` - - - - - -## Provide keys to VitNode - -Edit your `app.module.ts` file and add `captcha` object to the `VitNodeModule` configuration. - -```ts -@Module({ - imports: [ - VitNodeCoreModule.register({ - database: { - config: DATABASE_ENVS, - schemaDatabase, - }, - // [!code ++] - captcha: { - // [!code ++] - type: 'cloudflare_turnstile', - // [!code ++] - site_key: process.env.CAPTCHA_SITE_KEY, - // [!code ++] - secret_key: process.env.CAPTCHA_SECRET_KEY, - // [!code ++] - }, - }), - DatabaseModule, - PluginsModule, - CacheModule.register({ - isGlobal: true, - }), - ], -}) -export class AppModule {} -``` - - - - diff --git a/apps/docs/src/content/docs/guides/captcha/google.mdx b/apps/docs/src/content/docs/guides/captcha/google.mdx deleted file mode 100644 index ba2179316..000000000 --- a/apps/docs/src/content/docs/guides/captcha/google.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Google reCAPTCHA -description: How to integrate Google reCAPTCHA in your application. ---- - -import { Step, Steps } from 'fumadocs-ui/components/steps'; - - - - -## Sign in to Google reCAPTCHA - -Go into [Google reCAPTCHA](https://www.google.com/recaptcha) and sign in. - - - - - -## Register a new site - -Select `Create` button inside the `Register a new site` section. - -// import homeRecaptcha from '@/assets/guides/captcha/google.png'; - -{/* */} - -Add your domain and select the reCAPTCHA type you want to use. - - - - - -## Add keys to .env - -Add the keys to your `.env` file. - -```bash -CAPTCHA_SECRET_KEY=XXX -CAPTCHA_SITE_KEY=XXX -``` - - - - - -## Provide keys to VitNode - -Edit your `app.module.ts` file and add `captcha` object to the `VitNodeModule` configuration. - -```ts -@Module({ - imports: [ - VitNodeCoreModule.register({ - database: { - config: DATABASE_ENVS, - schemaDatabase, - }, - // [!code ++] - captcha: { - // [!code ++] - type: 'recaptcha_v3', // or 'recaptcha_v2_invisible' or 'recaptcha_v2_checkbox' - // [!code ++] - site_key: process.env.CAPTCHA_SITE_KEY, - // [!code ++] - secret_key: process.env.CAPTCHA_SECRET_KEY, - // [!code ++] - }, - }), - DatabaseModule, - PluginsModule, - CacheModule.register({ - isGlobal: true, - }), - ], -}) -export class AppModule {} -``` - - - - diff --git a/apps/docs/src/content/docs/guides/captcha/index.mdx b/apps/docs/src/content/docs/guides/captcha/index.mdx deleted file mode 100644 index cf8c1c0b0..000000000 --- a/apps/docs/src/content/docs/guides/captcha/index.mdx +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Captcha -description: Protecting your application from spam, abuse, and bot attacks is an essential feature ---- - -Captcha is a security feature that protects your application from spam, abuse, and bot attacks. It's a simple test that only humans can pass, ensuring that the user is a real person and not a bot. - -## Supported Captcha providers - -VitNode provides a build-in way to integrate Captcha in your application for: - -- [Google reCAPTCHA](/docs/guides/captcha/google) -- [Cloudflare Turnstile](/docs/guides/captcha/cloudflare) - -Choose one of the providers and follow the guide to integrate it into your application. diff --git a/apps/web/src/app/global.css b/apps/web/src/app/global.css index ef1bf8194..137b81a96 100644 --- a/apps/web/src/app/global.css +++ b/apps/web/src/app/global.css @@ -23,7 +23,7 @@ --accent: oklch(0.95 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.6 0.2 24.45); - --warn: oklch(0.57 0.13 82.37); + --warn: oklch(0.54 0.12 82.58); --border: oklch(0.9 0 0); --input: oklch(0.9 0 0); --ring: oklch(0.7 0.16 262.61); diff --git a/apps/web/src/locales/@vitnode/core/en.json b/apps/web/src/locales/@vitnode/core/en.json index a9e3936d6..30671a72d 100644 --- a/apps/web/src/locales/@vitnode/core/en.json +++ b/apps/web/src/locales/@vitnode/core/en.json @@ -41,6 +41,7 @@ "internal_server_error": "Internal server error.", "field_required": "This field is required.", "field_min_length": "This field must be at least {min} characters.", + "captcha_internal_error": "Captcha validation failed. Please try again later.", "404": { "title": "Page Not Found", "desc": "Oops! The page you're looking for doesn't exist." 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..a85722758 --- /dev/null +++ b/packages/vitnode/src/api/middlewares/captcha.middleware.ts @@ -0,0 +1,93 @@ +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'], + }; + } else if (captchaConfig.type === 'recaptcha_v3') { + const res = await fetch( + `https://www.google.com/recaptcha/api/siteverify?secret=${captchaConfig.secretKey}&response=${token}&remoteip=${userIp}`, + { + method: 'POST', + }, + ); + + const data: { + 'error-codes'?: string[]; + score: number; + success: boolean; + } = await res.json(); + + return { + success: data.success, + score: data.score ?? 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..90179811d 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.enum(['cloudflare_turnstile', 'recaptcha_v3']), + }) + .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..dba866900 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, + getToken: getTokenCaptcha, + 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: captcha ? await getTokenCaptcha() : '', + }); + + if (captcha) { + onResetCaptcha(); + } } }; const submitButton = (