This commit is contained in:
おさむのひと 2024-12-22 13:58:15 +09:00 committed by GitHub
commit 67cf7aaf1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1593 additions and 87 deletions

View File

@ -13,6 +13,7 @@
- Fix: 公開範囲がホームのノートの埋め込みウィジェットが読み込まれない問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/803)
- Fix: 絵文字管理画面で一部の絵文字が表示されない問題を修正
- Fix: Botプロテクションの設定変更時は実際に検証を通過しないと保存できないように( #15137 )
### Server
- Fix: ユーザーのプロフィール画面をアドレス入力などで直接表示した際に概要タブの描画に失敗する問題の修正( #15032 )

43
locales/index.d.ts vendored
View File

@ -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;

View File

@ -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: "想定外のエラーが発生しました。"

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<CaptchaSetting> {
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<CaptchaSaveResult> {
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 = <K extends keyof typeof metaPartial>(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);
}
}

View File

@ -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,

View File

@ -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],

View File

@ -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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private captchaService: CaptchaService,
) {
super(meta, paramDef, async () => {
return this.captchaService.get();
});
}
}

View File

@ -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<typeof meta, typeof paramDef> { // 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);
}
}
});
}
}

View File

@ -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<HttpRequestService>;
let metaService: jest.Mocked<MetaService>;
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<HttpRequestService>;
metaService = app.get(MetaService) as jest.Mocked<MetaService>;
});
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<void>) {
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<MiMeta>) {
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<CaptchaSaveResult>, expectMeta: Partial<MiMeta>) {
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<CaptchaSaveResult>) {
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',
}),
);
});
});
});
});
});

View File

@ -30,6 +30,9 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmount
import { defaultStore } from '@/store.js';
// APIs provided by Captcha services
// see: https://docs.hcaptcha.com/configuration/#javascript-api
// see: https://developers.google.com/recaptcha/docs/display?hl=ja
// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget
export type Captcha = {
render(container: string | Node, options: {
readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
@ -53,6 +56,7 @@ declare global {
const props = defineProps<{
provider: CaptchaProvider;
sitekey: string | null; // null will show error on request
secretKey?: string | null;
instanceUrl?: string | null;
modelValue?: string | null;
}>();
@ -64,7 +68,7 @@ const emit = defineEmits<{
const available = ref(false);
const captchaEl = shallowRef<HTMLDivElement | undefined>();
const captchaWidgetId = ref<string | undefined>(undefined);
const testcaptchaInput = ref('');
const testcaptchaPassed = ref(false);
@ -94,6 +98,15 @@ const scriptId = computed(() => `script-${props.provider}`);
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
//
if (available.value) {
callback(undefined);
clearWidget();
await requestRender();
}
});
if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
available.value = true;
} else if (src.value !== null) {
@ -106,14 +119,38 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha')
}
function reset() {
if (captcha.value.reset) captcha.value.reset();
if (captcha.value.reset && captchaWidgetId.value !== undefined) {
try {
captcha.value.reset(captchaWidgetId.value);
} catch (error: unknown) {
// ignore
if (_DEV_) console.warn(error);
}
}
testcaptchaPassed.value = false;
testcaptchaInput.value = '';
}
function remove() {
if (captcha.value.remove && captchaWidgetId.value) {
try {
if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value);
captcha.value.remove(captchaWidgetId.value);
} catch (error: unknown) {
// ignore
if (_DEV_) console.warn(error);
}
}
}
async function requestRender() {
if (captcha.value.render && captchaEl.value instanceof Element) {
captcha.value.render(captchaEl.value, {
if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
// reCAPTCHAcaptchaEldiv.
// divrenderreCAPTCHA
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({

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
<div style="margin-left: auto;" class="_buttons">
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
<MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton primary rounded :disabled="!canSaving" @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</template>
@ -26,6 +26,7 @@ const props = defineProps<{
discard: () => void;
save: () => void;
};
canSaving: boolean;
}>();
</script>

View File

@ -18,7 +18,7 @@
http-equiv="Content-Security-Policy"
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
worker-src 'self';
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://*.recaptcha.net https://*.gstatic.com https://challenges.cloudflare.com https://esm.sh;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;

View File

@ -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<string, { title?: string; text: string; }>;
export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
endpoint: E,
data: P,
token?: string | null | undefined,
customErrors?: Record<string, { title?: string; text: string; }>,
customErrors?: ApiWithDialogCustomErrors,
) => {
const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => {

View File

@ -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',
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);
});
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>

View File

@ -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,

View File

@ -250,6 +250,28 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:admin:meta*
*/
request<E extends 'admin/captcha/current', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:meta*
*/
request<E extends 'admin/captcha/save', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -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 };

View File

@ -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'];

View File

@ -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.