feat(frontend): Botプロテクションの設定変更時は実際に検証を通過しないと保存できないようにする (#15151)

* 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>
This commit is contained in:
おさむのひと
2025-01-14 19:57:58 +09:00
committed by GitHub
parent 7fbfc2e046
commit 64501c69a1
19 changed files with 1597 additions and 89 deletions

View File

@@ -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({