From d786e96c2bb6d637be7289efdb6766ae4406af1f Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Sat, 2 Nov 2024 02:20:35 +0100
Subject: [PATCH] upd: add FriendlyCaptcha as a captcha solution

FriendlyCaptcha is a german captcha solution which is GDPR compliant and has a non-commerical free license
---
 .../1730505338000-friendlyCaptcha.js          | 20 +++++++++++++
 packages/backend/src/core/CaptchaService.ts   | 30 +++++++++++++++++++
 .../src/core/entities/MetaEntityService.ts    |  2 ++
 packages/backend/src/models/Meta.ts           | 17 +++++++++++
 .../backend/src/models/json-schema/meta.ts    |  8 +++++
 .../src/server/NodeinfoServerService.ts       |  1 +
 .../src/server/api/ApiServerService.ts        |  1 +
 .../src/server/api/SignupApiService.ts        |  7 +++++
 .../src/server/api/endpoints/admin/meta.ts    | 15 ++++++++++
 .../server/api/endpoints/admin/update-meta.ts | 15 ++++++++++
 packages/backend/test/e2e/2fa.ts              |  2 ++
 packages/frontend-embed/src/index.html        |  2 +-
 .../frontend/src/components/MkCaptcha.vue     | 15 +++++++++-
 .../src/components/MkSignupDialog.form.vue    |  6 ++++
 packages/frontend/src/index.html              |  6 ++--
 .../src/pages/admin/bot-protection.vue        | 25 +++++++++++++++-
 packages/frontend/src/pages/admin/index.vue   |  2 +-
 packages/misskey-js/src/autogen/types.ts      |  8 +++++
 18 files changed, 175 insertions(+), 7 deletions(-)
 create mode 100644 packages/backend/migration/1730505338000-friendlyCaptcha.js

diff --git a/packages/backend/migration/1730505338000-friendlyCaptcha.js b/packages/backend/migration/1730505338000-friendlyCaptcha.js
new file mode 100644
index 0000000000..94a4f22dfa
--- /dev/null
+++ b/packages/backend/migration/1730505338000-friendlyCaptcha.js
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: marie and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class friendlyCaptcha1730505338000 {
+	name = 'friendlyCaptcha1730505338000';
+
+	async up(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "meta" ADD "enableFC" boolean NOT NULL DEFAULT false`, undefined);
+		await queryRunner.query(`ALTER TABLE "meta" ADD "fcSiteKey" character varying(1024)`, undefined);
+		await queryRunner.query(`ALTER TABLE "meta" ADD "fcSecretKey" character varying(1024)`, undefined);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSecretKey"`, undefined);
+		await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSiteKey"`, undefined);
+		await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFC"`, undefined);
+	}
+}
diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts
index f6b7955cd2..5a424890f2 100644
--- a/packages/backend/src/core/CaptchaService.ts
+++ b/packages/backend/src/core/CaptchaService.ts
@@ -10,6 +10,7 @@ import { bindThis } from '@/decorators.js';
 type CaptchaResponse = {
 	success: boolean;
 	'error-codes'?: string[];
+	'errors'?: string[];
 };
 
 @Injectable()
@@ -73,6 +74,35 @@ export class CaptchaService {
 		}
 	}
 
+	@bindThis
+	public async verifyFriendlyCaptcha(secret: string, response: string | null | undefined): Promise<void> {
+		if (response == null) {
+			throw new Error('recaptcha-failed: no response provided');
+		}
+
+		const result = await this.httpRequestService.send('https://api.friendlycaptcha.com/api/v1/siteverify', {
+			method: 'POST',
+			body: JSON.stringify({
+				secret: secret,
+				solution: response,
+			}),
+			headers: {
+				'Content-Type': 'application/json',
+			},
+		});
+
+		if (result.status !== 200) {
+			throw new Error('frc-failed: frc didn\'t return 200 OK');
+		}
+
+		const resp = await result.json() as CaptchaResponse;
+
+		if (resp.success !== true) {
+			const errorCodes = resp['errors'] ? resp['errors'].join(', ') : '';
+			throw new Error(`frc-failed: ${errorCodes}`);
+		}
+	}
+
 	// https://codeberg.org/Gusted/mCaptcha/src/branch/main/mcaptcha.go
 	@bindThis
 	public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index 1c463fb0c9..b2b9aebb79 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -98,6 +98,8 @@ export class MetaEntityService {
 			recaptchaSiteKey: instance.recaptchaSiteKey,
 			enableTurnstile: instance.enableTurnstile,
 			turnstileSiteKey: instance.turnstileSiteKey,
+			enableFC: instance.enableFC,
+			fcSiteKey: instance.fcSiteKey,
 			swPublickey: instance.swPublicKey,
 			themeColor: instance.themeColor,
 			mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index 6f2c4ccf70..0ea6765d6a 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -269,6 +269,23 @@ export class MiMeta {
 	})
 	public turnstileSecretKey: string | null;
 
+	@Column('boolean', {
+		default: false,
+	})
+	public enableFC: boolean;
+
+	@Column('varchar', {
+		length: 1024,
+		nullable: true,
+	})
+	public fcSiteKey: string | null;
+
+	@Column('varchar', {
+		length: 1024,
+		nullable: true,
+	})
+	public fcSecretKey: string | null;
+
 	// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
 
 	@Column('enum', {
diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts
index 92aff24b4b..decdbd5650 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -127,6 +127,14 @@ export const packedMetaLiteSchema = {
 			type: 'string',
 			optional: false, nullable: true,
 		},
+		enableFC: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+		fcSiteKey: {
+			type: 'string',
+			optional: false, nullable: true,
+		},
 		enableAchievements: {
 			type: 'boolean',
 			optional: false, nullable: true,
diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts
index 9d33658756..6dee6ecd78 100644
--- a/packages/backend/src/server/NodeinfoServerService.ts
+++ b/packages/backend/src/server/NodeinfoServerService.ts
@@ -121,6 +121,7 @@ export class NodeinfoServerService {
 					enableRecaptcha: meta.enableRecaptcha,
 					enableMcaptcha: meta.enableMcaptcha,
 					enableTurnstile: meta.enableTurnstile,
+					enableFC: meta.enableFC,
 					maxNoteTextLength: this.config.maxNoteLength,
 					maxRemoteNoteTextLength: this.config.maxRemoteNoteLength,
 					maxCwLength: this.config.maxCwLength,
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 709a044601..ac3b982742 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -118,6 +118,7 @@ export class ApiServerService {
 				'hcaptcha-response'?: string;
 				'g-recaptcha-response'?: string;
 				'turnstile-response'?: string;
+				'frc-captcha-solution'?: string;
 			}
 		}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
 
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index f21e1bd683..db860d710a 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -72,6 +72,7 @@ export class SignupApiService {
 				'g-recaptcha-response'?: string;
 				'turnstile-response'?: string;
 				'm-captcha-response'?: string;
+				'frc-captcha-solution'?: string;
 			}
 		}>,
 		reply: FastifyReply,
@@ -104,6 +105,12 @@ export class SignupApiService {
 					throw new FastifyReplyError(400, err);
 				});
 			}
+
+			if (this.meta.enableFC && this.meta.fcSecretKey) {
+				await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => {
+					throw new FastifyReplyError(400, err);
+				});
+			}
 		}
 
 		const username = body['username'];
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 21116ba402..6e368eff43 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -73,6 +73,14 @@ export const meta = {
 				type: 'string',
 				optional: false, nullable: true,
 			},
+			enableFC: {
+				type: 'boolean',
+				optional: false, nullable: false,
+			},
+			fcSiteKey: {
+				type: 'string',
+				optional: false, nullable: true,
+			},
 			swPublickey: {
 				type: 'string',
 				optional: false, nullable: true,
@@ -219,6 +227,10 @@ export const meta = {
 				type: 'string',
 				optional: false, nullable: true,
 			},
+			fcSecretKey: {
+				type: 'string',
+				optional: false, nullable: true,
+			},
 			sensitiveMediaDetection: {
 				type: 'string',
 				optional: false, nullable: false,
@@ -600,6 +612,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				recaptchaSiteKey: instance.recaptchaSiteKey,
 				enableTurnstile: instance.enableTurnstile,
 				turnstileSiteKey: instance.turnstileSiteKey,
+				enableFC: instance.enableFC,
+				fcSiteKey: instance.fcSiteKey,
 				swPublickey: instance.swPublicKey,
 				themeColor: instance.themeColor,
 				mascotImageUrl: instance.mascotImageUrl,
@@ -634,6 +648,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				mcaptchaSecretKey: instance.mcaptchaSecretKey,
 				recaptchaSecretKey: instance.recaptchaSecretKey,
 				turnstileSecretKey: instance.turnstileSecretKey,
+				fcSecretKey: instance.fcSecretKey,
 				sensitiveMediaDetection: instance.sensitiveMediaDetection,
 				sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity,
 				setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 1a55dec322..98760bbcc3 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -81,6 +81,9 @@ export const paramDef = {
 		enableTurnstile: { type: 'boolean' },
 		turnstileSiteKey: { type: 'string', nullable: true },
 		turnstileSecretKey: { type: 'string', nullable: true },
+		enableFC: { type: 'boolean' },
+		fcSiteKey: { type: 'string', nullable: true },
+		fcSecretKey: { type: 'string', nullable: true },
 		sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
 		sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
 		setSensitiveFlagAutomatically: { type: 'boolean' },
@@ -383,6 +386,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				set.turnstileSecretKey = ps.turnstileSecretKey;
 			}
 
+			if (ps.enableFC !== undefined) {
+				set.enableFC = ps.enableFC;
+			}
+
+			if (ps.fcSiteKey !== undefined) {
+				set.fcSiteKey = ps.fcSiteKey;
+			}
+
+			if (ps.fcSecretKey !== undefined) {
+				set.fcSecretKey = ps.fcSecretKey;
+			}
+
 			if (ps.enableBotTrending !== undefined) {
 				set.enableBotTrending = ps.enableBotTrending;
 			}
diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts
index 06548fa7da..48da6ba27f 100644
--- a/packages/backend/test/e2e/2fa.ts
+++ b/packages/backend/test/e2e/2fa.ts
@@ -123,12 +123,14 @@ describe('2要素認証', () => {
 		password: string,
 		'g-recaptcha-response'?: string | null,
 		'hcaptcha-response'?: string | null,
+		'frc-captcha-solution'?: string | null,
 	} => {
 		return {
 			username,
 			password,
 			'g-recaptcha-response': null,
 			'hcaptcha-response': null,
+			'frc-captcha-solution': null,
 		};
 	};
 
diff --git a/packages/frontend-embed/src/index.html b/packages/frontend-embed/src/index.html
index 2dbfcc5ddf..55be2f4ec1 100644
--- a/packages/frontend-embed/src/index.html
+++ b/packages/frontend-embed/src/index.html
@@ -18,7 +18,7 @@
 		http-equiv="Content-Security-Policy"
 		content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
 			worker-src 'self';
-			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
+			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh https://cdn.jsdelivr.net;
 			style-src 'self' 'unsafe-inline';
 			img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
 			media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index c5b6e0caed..ab00ea9930 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -27,9 +27,12 @@ export type Captcha = {
 	execute(id: string): void;
 	reset(id?: string): void;
 	getResponse(id: string): string;
+	WidgetInstance(container: string | Node, options: {
+		readonly [_ in 'sitekey' | 'doneCallback' | 'errorCallback' | 'puzzleEndpoint']?: unknown;
+	}): void;
 };
 
-export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha';
+export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'fc';
 
 type CaptchaContainer = {
 	readonly [_ in CaptchaProvider]?: Captcha;
@@ -60,6 +63,7 @@ const variable = computed(() => {
 		case 'recaptcha': return 'grecaptcha';
 		case 'turnstile': return 'turnstile';
 		case 'mcaptcha': return 'mcaptcha';
+		case 'fc': return 'friendlyChallenge';
 	}
 });
 
@@ -70,6 +74,7 @@ const src = computed(() => {
 		case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off';
 		case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
 		case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
+		case 'fc': return 'https://cdn.jsdelivr.net/npm/friendly-challenge@0.9.18/widget.min.js';
 		case 'mcaptcha': return null;
 	}
 });
@@ -110,6 +115,14 @@ async function requestRender() {
 				key: props.sitekey,
 			},
 		});
+	} else if (variable.value === 'friendlyChallenge' && captchaEl.value instanceof Element) {
+		new captcha.value.WidgetInstance(captchaEl.value, {
+			sitekey: props.sitekey,
+			doneCallback: callback,
+			errorCallback: callback,
+		});
+		// The following line is needed so that the design gets applied without it the captcha will look broken
+		captchaEl.value.className = 'frc-captcha';
 	} else {
 		window.setTimeout(requestRender, 1);
 	}
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index 5a5c712a48..4c55831a3a 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
 			<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
 			<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
+			<MkCaptcha v-if="instance.enableFC" ref="fc" v-model="fcResponse" :class="$style.captcha" provider="fc" :sitekey="instance.fcSiteKey"/>
 			<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
 				<template v-if="submitting">
 					<MkLoading :em="true" :colored="false"/>
@@ -112,6 +113,7 @@ const host = toUnicode(config.host);
 const hcaptcha = ref<Captcha | undefined>();
 const recaptcha = ref<Captcha | undefined>();
 const turnstile = ref<Captcha | undefined>();
+const fc = ref<Captcha | undefined>();
 
 const username = ref<string>('');
 const password = ref<string>('');
@@ -128,6 +130,7 @@ const hCaptchaResponse = ref<string | null>(null);
 const mCaptchaResponse = ref<string | null>(null);
 const reCaptchaResponse = ref<string | null>(null);
 const turnstileResponse = ref<string | null>(null);
+const fcResponse = ref<string | null>(null);
 const usernameAbortController = ref<null | AbortController>(null);
 const emailAbortController = ref<null | AbortController>(null);
 
@@ -137,6 +140,7 @@ const shouldDisableSubmitting = computed((): boolean => {
 		instance.enableMcaptcha && !mCaptchaResponse.value ||
 		instance.enableRecaptcha && !reCaptchaResponse.value ||
 		instance.enableTurnstile && !turnstileResponse.value ||
+		instance.enableFC && !fcResponse.value ||
 		instance.emailRequiredForSignup && emailState.value !== 'ok' ||
 		usernameState.value !== 'ok' ||
 		passwordRetypeState.value !== 'match';
@@ -266,6 +270,7 @@ async function onSubmit(): Promise<void> {
 			'm-captcha-response': mCaptchaResponse.value,
 			'g-recaptcha-response': reCaptchaResponse.value,
 			'turnstile-response': turnstileResponse.value,
+			'frc-captcha-solution': fcResponse.value,
 		});
 		if (instance.emailRequiredForSignup) {
 			os.alert({
@@ -297,6 +302,7 @@ async function onSubmit(): Promise<void> {
 		hcaptcha.value?.reset?.();
 		recaptcha.value?.reset?.();
 		turnstile.value?.reset?.();
+		fc.value?.reset?.();
 
 		os.alert({
 			type: 'error',
diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html
index fdeb642c70..55d6b6cffd 100644
--- a/packages/frontend/src/index.html
+++ b/packages/frontend/src/index.html
@@ -17,12 +17,12 @@
 	<meta
 		http-equiv="Content-Security-Policy"
 		content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
-			worker-src 'self';
-			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
+			worker-src 'self' blob:;
+			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh https://cdn.jsdelivr.net;
 			style-src 'self' 'unsafe-inline';
 			img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
 			media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
-			connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org;
+			connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org https://api.friendlycaptcha.com;
 			frame-src *;"
 	/>
 	<meta property="og:site_name" content="[DEV BUILD] Misskey" />
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index b34592cd6a..644436cde6 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template>
 	<template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template>
 	<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
+	<template v-else-if="botProtectionForm.savedState.provider === 'fc'" #suffix>FriendlyCaptcha</template>
 	<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
 	<template v-if="botProtectionForm.modified.value" #footer>
 		<MkFormFooter :form="botProtectionForm"/>
@@ -23,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<option value="mcaptcha">mCaptcha</option>
 			<option value="recaptcha">reCAPTCHA</option>
 			<option value="turnstile">Turnstile</option>
+			<option value="fc">FriendlyCaptcha</option>
 		</MkRadios>
 
 		<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
@@ -85,6 +87,20 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/>
 			</FormSlot>
 		</template>
+		<template v-else-if="botProtectionForm.state.provider === 'fc'">
+			<MkInput v-model="botProtectionForm.state.fcSiteKey">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
+			</MkInput>
+			<MkInput v-model="botProtectionForm.state.fcSecretKey">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
+			</MkInput>
+			<FormSlot>
+				<template #label>{{ i18n.ts.preview }}</template>
+				<MkCaptcha provider="fc" :sitekey="botProtectionForm.state.fcSiteKey"/>
+			</FormSlot>
+		</template>
 	</div>
 </MkFolder>
 </template>
@@ -115,7 +131,9 @@ const botProtectionForm = useForm({
 				? 'turnstile'
 				: meta.enableMcaptcha
 					? 'mcaptcha'
-					: null,
+					: meta.enableFC
+						? 'fc'
+						: null,
 	hcaptchaSiteKey: meta.hcaptchaSiteKey,
 	hcaptchaSecretKey: meta.hcaptchaSecretKey,
 	mcaptchaSiteKey: meta.mcaptchaSiteKey,
@@ -125,6 +143,8 @@ const botProtectionForm = useForm({
 	recaptchaSecretKey: meta.recaptchaSecretKey,
 	turnstileSiteKey: meta.turnstileSiteKey,
 	turnstileSecretKey: meta.turnstileSecretKey,
+	fcSiteKey: meta.fcSiteKey,
+	fcSecretKey: meta.fcSecretKey,
 }, async (state) => {
 	await os.apiWithDialog('admin/update-meta', {
 		enableHcaptcha: state.provider === 'hcaptcha',
@@ -140,6 +160,9 @@ const botProtectionForm = useForm({
 		enableTurnstile: state.provider === 'turnstile',
 		turnstileSiteKey: state.turnstileSiteKey,
 		turnstileSecretKey: state.turnstileSecretKey,
+		enableFC: state.provider === 'fc',
+		fcSiteKey: state.fcSiteKey,
+		fcSecretKey: state.fcSecretKey,
 	});
 	fetchInstance(true);
 });
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 1045b1e2b1..e8d123060a 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -63,7 +63,7 @@ const view = ref(null);
 const el = ref<HTMLDivElement | null>(null);
 const pageProps = ref({});
 const noMaintainerInformation = computed(() => isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail));
-const noBotProtection = computed(() => !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile && !instance.enableMcaptcha);
+const noBotProtection = computed(() => !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile && !instance.enableMcaptcha && !instance.enableFC);
 const noEmailServer = computed(() => !instance.enableEmail);
 const noInquiryUrl = computed(() => isEmpty(instance.inquiryUrl));
 const thereIsUnresolvedAbuseReport = ref(false);
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 12fa73cbc0..2265805352 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -5142,6 +5142,8 @@ export type components = {
       recaptchaSiteKey: string | null;
       enableTurnstile: boolean;
       turnstileSiteKey: string | null;
+      enableFC: boolean;
+      fcSiteKey: string | null;
       enableAchievements: boolean | null;
       swPublickey: string | null;
       /** @default /assets/ai.png */
@@ -5281,6 +5283,8 @@ export type operations = {
             recaptchaSiteKey: string | null;
             enableTurnstile: boolean;
             turnstileSiteKey: string | null;
+            enableFC: boolean;
+            fcSiteKey: string | null;
             swPublickey: string | null;
             /** @default /assets/ai.png */
             mascotImageUrl: string | null;
@@ -5309,6 +5313,7 @@ export type operations = {
             mcaptchaSecretKey: string | null;
             recaptchaSecretKey: string | null;
             turnstileSecretKey: string | null;
+            fcSecretKey: string | null;
             sensitiveMediaDetection: string;
             sensitiveMediaDetectionSensitivity: string;
             setSensitiveFlagAutomatically: boolean;
@@ -9891,6 +9896,9 @@ export type operations = {
           enableTurnstile?: boolean;
           turnstileSiteKey?: string | null;
           turnstileSecretKey?: string | null;
+          enableFC?: boolean;
+          fcSiteKey?: string | null;
+          fcSecretKey?: string | null;
           /** @enum {string} */
           sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
           /** @enum {string} */
-- 
GitLab