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:
		| @@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template> | ||||
| 	<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template> | ||||
| 	<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> | ||||
| 	<template v-if="botProtectionForm.modified.value" #footer> | ||||
| 		<MkFormFooter :form="botProtectionForm"/> | ||||
| 	<template #footer> | ||||
| 		<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/> | ||||
| 	</template> | ||||
|  | ||||
| 	<div class="_gaps_m"> | ||||
| 		<MkRadios v-model="botProtectionForm.state.provider"> | ||||
| 			<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> | ||||
| 			<option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> | ||||
| 			<option value="hcaptcha">hCaptcha</option> | ||||
| 			<option value="mcaptcha">mCaptcha</option> | ||||
| 			<option value="recaptcha">reCAPTCHA</option> | ||||
| @@ -28,70 +28,125 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 		</MkRadios> | ||||
|  | ||||
| 		<template v-if="botProtectionForm.state.provider === 'hcaptcha'"> | ||||
| 			<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce> | ||||
| 				<template #prefix><i class="ti ti-key"></i></template> | ||||
| 				<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce> | ||||
| 				<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="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> | ||||
| 			<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey"> | ||||
| 				<template #label>{{ i18n.ts._captcha.verify }}</template> | ||||
| 				<MkCaptcha | ||||
| 					v-model="captchaResult" | ||||
| 					provider="hcaptcha" | ||||
| 					:sitekey="botProtectionForm.state.hcaptchaSiteKey" | ||||
| 					:secretKey="botProtectionForm.state.hcaptchaSecretKey" | ||||
| 				/> | ||||
| 			</FormSlot> | ||||
| 			<MkInfo> | ||||
| 				<div :class="$style.captchaInfoMsg"> | ||||
| 					<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div> | ||||
| 					<div> | ||||
| 						<span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</MkInfo> | ||||
| 		</template> | ||||
|  | ||||
| 		<template v-else-if="botProtectionForm.state.provider === 'mcaptcha'"> | ||||
| 			<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce> | ||||
| 				<template #prefix><i class="ti ti-key"></i></template> | ||||
| 				<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce> | ||||
| 				<template #prefix><i class="ti ti-key"></i></template> | ||||
| 				<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl"> | ||||
| 			<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce> | ||||
| 				<template #prefix><i class="ti ti-link"></i></template> | ||||
| 				<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template> | ||||
| 			</MkInput> | ||||
| 			<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl"> | ||||
| 				<template #label>{{ i18n.ts.preview }}</template> | ||||
| 				<MkCaptcha provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey" :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"/> | ||||
| 				<template #label>{{ i18n.ts._captcha.verify }}</template> | ||||
| 				<MkCaptcha | ||||
| 					v-model="captchaResult" | ||||
| 					provider="mcaptcha" | ||||
| 					:sitekey="botProtectionForm.state.mcaptchaSiteKey" | ||||
| 					:secretKey="botProtectionForm.state.mcaptchaSecretKey" | ||||
| 					:instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl" | ||||
| 				/> | ||||
| 			</FormSlot> | ||||
| 		</template> | ||||
|  | ||||
| 		<template v-else-if="botProtectionForm.state.provider === 'recaptcha'"> | ||||
| 			<MkInput v-model="botProtectionForm.state.recaptchaSiteKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce> | ||||
| 				<template #prefix><i class="ti ti-key"></i></template> | ||||
| 				<template #label>{{ i18n.ts.recaptchaSiteKey }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkInput v-model="botProtectionForm.state.recaptchaSecretKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce> | ||||
| 				<template #prefix><i class="ti ti-key"></i></template> | ||||
| 				<template #label>{{ i18n.ts.recaptchaSecretKey }}</template> | ||||
| 			</MkInput> | ||||
| 			<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey"> | ||||
| 				<template #label>{{ i18n.ts.preview }}</template> | ||||
| 				<MkCaptcha provider="recaptcha" :sitekey="botProtectionForm.state.recaptchaSiteKey"/> | ||||
| 				<template #label>{{ i18n.ts._captcha.verify }}</template> | ||||
| 				<MkCaptcha | ||||
| 					v-model="captchaResult" | ||||
| 					provider="recaptcha" | ||||
| 					:sitekey="botProtectionForm.state.recaptchaSiteKey" | ||||
| 					:secretKey="botProtectionForm.state.recaptchaSecretKey" | ||||
| 				/> | ||||
| 			</FormSlot> | ||||
| 			<MkInfo> | ||||
| 				<div :class="$style.captchaInfoMsg"> | ||||
| 					<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div> | ||||
| 					<div> | ||||
| 						<span>ref: </span> | ||||
| 						<a | ||||
| 							href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do" | ||||
| 							target="_blank" | ||||
| 						>reCAPTCHA FAQ</a> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</MkInfo> | ||||
| 		</template> | ||||
|  | ||||
| 		<template v-else-if="botProtectionForm.state.provider === 'turnstile'"> | ||||
| 			<MkInput v-model="botProtectionForm.state.turnstileSiteKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce> | ||||
| 				<template #prefix><i class="ti ti-key"></i></template> | ||||
| 				<template #label>{{ i18n.ts.turnstileSiteKey }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkInput v-model="botProtectionForm.state.turnstileSecretKey"> | ||||
| 			<MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce> | ||||
| 				<template #prefix><i class="ti ti-key"></i></template> | ||||
| 				<template #label>{{ i18n.ts.turnstileSecretKey }}</template> | ||||
| 			</MkInput> | ||||
| 			<FormSlot> | ||||
| 				<template #label>{{ i18n.ts.preview }}</template> | ||||
| 				<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/> | ||||
| 			<FormSlot v-if="botProtectionForm.state.turnstileSiteKey"> | ||||
| 				<template #label>{{ i18n.ts._captcha.verify }}</template> | ||||
| 				<MkCaptcha | ||||
| 					v-model="captchaResult" | ||||
| 					provider="turnstile" | ||||
| 					:sitekey="botProtectionForm.state.turnstileSiteKey" | ||||
| 					:secretKey="botProtectionForm.state.turnstileSecretKey" | ||||
| 				/> | ||||
| 			</FormSlot> | ||||
| 			<MkInfo> | ||||
| 				<div :class="$style.captchaInfoMsg"> | ||||
| 					<div> | ||||
| 						{{ i18n.ts._captcha.testSiteKeyMessage }} | ||||
| 					</div> | ||||
| 					<div> | ||||
| 						<span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</MkInfo> | ||||
| 		</template> | ||||
|  | ||||
| 		<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'"> | ||||
| 			<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo> | ||||
| 			<FormSlot> | ||||
| 				<template #label>{{ i18n.ts.preview }}</template> | ||||
| 				<MkCaptcha provider="testcaptcha"/> | ||||
| 				<template #label>{{ i18n.ts._captcha.verify }}</template> | ||||
| 				<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/> | ||||
| 			</FormSlot> | ||||
| 		</template> | ||||
| 	</div> | ||||
| @@ -99,7 +154,8 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { defineAsyncComponent, ref } from 'vue'; | ||||
| import { computed, defineAsyncComponent, ref, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import MkRadios from '@/components/MkRadios.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import FormSlot from '@/components/form/slot.vue'; | ||||
| @@ -111,49 +167,107 @@ import { useForm } from '@/scripts/use-form.js'; | ||||
| import MkFormFooter from '@/components/MkFormFooter.vue'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import { ApiWithDialogCustomErrors } from '@/os.js'; | ||||
|  | ||||
| const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); | ||||
|  | ||||
| const meta = await misskeyApi('admin/meta'); | ||||
| const errorHandler: ApiWithDialogCustomErrors = { | ||||
| 	// 検証リクエストそのものに失敗 | ||||
| 	'0f4fe2f1-2c15-4d6e-b714-efbfcde231cd': { | ||||
| 		title: i18n.ts._captcha._error._requestFailed.title, | ||||
| 		text: i18n.ts._captcha._error._requestFailed.text, | ||||
| 	}, | ||||
| 	// 検証リクエストの結果が不正 | ||||
| 	'c41c067f-24f3-4150-84b2-b5a3ae8c2214': { | ||||
| 		title: i18n.ts._captcha._error._verificationFailed.title, | ||||
| 		text: i18n.ts._captcha._error._verificationFailed.text, | ||||
| 	}, | ||||
| 	// 不明なエラー | ||||
| 	'f868d509-e257-42a9-99c1-42614b031a97': { | ||||
| 		title: i18n.ts._captcha._error._unknown.title, | ||||
| 		text: i18n.ts._captcha._error._unknown.text, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| const captchaResult = ref<string | null>(null); | ||||
|  | ||||
| const meta = await misskeyApi('admin/captcha/current'); | ||||
| const botProtectionForm = useForm({ | ||||
| 	provider: meta.enableHcaptcha | ||||
| 		? 'hcaptcha' | ||||
| 		: meta.enableRecaptcha | ||||
| 			? 'recaptcha' | ||||
| 			: meta.enableTurnstile | ||||
| 				? 'turnstile' | ||||
| 				: meta.enableMcaptcha | ||||
| 					? 'mcaptcha' | ||||
| 					: meta.enableTestcaptcha | ||||
| 						? 'testcaptcha' | ||||
| 						: null, | ||||
| 	hcaptchaSiteKey: meta.hcaptchaSiteKey, | ||||
| 	hcaptchaSecretKey: meta.hcaptchaSecretKey, | ||||
| 	mcaptchaSiteKey: meta.mcaptchaSiteKey, | ||||
| 	mcaptchaSecretKey: meta.mcaptchaSecretKey, | ||||
| 	mcaptchaInstanceUrl: meta.mcaptchaInstanceUrl, | ||||
| 	recaptchaSiteKey: meta.recaptchaSiteKey, | ||||
| 	recaptchaSecretKey: meta.recaptchaSecretKey, | ||||
| 	turnstileSiteKey: meta.turnstileSiteKey, | ||||
| 	turnstileSecretKey: meta.turnstileSecretKey, | ||||
| 	provider: meta.provider, | ||||
| 	hcaptchaSiteKey: meta.hcaptcha.siteKey, | ||||
| 	hcaptchaSecretKey: meta.hcaptcha.secretKey, | ||||
| 	mcaptchaSiteKey: meta.mcaptcha.siteKey, | ||||
| 	mcaptchaSecretKey: meta.mcaptcha.secretKey, | ||||
| 	mcaptchaInstanceUrl: meta.mcaptcha.instanceUrl, | ||||
| 	recaptchaSiteKey: meta.recaptcha.siteKey, | ||||
| 	recaptchaSecretKey: meta.recaptcha.secretKey, | ||||
| 	turnstileSiteKey: meta.turnstile.siteKey, | ||||
| 	turnstileSecretKey: meta.turnstile.secretKey, | ||||
| }, async (state) => { | ||||
| 	await os.apiWithDialog('admin/update-meta', { | ||||
| 		enableHcaptcha: state.provider === 'hcaptcha', | ||||
| 		hcaptchaSiteKey: state.hcaptchaSiteKey, | ||||
| 		hcaptchaSecretKey: state.hcaptchaSecretKey, | ||||
| 		enableMcaptcha: state.provider === 'mcaptcha', | ||||
| 		mcaptchaSiteKey: state.mcaptchaSiteKey, | ||||
| 		mcaptchaSecretKey: state.mcaptchaSecretKey, | ||||
| 		mcaptchaInstanceUrl: state.mcaptchaInstanceUrl, | ||||
| 		enableRecaptcha: state.provider === 'recaptcha', | ||||
| 		recaptchaSiteKey: state.recaptchaSiteKey, | ||||
| 		recaptchaSecretKey: state.recaptchaSecretKey, | ||||
| 		enableTurnstile: state.provider === 'turnstile', | ||||
| 		turnstileSiteKey: state.turnstileSiteKey, | ||||
| 		turnstileSecretKey: state.turnstileSecretKey, | ||||
| 		enableTestcaptcha: state.provider === 'testcaptcha', | ||||
| 	}); | ||||
| 	fetchInstance(true); | ||||
| 	const provider = state.provider; | ||||
| 	if (provider === 'none') { | ||||
| 		await os.apiWithDialog( | ||||
| 			'admin/captcha/save', | ||||
| 			{ provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'] }, | ||||
| 			undefined, | ||||
| 			errorHandler, | ||||
| 		); | ||||
| 	} else { | ||||
| 		const sitekey = provider === 'hcaptcha' | ||||
| 			? state.hcaptchaSiteKey | ||||
| 			: provider === 'mcaptcha' | ||||
| 				? state.mcaptchaSiteKey | ||||
| 				: provider === 'recaptcha' | ||||
| 					? state.recaptchaSiteKey | ||||
| 					: provider === 'turnstile' | ||||
| 						? state.turnstileSiteKey | ||||
| 						: null; | ||||
| 		const secret = provider === 'hcaptcha' | ||||
| 			? state.hcaptchaSecretKey | ||||
| 			: provider === 'mcaptcha' | ||||
| 				? state.mcaptchaSecretKey | ||||
| 				: provider === 'recaptcha' | ||||
| 					? state.recaptchaSecretKey | ||||
| 					: provider === 'turnstile' | ||||
| 						? state.turnstileSecretKey | ||||
| 						: null; | ||||
|  | ||||
| 		await os.apiWithDialog( | ||||
| 			'admin/captcha/save', | ||||
| 			{ | ||||
| 				provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'], | ||||
| 				sitekey: sitekey, | ||||
| 				secret: secret, | ||||
| 				instanceUrl: state.mcaptchaInstanceUrl, | ||||
| 				captchaResult: captchaResult.value, | ||||
| 			}, | ||||
| 			undefined, | ||||
| 			errorHandler, | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	await fetchInstance(true); | ||||
| }); | ||||
|  | ||||
| watch(botProtectionForm.state, () => { | ||||
| 	captchaResult.value = null; | ||||
| }); | ||||
|  | ||||
| const canSaving = computed((): boolean => { | ||||
| 	return (botProtectionForm.state.provider === 'none') || | ||||
| 		(botProtectionForm.state.provider === 'hcaptcha' && !!captchaResult.value) || | ||||
| 		(botProtectionForm.state.provider === 'mcaptcha' && !!captchaResult.value) || | ||||
| 		(botProtectionForm.state.provider === 'recaptcha' && !!captchaResult.value) || | ||||
| 		(botProtectionForm.state.provider === 'turnstile' && !!captchaResult.value) || | ||||
| 		(botProtectionForm.state.provider === 'testcaptcha' && !!captchaResult.value); | ||||
| }); | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .captchaInfoMsg { | ||||
| 	display: flex; | ||||
| 	flex-direction: column; | ||||
| 	gap: 8px; | ||||
| } | ||||
| </style> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 おさむのひと
					おさむのひと