mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-23 00:29:22 +09:00
Merge a060269419
into 3c81926f71
This commit is contained in:
commit
67cf7aaf1c
@ -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
43
locales/index.d.ts
vendored
@ -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;
|
||||
|
@ -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: "想定外のエラーが発生しました。"
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
129
packages/backend/src/server/api/endpoints/admin/captcha/save.ts
Normal file
129
packages/backend/src/server/api/endpoints/admin/captcha/save.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
622
packages/backend/test/unit/CaptchaService.ts
Normal file
622
packages/backend/test/unit/CaptchaService.ts
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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) {
|
||||
// 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({
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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 };
|
||||
|
@ -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'];
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user