diff --git a/locales/index.d.ts b/locales/index.d.ts index f0dead1245..dab8eb0361 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5166,6 +5166,10 @@ export interface Locale extends ILocale { * 対象 */ "target": string; + /** + * CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。 + */ + "testCaptchaWarning": string; "_abuseUserReport": { /** * 転送 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0076c467ec..812cd1854f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1287,6 +1287,7 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" messageToFollower: "フォロワーへのメッセージ" target: "対象" +testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。" _abuseUserReport: forward: "転送" diff --git a/packages/backend/migration/1728550878802-testcaptcha.js b/packages/backend/migration/1728550878802-testcaptcha.js new file mode 100644 index 0000000000..d8d987c0c1 --- /dev/null +++ b/packages/backend/migration/1728550878802-testcaptcha.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Testcaptcha1728550878802 { + name = 'Testcaptcha1728550878802' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`); + } +} diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index f6b7955cd2..206d0dbe0a 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -119,5 +119,18 @@ export class CaptchaService { throw new Error(`turnstile-failed: ${errorCodes}`); } } + + @bindThis + public async verifyTestcaptcha(response: string | null | undefined): Promise { + if (response == null) { + throw new Error('testcaptcha-failed: no response provided'); + } + + const success = response === 'testcaptcha-passed'; + + if (!success) { + throw new Error('testcaptcha-failed'); + } + } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index fbd982eb34..409dca3426 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -96,6 +96,7 @@ export class MetaEntityService { recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, 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 d29689f907..fd007de6c6 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -258,6 +258,11 @@ export class MiMeta { }) public turnstileSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableTestcaptcha: boolean; + // 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 99feeaa7d7..e3fd63464a 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -115,6 +115,10 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index be63635efe..3a8cb19f01 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -119,6 +119,7 @@ export class ApiServerService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); @@ -132,6 +133,7 @@ export class ApiServerService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 0d24ffa56a..1d983ca4bc 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -71,6 +71,7 @@ export class SigninApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>, reply: FastifyReply, @@ -194,6 +195,12 @@ export class SigninApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } if (same) { diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index c499638018..3ec5e5d3e6 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -67,6 +67,7 @@ export class SignupApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>, reply: FastifyReply, @@ -99,6 +100,12 @@ export class SignupApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).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 b76ed5c524..abb3c17be3 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -69,6 +69,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -555,6 +559,7 @@ export default class extends Endpoint { // eslint- recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, 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 9ffae840b6..e97ac4e2b9 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -78,6 +78,7 @@ export const paramDef = { enableTurnstile: { type: 'boolean' }, turnstileSiteKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true }, + enableTestcaptcha: { type: 'boolean' }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -357,6 +358,10 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } + if (ps.enableTestcaptcha !== undefined) { + set.enableTestcaptcha = ps.enableTestcaptcha; + } + if (ps.sensitiveMediaDetection !== undefined) { set.sensitiveMediaDetection = ps.sensitiveMediaDetection; } diff --git a/packages/frontend/assets/testcaptcha.png b/packages/frontend/assets/testcaptcha.png new file mode 100644 index 0000000000..9bfd252b51 Binary files /dev/null and b/packages/frontend/assets/testcaptcha.png differ diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c5b6e0caed..82fc89e51c 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -10,6 +10,17 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + Test captcha passed! + + + Type "ai-chan-kawaii" to pass captcha + + Submit + + @@ -29,7 +40,7 @@ export type Captcha = { getResponse(id: string): string; }; -export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha'; +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -54,12 +65,16 @@ const available = ref(false); const captchaEl = shallowRef(); +const testcaptchaInput = ref(''); +const testcaptchaPassed = ref(false); + const variable = computed(() => { switch (props.provider) { case 'hcaptcha': return 'hcaptcha'; case 'recaptcha': return 'grecaptcha'; case 'turnstile': return 'turnstile'; case 'mcaptcha': return 'mcaptcha'; + case 'testcaptcha': return 'testcaptcha'; } }); @@ -71,6 +86,7 @@ const src = computed(() => { 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 'mcaptcha': return null; + case 'testcaptcha': return null; } }); @@ -78,7 +94,7 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed(() => window[variable.value] || {} as unknown as Captcha); -if (loaded || props.provider === 'mcaptcha') { +if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { @@ -91,6 +107,8 @@ if (loaded || props.provider === 'mcaptcha') { function reset() { if (captcha.value.reset) captcha.value.reset(); + testcaptchaPassed.value = false; + testcaptchaInput.value = ''; } async function requestRender() { @@ -127,6 +145,12 @@ function onReceivedMessage(message: MessageEvent) { } } +function testcaptchaSubmit() { + testcaptchaPassed.value = testcaptchaInput.value === 'ai-chan-kawaii'; + callback(testcaptchaPassed.value ? 'testcaptcha-passed' : undefined); + if (!testcaptchaPassed.value) testcaptchaInput.value = ''; +} + onMounted(() => { if (available.value) { window.addEventListener('message', onReceivedMessage); diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue index f30bf5f861..5608122a39 100644 --- a/packages/frontend/src/components/MkSignin.password.vue +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only + {{ i18n.ts.continue }} @@ -44,6 +45,7 @@ export type PwResponse = { mCaptchaResponse: string | null; reCaptchaResponse: string | null; turnstileResponse: string | null; + testcaptchaResponse: string | null; }; }; @@ -75,18 +77,21 @@ const hCaptcha = useTemplateRef('hcaptcha'); const mCaptcha = useTemplateRef('mcaptcha'); const reCaptcha = useTemplateRef('recaptcha'); const turnstile = useTemplateRef('turnstile'); +const testcaptcha = useTemplateRef('testcaptcha'); const hCaptchaResponse = ref(null); const mCaptchaResponse = ref(null); const reCaptchaResponse = ref(null); const turnstileResponse = ref(null); +const testcaptchaResponse = ref(null); const captchaFailed = computed((): boolean => { return ( (instance.enableHcaptcha && !hCaptchaResponse.value) || (instance.enableMcaptcha && !mCaptchaResponse.value) || (instance.enableRecaptcha && !reCaptchaResponse.value) || - (instance.enableTurnstile && !turnstileResponse.value) + (instance.enableTurnstile && !turnstileResponse.value) || + (instance.enableTestcaptcha && !testcaptchaResponse.value) ); }); @@ -104,6 +109,7 @@ function onSubmit() { mCaptchaResponse: mCaptchaResponse.value, reCaptchaResponse: reCaptchaResponse.value, turnstileResponse: turnstileResponse.value, + testcaptchaResponse: testcaptchaResponse.value, }, }); } @@ -113,6 +119,7 @@ function resetCaptcha() { mCaptcha.value?.reset(); reCaptcha.value?.reset(); turnstile.value?.reset(); + testcaptcha.value?.reset(); } defineExpose({ diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index a773cefdab..776ee20e36 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -68,6 +68,8 @@ import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue' import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; +import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { login } from '@/account.js'; @@ -79,9 +81,6 @@ import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue'; import XTotp from '@/components/MkSignin.totp.vue'; import XPasskey from '@/components/MkSignin.passkey.vue'; -import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; - const emit = defineEmits<{ (ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void; }>(); @@ -188,6 +187,7 @@ async function onPasswordSubmitted(pw: PwResponse) { 'm-captcha-response': pw.captcha.mCaptchaResponse, 'g-recaptcha-response': pw.captcha.reCaptchaResponse, 'turnstile-response': pw.captcha.turnstileResponse, + 'testcaptcha-response': pw.captcha.testcaptchaResponse, }); } } diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index ffb5551ff3..3d1c44fc90 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -66,6 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only + @@ -108,6 +109,7 @@ const hcaptcha = ref(); const mcaptcha = ref(); const recaptcha = ref(); const turnstile = ref(); +const testcaptcha = ref(); const username = ref(''); const password = ref(''); @@ -123,6 +125,7 @@ const hCaptchaResponse = ref(null); const mCaptchaResponse = ref(null); const reCaptchaResponse = ref(null); const turnstileResponse = ref(null); +const testcaptchaResponse = ref(null); const usernameAbortController = ref(null); const emailAbortController = ref(null); @@ -132,6 +135,7 @@ const shouldDisableSubmitting = computed((): boolean => { instance.enableMcaptcha && !mCaptchaResponse.value || instance.enableRecaptcha && !reCaptchaResponse.value || instance.enableTurnstile && !turnstileResponse.value || + instance.enableTestcaptcha && !testcaptchaResponse.value || instance.emailRequiredForSignup && emailState.value !== 'ok' || usernameState.value !== 'ok' || passwordRetypeState.value !== 'match'; @@ -259,6 +263,7 @@ async function onSubmit(): Promise { 'm-captcha-response': mCaptchaResponse.value, 'g-recaptcha-response': reCaptchaResponse.value, 'turnstile-response': turnstileResponse.value, + 'testcaptcha-response': testcaptchaResponse.value, }; const res = await fetch(`${config.apiUrl}/signup`, { @@ -301,6 +306,7 @@ function onSignupApiError() { mcaptcha.value?.reset?.(); recaptcha.value?.reset?.(); turnstile.value?.reset?.(); + testcaptcha.value?.reset?.(); os.alert({ type: 'error', diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index b34592cd6a..d07add4408 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 mCaptcha reCAPTCHA Turnstile + testCaptcha {{ i18n.ts.none }} ({{ i18n.ts.notRecommended }}) @@ -23,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only mCaptcha reCAPTCHA Turnstile + testCaptcha @@ -85,6 +87,13 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + {{ i18n.ts.preview }} + + + @@ -101,6 +110,7 @@ import { i18n } from '@/i18n.js'; 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'; const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); @@ -115,7 +125,9 @@ const botProtectionForm = useForm({ ? 'turnstile' : meta.enableMcaptcha ? 'mcaptcha' - : null, + : meta.enableTestcaptcha + ? 'testcaptcha' + : null, hcaptchaSiteKey: meta.hcaptchaSiteKey, hcaptchaSecretKey: meta.hcaptchaSecretKey, mcaptchaSiteKey: meta.mcaptchaSiteKey, @@ -140,6 +152,7 @@ const botProtectionForm = useForm({ enableTurnstile: state.provider === 'turnstile', turnstileSiteKey: state.turnstileSiteKey, turnstileSecretKey: state.turnstileSecretKey, + enableTestcaptcha: state.provider === 'testcaptcha', }); fetchInstance(true); }); diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 76ef7ea1fb..e40cb050fd 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4972,6 +4972,7 @@ export type components = { recaptchaSiteKey: string | null; enableTurnstile: boolean; turnstileSiteKey: string | null; + enableTestcaptcha: boolean; swPublickey: string | null; /** @default /assets/ai.png */ mascotImageUrl: string; @@ -5102,6 +5103,7 @@ export type operations = { recaptchaSiteKey: string | null; enableTurnstile: boolean; turnstileSiteKey: string | null; + enableTestcaptcha: boolean; swPublickey: string | null; /** @default /assets/ai.png */ mascotImageUrl: string | null; @@ -9491,6 +9493,7 @@ export type operations = { enableTurnstile?: boolean; turnstileSiteKey?: string | null; turnstileSecretKey?: string | null; + enableTestcaptcha?: boolean; /** @enum {string} */ sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote'; /** @enum {string} */