diff --git a/CHANGELOG.md b/CHANGELOG.md index fd56700e1f2c..7ccb39adc96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Fix: 公開範囲がホームのノートの埋め込みウィジェットが読み込まれない問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/803) - Fix: 絵文字管理画面で一部の絵文字が表示されない問題を修正 +- Fix: Botプロテクションの設定変更時は実際に検証を通過しないと保存できないように( #15137 ) ### Server - Fix: ユーザーのプロフィール画面をアドレス入力などで直接表示した際に概要タブの描画に失敗する問題の修正( #15032 ) diff --git a/locales/index.d.ts b/locales/index.d.ts index 63878d3d4771..2fe4d203bea0 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10660,6 +10660,49 @@ export interface Locale extends ILocale { "description": 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 d78bd4ee656d..13a00a237247 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2845,3 +2845,17 @@ _remoteLookupErrors: _noSuchObject: title: "見つかりません" description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。" + +_captcha: + verify: "CAPTCHAを通過してください" + testSiteKeyMessage: "サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。\n詳細は下記ページをご確認ください。" + _error: + _requestFailed: + title: "CAPTCHAのリクエストに失敗しました" + text: "しばらく後に実行するか、設定をもう一度ご確認ください。" + _verificationFailed: + title: "CAPTCHAの検証に失敗しました" + text: "設定が正しいかどうかもう一度確認ください。" + _unknown: + title: "CAPTCHAエラー" + text: "想定外のエラーが発生しました。" diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index 206d0dbe0aaf..8c7f66236e27 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -6,6 +6,65 @@ import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/Meta.js'; +import Logger from '@/logger.js'; +import { LoggerService } from './LoggerService.js'; + +export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const; +export type CaptchaProvider = typeof supportedCaptchaProviders[number]; + +export const captchaErrorCodes = { + invalidProvider: Symbol('invalidProvider'), + invalidParameters: Symbol('invalidParameters'), + noResponseProvided: Symbol('noResponseProvided'), + requestFailed: Symbol('requestFailed'), + verificationFailed: Symbol('verificationFailed'), + unknown: Symbol('unknown'), +} as const; +export type CaptchaErrorCode = typeof captchaErrorCodes[keyof typeof captchaErrorCodes]; + +export type CaptchaSetting = { + provider: CaptchaProvider; + hcaptcha: { + siteKey: string | null; + secretKey: string | null; + } + mcaptcha: { + siteKey: string | null; + secretKey: string | null; + instanceUrl: string | null; + } + recaptcha: { + siteKey: string | null; + secretKey: string | null; + } + turnstile: { + siteKey: string | null; + secretKey: string | null; + } +} + +export class CaptchaError extends Error { + public readonly code: CaptchaErrorCode; + public readonly cause?: unknown; + + constructor(code: CaptchaErrorCode, message: string, cause?: unknown) { + super(message); + this.code = code; + this.cause = cause; + this.name = 'CaptchaError'; + } +} + +export type CaptchaSaveSuccess = { + success: true; +} +export type CaptchaSaveFailure = { + success: false; + error: CaptchaError; +} +export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure; type CaptchaResponse = { success: boolean; @@ -14,9 +73,14 @@ type CaptchaResponse = { @Injectable() export class CaptchaService { + private readonly logger: Logger; + constructor( private httpRequestService: HttpRequestService, + private metaService: MetaService, + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('captcha'); } @bindThis @@ -44,32 +108,32 @@ export class CaptchaService { @bindThis public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('recaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'recaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { - throw new Error(`recaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`recaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`); } } @bindThis public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('hcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'hcaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { - throw new Error(`hcaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`hcaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `hcaptcha-failed: ${errorCodes}`); } } @@ -77,7 +141,7 @@ export class CaptchaService { @bindThis public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('mcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'mcaptcha-failed: no response provided'); } const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost); @@ -91,46 +155,251 @@ export class CaptchaService { headers: { 'Content-Type': 'application/json', }, - }); + }, { throwErrorWhenResponseNotOk: false }); if (result.status !== 200) { - throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK'); + throw new CaptchaError(captchaErrorCodes.requestFailed, 'mcaptcha-failed: mcaptcha didn\'t return 200 OK'); } const resp = (await result.json()) as { valid: boolean }; if (!resp.valid) { - throw new Error('mcaptcha-request-failed'); + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed'); } } @bindThis public async verifyTurnstile(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('turnstile-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'turnstile-failed: no response provided'); } const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { - throw new Error(`turnstile-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`turnstile-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`); } } @bindThis public async verifyTestcaptcha(response: string | null | undefined): Promise { if (response == null) { - throw new Error('testcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided'); } const success = response === 'testcaptcha-passed'; if (!success) { - throw new Error('testcaptcha-failed'); + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'testcaptcha-failed'); + } + } + + @bindThis + public async get(): Promise { + const meta = await this.metaService.fetch(true); + + let provider: CaptchaProvider; + switch (true) { + case meta.enableHcaptcha: { + provider = 'hcaptcha'; + break; + } + case meta.enableMcaptcha: { + provider = 'mcaptcha'; + break; + } + case meta.enableRecaptcha: { + provider = 'recaptcha'; + break; + } + case meta.enableTurnstile: { + provider = 'turnstile'; + break; + } + case meta.enableTestcaptcha: { + provider = 'testcaptcha'; + break; + } + default: { + provider = 'none'; + break; + } + } + + return { + provider: provider, + hcaptcha: { + siteKey: meta.hcaptchaSiteKey, + secretKey: meta.hcaptchaSecretKey, + }, + mcaptcha: { + siteKey: meta.mcaptchaSitekey, + secretKey: meta.mcaptchaSecretKey, + instanceUrl: meta.mcaptchaInstanceUrl, + }, + recaptcha: { + siteKey: meta.recaptchaSiteKey, + secretKey: meta.recaptchaSecretKey, + }, + turnstile: { + siteKey: meta.turnstileSiteKey, + secretKey: meta.turnstileSecretKey, + }, + }; + } + + /** + * captchaの設定を更新します. その際、フロントエンド側で受け取ったcaptchaからの戻り値を検証し、passした場合のみ設定を更新します. + * 実際の検証処理はサービス内で定義されている各captchaプロバイダの検証関数に委譲します. + * + * @param provider 検証するcaptchaのプロバイダ + * @param params + * @param params.sitekey hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsitekey. それ以外のプロバイダでは無視されます + * @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret. それ以外のプロバイダでは無視されます + * @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL. それ以外のプロバイダでは無視されます + * @param params.captchaResult フロントエンド側で受け取ったcaptchaプロバイダからの戻り値. この値を使ってサーバサイドでの検証を行います + * @see verifyHcaptcha + * @see verifyMcaptcha + * @see verifyRecaptcha + * @see verifyTurnstile + * @see verifyTestcaptcha + */ + @bindThis + public async save( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + captchaResult?: string | null; + }, + ): Promise { + if (!supportedCaptchaProviders.includes(provider)) { + return { + success: false, + error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`), + }; + } + + const operation = { + none: async () => { + await this.updateMeta(provider, params); + }, + hcaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required'); + } + + await this.verifyHcaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + mcaptcha: async () => { + if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required'); + } + + await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult); + await this.updateMeta(provider, params); + }, + recaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required'); + } + + await this.verifyRecaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + turnstile: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required'); + } + + await this.verifyTurnstile(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + testcaptcha: async () => { + if (!params?.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required'); + } + + await this.verifyTestcaptcha(params.captchaResult); + await this.updateMeta(provider, params); + }, + }[provider]; + + return operation() + .then(() => ({ success: true }) as CaptchaSaveSuccess) + .catch(err => { + this.logger.info(err); + const error = err instanceof CaptchaError + ? err + : new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`); + return { + success: false, + error, + }; + }); + } + + @bindThis + private async updateMeta( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + }, + ) { + const metaPartial: Partial< + Pick< + MiMeta, + ('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') | + ('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') | + ('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') | + ('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') | + ('enableTestcaptcha') + > + > = { + enableHcaptcha: provider === 'hcaptcha', + enableMcaptcha: provider === 'mcaptcha', + enableRecaptcha: provider === 'recaptcha', + enableTurnstile: provider === 'turnstile', + enableTestcaptcha: provider === 'testcaptcha', + }; + + const updateIfNotUndefined = (key: K, value: typeof metaPartial[K]) => { + if (value !== undefined) { + metaPartial[key] = value; + } + }; + switch (provider) { + case 'hcaptcha': { + updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('hcaptchaSecretKey', params?.secret); + break; + } + case 'mcaptcha': { + updateIfNotUndefined('mcaptchaSitekey', params?.sitekey); + updateIfNotUndefined('mcaptchaSecretKey', params?.secret); + updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl); + break; + } + case 'recaptcha': { + updateIfNotUndefined('recaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('recaptchaSecretKey', params?.secret); + break; + } + case 'turnstile': { + updateIfNotUndefined('turnstileSiteKey', params?.sitekey); + updateIfNotUndefined('turnstileSecretKey', params?.secret); + break; + } } + + await this.metaService.update(metaPartial); } } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5bb194313d2c..c2462d8b3da0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -28,6 +28,8 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; +import * as ep___admin_captcha_current from './endpoints/admin/captcha/current.js'; +import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; @@ -416,6 +418,8 @@ const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-de const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; +const $admin_captcha_current: Provider = { provide: 'ep:admin/captcha/current', useClass: ep___admin_captcha_current.default }; +const $admin_captcha_save: Provider = { provide: 'ep:admin/captcha/save', useClass: ep___admin_captcha_save.default }; const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; @@ -808,6 +812,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_delete, $admin_avatarDecorations_list, $admin_avatarDecorations_update, + $admin_captcha_current, + $admin_captcha_save, $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, $admin_unsetUserBanner, @@ -1194,6 +1200,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_delete, $admin_avatarDecorations_list, $admin_avatarDecorations_update, + $admin_captcha_current, + $admin_captcha_save, $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, $admin_unsetUserBanner, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 15809b2678ad..86728ef381fd 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -33,6 +33,8 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; +import * as ep___admin_captcha_current from './endpoints/admin/captcha/current.js'; +import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; @@ -420,6 +422,8 @@ const eps = [ ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], + ['admin/captcha/current', ep___admin_captcha_current], + ['admin/captcha/save', ep___admin_captcha_save], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], ['admin/unset-user-banner', ep___admin_unsetUserBanner], diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/current.ts b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts new file mode 100644 index 000000000000..63ec740348d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの取得であるため + kind: 'read:admin:meta', + + res: { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + hcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + mcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + instanceUrl: { type: 'string', nullable: true }, + }, + }, + recaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + turnstile: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + }, + }, +} as const; + +export const paramDef = {} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async () => { + return this.captchaService.get(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/save.ts b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts new file mode 100644 index 000000000000..98ec278ebefe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { captchaErrorCodes, CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの更新であるため + kind: 'write:admin:meta', + + errors: { + invalidProvider: { + message: 'Invalid provider.', + code: 'INVALID_PROVIDER', + id: '14bf7ae1-80cc-4363-acb2-4fd61d086af0', + httpStatusCode: 400, + }, + invalidParameters: { + message: 'Invalid parameters.', + code: 'INVALID_PARAMETERS', + id: '26654194-410e-44e2-b42e-460ff6f92476', + httpStatusCode: 400, + }, + noResponseProvided: { + message: 'No response provided.', + code: 'NO_RESPONSE_PROVIDED', + id: '40acbba8-0937-41fb-bb3f-474514d40afe', + httpStatusCode: 400, + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd', + httpStatusCode: 500, + }, + verificationFailed: { + message: 'Verification failed.', + code: 'VERIFICATION_FAILED', + id: 'c41c067f-24f3-4150-84b2-b5a3ae8c2214', + httpStatusCode: 400, + }, + unknown: { + message: 'unknown', + code: 'UNKNOWN', + id: 'f868d509-e257-42a9-99c1-42614b031a97', + httpStatusCode: 500, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + captchaResult: { + type: 'string', nullable: true, + }, + sitekey: { + type: 'string', nullable: true, + }, + secret: { + type: 'string', nullable: true, + }, + instanceUrl: { + type: 'string', nullable: true, + }, + }, + required: ['provider'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async (ps) => { + const result = await this.captchaService.save(ps.provider, { + sitekey: ps.sitekey, + secret: ps.secret, + instanceUrl: ps.instanceUrl, + captchaResult: ps.captchaResult, + }); + + if (!result.success) { + switch (result.error.code) { + case captchaErrorCodes.invalidProvider: + throw new ApiError({ + ...meta.errors.invalidProvider, + message: result.error.message, + }); + case captchaErrorCodes.invalidParameters: + throw new ApiError({ + ...meta.errors.invalidParameters, + message: result.error.message, + }); + case captchaErrorCodes.noResponseProvided: + throw new ApiError({ + ...meta.errors.noResponseProvided, + message: result.error.message, + }); + case captchaErrorCodes.requestFailed: + throw new ApiError({ + ...meta.errors.requestFailed, + message: result.error.message, + }); + case captchaErrorCodes.verificationFailed: + throw new ApiError({ + ...meta.errors.verificationFailed, + message: result.error.message, + }); + default: + throw new ApiError(meta.errors.unknown); + } + } + }); + } +} diff --git a/packages/backend/test/unit/CaptchaService.ts b/packages/backend/test/unit/CaptchaService.ts new file mode 100644 index 000000000000..51b70b05a17c --- /dev/null +++ b/packages/backend/test/unit/CaptchaService.ts @@ -0,0 +1,622 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'node-fetch'; +import { + CaptchaError, + CaptchaErrorCode, + captchaErrorCodes, + CaptchaSaveResult, + CaptchaService, +} from '@/core/CaptchaService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/Meta.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +describe('CaptchaService', () => { + let app: TestingModule; + let service: CaptchaService; + let httpRequestService: jest.Mocked; + let metaService: jest.Mocked; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CaptchaService, + LoggerService, + { + provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ + fetch: jest.fn(), + update: jest.fn(), + }), + }, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(CaptchaService); + httpRequestService = app.get(HttpRequestService) as jest.Mocked; + metaService = app.get(MetaService) as jest.Mocked; + }); + + beforeEach(() => { + httpRequestService.send.mockClear(); + metaService.update.mockClear(); + metaService.fetch.mockClear(); + }); + + afterAll(async () => { + await app.close(); + }); + + function successMock(result: object) { + httpRequestService.send.mockResolvedValue({ + ok: true, + status: 200, + json: async () => (result), + } as Response); + } + + function failureHttpMock() { + httpRequestService.send.mockResolvedValue({ + ok: false, + status: 400, + } as Response); + } + + function failureVerificationMock(result: object) { + httpRequestService.send.mockResolvedValue({ + ok: true, + status: 200, + json: async () => (result), + } as Response); + } + + async function testCaptchaError(code: CaptchaErrorCode, test: () => Promise) { + try { + await test(); + expect(false).toBe(true); + } catch (e) { + expect(e instanceof CaptchaError).toBe(true); + + const _e = e as CaptchaError; + expect(_e.code).toBe(code); + } + } + + describe('verifyRecaptcha', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyRecaptcha('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyRecaptcha('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyRecaptcha('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyRecaptcha('secret', 'response')); + }); + }); + + describe('verifyHcaptcha', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyHcaptcha('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyHcaptcha('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyHcaptcha('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyHcaptcha('secret', 'response')); + }); + }); + + describe('verifyMcaptcha', () => { + const host = 'https://localhost'; + + test('success', async () => { + successMock({ valid: true }); + await service.verifyMcaptcha('secret', 'sitekey', host, 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyMcaptcha('secret', 'sitekey', host, null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ valid: false }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response')); + }); + }); + + describe('verifyTurnstile', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyTurnstile('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTurnstile('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyTurnstile('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTurnstile('secret', 'response')); + }); + }); + + describe('verifyTestcaptcha', () => { + test('success', async () => { + await service.verifyTestcaptcha('testcaptcha-passed'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTestcaptcha(null)); + }); + + test('verificationFailed', async () => { + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTestcaptcha('testcaptcha-failed')); + }); + }); + + describe('get', () => { + function setupMeta(meta: Partial) { + metaService.fetch.mockResolvedValue(meta as MiMeta); + } + + test('values', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + hcaptchaSiteKey: 'hcaptcha-sitekey', + hcaptchaSecretKey: 'hcaptcha-secret', + mcaptchaSitekey: 'mcaptcha-sitekey', + mcaptchaSecretKey: 'mcaptcha-secret', + mcaptchaInstanceUrl: 'https://localhost', + recaptchaSiteKey: 'recaptcha-sitekey', + recaptchaSecretKey: 'recaptcha-secret', + turnstileSiteKey: 'turnstile-sitekey', + turnstileSecretKey: 'turnstile-secret', + }); + + const result = await service.get(); + expect(result.provider).toBe('none'); + expect(result.hcaptcha.siteKey).toBe('hcaptcha-sitekey'); + expect(result.hcaptcha.secretKey).toBe('hcaptcha-secret'); + expect(result.mcaptcha.siteKey).toBe('mcaptcha-sitekey'); + expect(result.mcaptcha.secretKey).toBe('mcaptcha-secret'); + expect(result.mcaptcha.instanceUrl).toBe('https://localhost'); + expect(result.recaptcha.siteKey).toBe('recaptcha-sitekey'); + expect(result.recaptcha.secretKey).toBe('recaptcha-secret'); + expect(result.turnstile.siteKey).toBe('turnstile-sitekey'); + expect(result.turnstile.secretKey).toBe('turnstile-secret'); + }); + + describe('provider', () => { + test('none', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('none'); + }); + + test('hcaptcha', async () => { + setupMeta({ + enableHcaptcha: true, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('hcaptcha'); + }); + + test('mcaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: true, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('mcaptcha'); + }); + + test('recaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: true, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('recaptcha'); + }); + + test('turnstile', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: true, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('turnstile'); + }); + + test('testcaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: true, + }); + + const result = await service.get(); + expect(result.provider).toBe('testcaptcha'); + }); + }); + }); + + describe('save', () => { + const host = 'https://localhost'; + + describe('[success] 検証に成功した時だけ保存できる+他のプロバイダの設定値を誤って更新しない', () => { + beforeEach(() => { + successMock({ success: true, valid: true }); + }); + + async function assertSuccess(promise: Promise, expectMeta: Partial) { + await expect(promise) + .resolves + .toStrictEqual({ success: true }); + const partialParams = metaService.update.mock.calls[0][0]; + expect(partialParams).toStrictEqual(expectMeta); + } + + test('none', async () => { + await assertSuccess( + service.save('none'), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }, + ); + }); + + test('hcaptcha', async () => { + await assertSuccess( + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hcaptcha-passed', + }), + { + enableHcaptcha: true, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + hcaptchaSiteKey: 'hcaptcha-sitekey', + hcaptchaSecretKey: 'hcaptcha-secret', + }, + ); + }); + + test('mcaptcha', async () => { + await assertSuccess( + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: true, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + mcaptchaSitekey: 'mcaptcha-sitekey', + mcaptchaSecretKey: 'mcaptcha-secret', + mcaptchaInstanceUrl: host, + }, + ); + }); + + test('recaptcha', async () => { + await assertSuccess( + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: true, + enableTurnstile: false, + enableTestcaptcha: false, + recaptchaSiteKey: 'recaptcha-sitekey', + recaptchaSecretKey: 'recaptcha-secret', + }, + ); + }); + + test('turnstile', async () => { + await assertSuccess( + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: true, + enableTestcaptcha: false, + turnstileSiteKey: 'turnstile-sitekey', + turnstileSecretKey: 'turnstile-secret', + }, + ); + }); + + test('testcaptcha', async () => { + await assertSuccess( + service.save('testcaptcha', { + sitekey: 'testcaptcha-sitekey', + secret: 'testcaptcha-secret', + captchaResult: 'testcaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: true, + }, + ); + }); + }); + + describe('[failure] 検証に失敗した場合は保存できない+設定値の更新そのものが発生しない', () => { + async function assertFailure(code: CaptchaErrorCode, promise: Promise) { + const res = await promise; + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.code).toBe(code); + } + expect(metaService.update).not.toBeCalled(); + } + + describe('invalidParameters', () => { + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: null, + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: null, + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: null, + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: null, + }), + ); + }); + + test('testcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('testcaptcha', { + captchaResult: null, + }), + ); + }); + }); + + describe('requestFailed', () => { + beforeEach(() => { + failureHttpMock(); + }); + + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hcaptcha-passed', + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + ); + }); + + // testchapchaはrequestFailedがない + }); + + describe('verificationFailed', () => { + beforeEach(() => { + failureVerificationMock({ success: false, valid: false, 'error-codes': ['code01', 'code02'] }); + }); + + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hccaptcha-passed', + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + ); + }); + + test('testcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('testcaptcha', { + captchaResult: 'testcaptcha-failed', + }), + ); + }); + }); + }); + }); +}); diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 264cf9af069b..b1167bbac654 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,6 +98,15 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed(() => 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({ diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue index f409f6ce50a5..96214a9542a0 100644 --- a/packages/frontend/src/components/MkFormFooter.vue +++ b/packages/frontend/src/components/MkFormFooter.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}
{{ i18n.ts.discard }} - {{ i18n.ts.save }} + {{ i18n.ts.save }}
@@ -18,7 +18,7 @@ import { } from 'vue'; import MkButton from './MkButton.vue'; import { i18n } from '@/i18n.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ form: { modifiedCount: { value: number; @@ -26,7 +26,10 @@ const props = defineProps<{ discard: () => void; save: () => void; }; -}>(); + canSaving?: boolean; +}>(), { + canSaving: true, +}); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 01a3dbbb3074..47bf69184608 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -136,6 +136,12 @@ type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations // @public (undocumented) type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json']; + // @public (undocumented) type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json']; @@ -1261,6 +1267,8 @@ declare namespace entities { AdminAvatarDecorationsListRequest, AdminAvatarDecorationsListResponse, AdminAvatarDecorationsUpdateRequest, + AdminCaptchaCurrentResponse, + AdminCaptchaSaveRequest, AdminDeleteAllFilesOfAUserRequest, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 1837f3db4f39..3bcdae6a4ab3 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -250,6 +250,28 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:meta* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index cb1f4dbe96b5..b016d5bbcf49 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -36,6 +36,8 @@ import type { AdminAvatarDecorationsListRequest, AdminAvatarDecorationsListResponse, AdminAvatarDecorationsUpdateRequest, + AdminCaptchaCurrentResponse, + AdminCaptchaSaveRequest, AdminDeleteAllFilesOfAUserRequest, AdminUnsetUserAvatarRequest, AdminUnsetUserBannerRequest, @@ -604,6 +606,8 @@ export type Endpoints = { 'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse }; 'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse }; 'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse }; + 'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse }; + 'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse }; 'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse }; 'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse }; 'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index a8f474c25c25..02be4848c72e 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -39,6 +39,8 @@ export type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-dec export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decorations___list']['requestBody']['content']['application/json']; export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json']; export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; +export type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json']; +export type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json']; export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json']; export type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json']; export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 42ca05e05761..e6a9df3f5a16 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -215,6 +215,24 @@ export type paths = { */ post: operations['admin___avatar-decorations___update']; }; + '/admin/captcha/current': { + /** + * admin/captcha/current + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:meta* + */ + post: operations['admin___captcha___current']; + }; + '/admin/captcha/save': { + /** + * admin/captcha/save + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + post: operations['admin___captcha___save']; + }; '/admin/delete-all-files-of-a-user': { /** * admin/delete-all-files-of-a-user @@ -6564,6 +6582,128 @@ export type operations = { }; }; }; + /** + * admin/captcha/current + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:meta* + */ + admin___captcha___current: { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + /** @enum {string} */ + provider: 'none' | 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha'; + hcaptcha: { + siteKey: string | null; + secretKey: string | null; + }; + mcaptcha: { + siteKey: string | null; + secretKey: string | null; + instanceUrl: string | null; + }; + recaptcha: { + siteKey: string | null; + secretKey: string | null; + }; + turnstile: { + siteKey: string | null; + secretKey: string | null; + }; + }; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/captcha/save + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:meta* + */ + admin___captcha___save: { + requestBody: { + content: { + 'application/json': { + /** @enum {string} */ + provider: 'none' | 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha'; + captchaResult?: string | null; + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/delete-all-files-of-a-user * @description No description provided.