From 64501c69a10323067dee739790b5a4fc5104e50d Mon Sep 17 00:00:00 2001
From: おさむのひと <46447427+samunohito@users.noreply.github.com>
Date: Tue, 14 Jan 2025 19:57:58 +0900
Subject: feat(frontend):
 Botプロテクションの設定変更時は実際に検証を通過しないと保存できないようにする
 (#15151)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(frontend): CAPTCHAの設定変更時は実際に検証を通過しないと保存できないようにする

* なしでも保存できるようにした

* fix CHANGELOG.md

* フォームが増殖するのを修正

* add comment

* add server-side verify

* fix ci

* fix

* fix

* fix i18n

* add current.ts

* fix text

* fix

* regenerate locales

* fix MkFormFooter.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 packages/frontend/src/components/MkCaptcha.vue | 64 ++++++++++++++++++++++++--
 1 file changed, 59 insertions(+), 5 deletions(-)

(limited to 'packages/frontend/src/components/MkCaptcha.vue')

diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index 264cf9af06..b1167bbac6 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -30,6 +30,9 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmount
 import { defaultStore } from '@/store.js';
 
 // APIs provided by Captcha services
+// see: https://docs.hcaptcha.com/configuration/#javascript-api
+// see: https://developers.google.com/recaptcha/docs/display?hl=ja
+// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget
 export type Captcha = {
 	render(container: string | Node, options: {
 		readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
@@ -53,6 +56,7 @@ declare global {
 const props = defineProps<{
 	provider: CaptchaProvider;
 	sitekey: string | null; // null will show error on request
+	secretKey?: string | null;
 	instanceUrl?: string | null;
 	modelValue?: string | null;
 }>();
@@ -64,7 +68,7 @@ const emit = defineEmits<{
 const available = ref(false);
 
 const captchaEl = shallowRef<HTMLDivElement | undefined>();
-
+const captchaWidgetId = ref<string | undefined>(undefined);
 const testcaptchaInput = ref('');
 const testcaptchaPassed = ref(false);
 
@@ -94,6 +98,15 @@ const scriptId = computed(() => `script-${props.provider}`);
 
 const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
 
+watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
+	// 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない
+	if (available.value) {
+		callback(undefined);
+		clearWidget();
+		await requestRender();
+	}
+});
+
 if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
 	available.value = true;
 } else if (src.value !== null) {
@@ -106,14 +119,38 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha')
 }
 
 function reset() {
-	if (captcha.value.reset) captcha.value.reset();
+	if (captcha.value.reset && captchaWidgetId.value !== undefined) {
+		try {
+			captcha.value.reset(captchaWidgetId.value);
+		} catch (error: unknown) {
+			// ignore
+			if (_DEV_) console.warn(error);
+		}
+	}
 	testcaptchaPassed.value = false;
 	testcaptchaInput.value = '';
 }
 
+function remove() {
+	if (captcha.value.remove && captchaWidgetId.value) {
+		try {
+			if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value);
+			captcha.value.remove(captchaWidgetId.value);
+		} catch (error: unknown) {
+			// ignore
+			if (_DEV_) console.warn(error);
+		}
+	}
+}
+
 async function requestRender() {
-	if (captcha.value.render && captchaEl.value instanceof Element) {
-		captcha.value.render(captchaEl.value, {
+	if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
+		// reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する.
+		// （同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので）
+		const elem = document.createElement('div');
+		captchaEl.value.appendChild(elem);
+
+		captchaWidgetId.value = captcha.value.render(elem, {
 			sitekey: props.sitekey,
 			theme: defaultStore.state.darkMode ? 'dark' : 'light',
 			callback: callback,
@@ -133,6 +170,23 @@ async function requestRender() {
 	}
 }
 
+function clearWidget() {
+	if (props.provider === 'mcaptcha') {
+		const container = document.getElementById('mcaptcha__widget-container');
+		if (container) {
+			container.innerHTML = '';
+		}
+	} else {
+		reset();
+		remove();
+
+		if (captchaEl.value) {
+			// レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止
+			captchaEl.value.innerHTML = '';
+		}
+	}
+}
+
 function callback(response?: string) {
 	emit('update:modelValue', typeof response === 'string' ? response : null);
 }
@@ -165,7 +219,7 @@ onUnmounted(() => {
 });
 
 onBeforeUnmount(() => {
-	reset();
+	clearWidget();
 });
 
 defineExpose({
-- 
cgit v1.2.3-freya

