diff --git a/locales/index.d.ts b/locales/index.d.ts index 91cdf54255..8500526758 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1670,10 +1670,6 @@ export interface Locale extends ILocale { * 複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。 */ "avoidMultiCaptchaConfirm": string; - /** - * サイトキーに"{testSiteKey}"と入力することでプレビューを確認できます。 - */ - "testSiteKeyMessage": ParameterizedString<"testSiteKey">; /** * アンテナ */ @@ -10605,6 +10601,49 @@ export interface Locale extends ILocale { */ "sent": string; }; + "_captcha": { + /** + * CAPTCHAを通過してください + */ + "verify": string; + /** + * サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。 + * 詳細は下記ページをご確認ください。 + */ + "testSiteKeyMessage": string; + "_error": { + "_requestFailed": { + /** + * CAPTCHAのリクエストに失敗しました + */ + "title": string; + /** + * しばらく後に実行するか、設定をもう一度ご確認ください。 + */ + "text": string; + }; + "_verificationFailed": { + /** + * CAPTCHAの検証に失敗しました + */ + "title": string; + /** + * 設定が正しいかどうかもう一度確認ください。 + */ + "text": string; + }; + "_unknown": { + /** + * CAPTCHAエラー + */ + "title": string; + /** + * 想定外のエラーが発生しました。 + */ + "text": string; + }; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1c0b25767c..eae6a26164 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -413,7 +413,6 @@ enableTurnstile: "Turnstileを有効にする" turnstileSiteKey: "サイトキー" turnstileSecretKey: "シークレットキー" avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。" -testSiteKeyMessage: "サイトキーに\"{testSiteKey}\"と入力することでプレビューを確認できます。" antennas: "アンテナ" manageAntennas: "アンテナの管理" name: "名前" @@ -2827,3 +2826,17 @@ _selfXssPrevention: _followRequest: recieved: "受け取った申請" sent: "送った申請" + +_captcha: + verify: "CAPTCHAを通過してください" + testSiteKeyMessage: "サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。\n詳細は下記ページをご確認ください。" + _error: + _requestFailed: + title: "CAPTCHAのリクエストに失敗しました" + text: "しばらく後に実行するか、設定をもう一度ご確認ください。" + _verificationFailed: + title: "CAPTCHAの検証に失敗しました" + text: "設定が正しいかどうかもう一度確認ください。" + _unknown: + title: "CAPTCHAエラー" + text: "想定外のエラーが発生しました。" diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/save.ts b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts index cc6186a8d6..98ec278ebe 100644 --- a/packages/backend/src/server/api/endpoints/admin/captcha/save.ts +++ b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts @@ -21,37 +21,37 @@ export const meta = { invalidProvider: { message: 'Invalid provider.', code: 'INVALID_PROVIDER', - id: '14BF7AE1-80CC-4363-ACB2-4FD61D086AF0', + id: '14bf7ae1-80cc-4363-acb2-4fd61d086af0', httpStatusCode: 400, }, invalidParameters: { message: 'Invalid parameters.', code: 'INVALID_PARAMETERS', - id: '26654194-410E-44E2-B42E-460FF6F92476', + id: '26654194-410e-44e2-b42e-460ff6f92476', httpStatusCode: 400, }, noResponseProvided: { message: 'No response provided.', code: 'NO_RESPONSE_PROVIDED', - id: '40ACBBA8-0937-41FB-BB3F-474514D40AFE', + id: '40acbba8-0937-41fb-bb3f-474514d40afe', httpStatusCode: 400, }, requestFailed: { message: 'Request failed.', code: 'REQUEST_FAILED', - id: '0F4FE2F1-2C15-4D6E-B714-EFBFCDE231CD', + id: '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd', httpStatusCode: 500, }, verificationFailed: { message: 'Verification failed.', code: 'VERIFICATION_FAILED', - id: 'C41C067F-24F3-4150-84B2-B5A3AE8C2214', + id: 'c41c067f-24f3-4150-84b2-b5a3ae8c2214', httpStatusCode: 400, }, unknown: { message: 'unknown', code: 'UNKNOWN', - id: 'F868D509-E257-42A9-99C1-42614B031A97', + id: 'f868d509-e257-42a9-99c1-42614b031a97', httpStatusCode: 500, }, }, diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index f22e906240..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(); - +const captchaWidgetId = ref(undefined); const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -94,10 +98,11 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed(() => window[variable.value] || {} as unknown as Captcha); -watch(() => [props.instanceUrl, props.sitekey], async () => { +watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { // 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない if (available.value) { callback(undefined); + clearWidget(); await requestRender(); } }); @@ -114,9 +119,9 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') } function reset() { - if (captcha.value.reset) { + if (captcha.value.reset && captchaWidgetId.value !== undefined) { try { - captcha.value.reset(); + captcha.value.reset(captchaWidgetId.value); } catch (error: unknown) { // ignore if (_DEV_) console.warn(error); @@ -126,28 +131,33 @@ function reset() { 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) { - // 設定値の変更時などのタイミングで再レンダリングを行う際はリセットしておく必要がある - reset(); - captchaEl.value.innerHTML = ''; + 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); - if (props.sitekey && props.sitekey.length > 0) { - captcha.value.render(captchaEl.value, { - sitekey: props.sitekey, - theme: defaultStore.state.darkMode ? 'dark' : 'light', - callback: callback, - 'expired-callback': () => callback(undefined), - 'error-callback': () => callback(undefined), - }); - } + captchaWidgetId.value = captcha.value.render(elem, { + sitekey: props.sitekey, + theme: defaultStore.state.darkMode ? 'dark' : 'light', + callback: callback, + 'expired-callback': () => callback(undefined), + 'error-callback': () => callback(undefined), + }); } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { - // 設定値の変更時などのタイミングで再レンダリングを行う際はコンテナ内をクリアしておかないと更新されるたびに増えていく - const container = document.getElementById('mcaptcha__widget-container'); - if (container) { - container.innerHTML = ''; - } - const { default: Widget } = await import('@mcaptcha/vanilla-glue'); new Widget({ siteKey: { @@ -160,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); } @@ -192,7 +219,7 @@ onUnmounted(() => { }); onBeforeUnmount(() => { - reset(); + clearWidget(); }); defineExpose({ diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index ea1b673de9..589ace0155 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -11,6 +11,7 @@ import * as Misskey from 'misskey-js'; import type { ComponentProps as CP } from 'vue-component-type-helpers'; import type { Form, GetFormResultType } from '@/scripts/form.js'; import type { MenuItem } from '@/types/menu.js'; +import type { PostFormProps } from '@/types/post-form.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -28,15 +29,15 @@ import { pleaseLogin } from '@/scripts/please-login.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; import { focusParent } from '@/scripts/focus.js'; -import type { PostFormProps } from '@/types/post-form.js'; export const openingWindowsCount = ref(0); +export type ApiWithDialogCustomErrors = Record; export const apiWithDialog = (( endpoint: E, data: P, token?: string | null | undefined, - customErrors?: Record, + customErrors?: ApiWithDialogCustomErrors, ) => { const promise = misskeyApi(endpoint, data, token); promiseDialog(promise, null, async (err) => { diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index 26253ce91e..498cf13943 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -28,21 +28,26 @@ SPDX-License-Identifier: AGPL-3.0-only