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 (
+
+ );
+};
+```
+
+
+
+
+### 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 = (