From 78608392208cb73351354cda5678daee232159d8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Tue, 28 Apr 2020 14:29:33 +0900
Subject: [PATCH 01/12] Add support for hCaptcha

---
 locales/ja-JP.yml                             |  4 +
 migration/1588044505511-hCaptcha.ts           | 18 +++++
 package.json                                  |  1 +
 src/@types/hcaptcha.d.ts                      |  9 +++
 src/client/components/hcaptcha.vue            | 76 +++++++++++++++++++
 src/client/components/signup.vue              | 24 ++++--
 src/client/components/url-preview.vue         |  2 +-
 src/client/pages/instance/settings.vue        | 67 +++++++++++++---
 src/models/entities/meta.ts                   | 17 +++++
 src/server/api/endpoints/admin/update-meta.ts | 33 ++++++++
 src/server/api/endpoints/meta.ts              |  4 +
 src/server/api/private/signup.ts              | 13 +++-
 src/server/nodeinfo.ts                        |  1 +
 src/server/web/views/info.pug                 |  3 +
 yarn.lock                                     |  5 ++
 15 files changed, 257 insertions(+), 20 deletions(-)
 create mode 100644 migration/1588044505511-hCaptcha.ts
 create mode 100644 src/@types/hcaptcha.d.ts
 create mode 100644 src/client/components/hcaptcha.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 3db383e37..de44d89a0 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -299,6 +299,10 @@ bannerUrl: "バナー画像のURL"
 basicInfo: "基本情報"
 pinnedUsers: "ピン留めユーザー"
 pinnedUsersDescription: "「みつける」ページなどにピン留めしたいユーザーを改行で区切って記述します。"
+hcaptcha: "hCaptcha"
+enableHcaptcha: "hCaptchaを有効にする"
+hcaptchaSiteKey: "サイトキー"
+hcaptchaSecretKey: "シークレットキー"
 recaptcha: "reCAPTCHA"
 enableRecaptcha: "reCAPTCHAを有効にする"
 recaptchaSiteKey: "サイトキー"
diff --git a/migration/1588044505511-hCaptcha.ts b/migration/1588044505511-hCaptcha.ts
new file mode 100644
index 000000000..a3f4e9367
--- /dev/null
+++ b/migration/1588044505511-hCaptcha.ts
@@ -0,0 +1,18 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class hCaptcha1588044505511 implements MigrationInterface {
+    name = 'hCaptcha1588044505511'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "meta" ADD "enableHcaptcha" boolean NOT NULL DEFAULT false`, undefined);
+        await queryRunner.query(`ALTER TABLE "meta" ADD "hcaptchaSiteKey" character varying(64)`, undefined);
+        await queryRunner.query(`ALTER TABLE "meta" ADD "hcaptchaSecretKey" character varying(64)`, undefined);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "hcaptchaSecretKey"`, undefined);
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "hcaptchaSiteKey"`, undefined);
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableHcaptcha"`, undefined);
+    }
+
+}
diff --git a/package.json b/package.json
index 07d88db0e..de669ee48 100644
--- a/package.json
+++ b/package.json
@@ -144,6 +144,7 @@
 		"gulp-tslint": "8.1.4",
 		"gulp-typescript": "6.0.0-alpha.1",
 		"hard-source-webpack-plugin": "0.13.1",
+		"hcaptcha": "0.0.1",
 		"html-minifier": "4.0.0",
 		"http-proxy-agent": "4.0.1",
 		"http-signature": "1.3.4",
diff --git a/src/@types/hcaptcha.d.ts b/src/@types/hcaptcha.d.ts
new file mode 100644
index 000000000..ef3d44256
--- /dev/null
+++ b/src/@types/hcaptcha.d.ts
@@ -0,0 +1,9 @@
+declare module 'hcaptcha' {
+	export function verify(secret: string, token: string): Promise<{
+		success: boolean;
+		challenge_ts: string;
+		hostname: string;
+		credit?: boolean;
+		'error-codes'?: unknown[];
+	}>;
+}
diff --git a/src/client/components/hcaptcha.vue b/src/client/components/hcaptcha.vue
new file mode 100644
index 000000000..e54eb314a
--- /dev/null
+++ b/src/client/components/hcaptcha.vue
@@ -0,0 +1,76 @@
+<template>
+	<div ref="hCaptcha"></div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+declare global {
+	interface Window {
+		hcaptcha?: {
+			render(container: string | Node, options: {
+				readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
+			}): string;
+			remove(id: string): void;
+			execute(id: string): void;
+			reset(id: string): void;
+			getResponse(id: string): string;
+		};
+	}
+}
+
+export default Vue.extend({
+	props: {
+		sitekey: {
+			type: String,
+      required: true,
+		},
+		value: {
+			type: String,
+		},
+	},
+
+	data() {
+		return {
+			available: false,
+		};
+	},
+
+	created() {
+		if (window.hcaptcha) {
+			this.available = true;
+		} else {
+			const script = document.createElement('script');
+			script.addEventListener('load', () => this.available = true);
+			script.src = 'https://hcaptcha.com/1/api.js?render=explicit';
+			script.async = true;
+			document.head.appendChild(script);
+		}
+	},
+
+	mounted() {
+		if (this.available) {
+			this.render();
+		} else {
+			this.$watch('available', this.render);
+		}
+	},
+
+	methods: {
+		render() {
+			if (this.$refs.hCaptcha instanceof Element) {
+				window.hcaptcha!.render(this.$refs.hCaptcha, {
+					sitekey: this.sitekey,
+					theme: this.$store.state.device.darkMode ? 'dark' : 'light',
+					callback: this.callback,
+					'expired-callback': this.callback,
+					'error-callback': this.callback,
+				});
+			}
+		},
+		callback(response?: string) {
+			this.$emit('input', typeof response == 'string' ? response : null);
+		},
+	},
+});
+</script>
diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue
index 9f3ae8db2..5d16a82ba 100644
--- a/src/client/components/signup.vue
+++ b/src/client/components/signup.vue
@@ -42,7 +42,8 @@
 			</i18n>
 		</mk-switch>
 		<div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div>
-		<mk-button type="submit" :disabled=" submitting || !(meta.tosUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'" primary>{{ $t('start') }}</mk-button>
+		<h-captcha v-if="meta.enableHcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/>
+		<mk-button type="submit" :disabled="shouldDisableSubmitting" primary>{{ $t('start') }}</mk-button>
 	</template>
 </form>
 </template>
@@ -65,6 +66,7 @@ export default Vue.extend({
 		MkButton,
 		MkInput,
 		MkSwitch,
+		hCaptcha: () => import('./hcaptcha.vue').then(x => x.default),
 	},
 
 	data() {
@@ -80,6 +82,7 @@ export default Vue.extend({
 			passwordRetypeState: null,
 			submitting: false,
 			ToSAgreement: false,
+			hCaptchaResponse: null,
 			faLock, faExclamationTriangle, faSpinner, faCheck, faKey
 		}
 	},
@@ -96,7 +99,14 @@ export default Vue.extend({
 		meta() {
 			return this.$store.state.instance.meta;
 		},
-		
+
+		shouldDisableSubmitting(): boolean {
+			return this.submitting ||
+				this.meta.tosUrl && !this.ToSAgreement ||
+				this.meta.enableHcaptcha && !this.hCaptchaResponse ||
+				this.passwordRetypeState == 'not-match';
+		},
+
 		shouldShowProfileUrl(): boolean {
 			return (this.username != '' &&
 				this.usernameState != 'invalid-format' &&
@@ -115,10 +125,11 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		const head = document.getElementsByTagName('head')[0];
-		const script = document.createElement('script');
-		script.setAttribute('src', 'https://www.google.com/recaptcha/api.js');
-		head.appendChild(script);
+		if (this.meta.enableRecaptcha) {
+			const script = document.createElement('script');
+			script.setAttribute('src', 'https://www.google.com/recaptcha/api.js');
+			document.head.appendChild(script);
+		}
 	},
 
 	methods: {
@@ -177,6 +188,7 @@ export default Vue.extend({
 				username: this.username,
 				password: this.password,
 				invitationCode: this.invitationCode,
+				'hcaptcha-response': this.hCaptchaResponse,
 				'g-recaptcha-response': this.meta.enableRecaptcha ? (window as any).grecaptcha.getResponse() : null
 			}).then(() => {
 				this.$root.api('signin', {
diff --git a/src/client/components/url-preview.vue b/src/client/components/url-preview.vue
index 94d07cbae..c2dd0038b 100644
--- a/src/client/components/url-preview.vue
+++ b/src/client/components/url-preview.vue
@@ -4,7 +4,7 @@
 	<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen />
 </div>
 <div v-else-if="tweetUrl && detail" class="twitter">
-	<blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkmode ? 'dark' : null">
+	<blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkMode ? 'dark' : null">
 		<a :href="url"></a>
 	</blockquote>
 </div>
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index f7db4aa10..088db8763 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -38,6 +38,24 @@
 		</div>
 	</section>
 
+	<section class="_card">
+		<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div>
+		<div class="_content">
+			<mk-switch v-model="enableHcaptcha">{{ $t('enableHcaptcha') }}</mk-switch>
+			<template v-if="enableHcaptcha">
+				<mk-input v-model="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</mk-input>
+				<mk-input v-model="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</mk-input>
+			</template>
+		</div>
+		<div class="_content" v-if="enableHcaptcha && hcaptchaSiteKey">
+			<header>{{ $t('preview') }}</header>
+			<h-captcha v-if="enableHcaptcha" :sitekey="hcaptchaSiteKey"/>
+		</div>
+		<div class="_footer">
+			<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+		</div>
+	</section>
+
 	<section class="_card">
 		<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
 		<div class="_content">
@@ -195,6 +213,12 @@ import { url } from '../../config';
 import i18n from '../../i18n';
 import getAcct from '../../../misc/acct/render';
 
+declare global {
+	interface Window {
+		onRecaptchaLoad?: Function;
+	}
+}
+
 export default Vue.extend({
 	i18n,
 
@@ -210,6 +234,7 @@ export default Vue.extend({
 		MkTextarea,
 		MkSwitch,
 		MkInfo,
+		hCaptcha: () => import('../../components/hcaptcha.vue').then(x => x.default),
 	},
 
 	data() {
@@ -234,6 +259,9 @@ export default Vue.extend({
 			enableRegistration: false,
 			enableLocalTimeline: false,
 			enableGlobalTimeline: false,
+			enableHcaptcha: false,
+			hcaptchaSiteKey: null,
+			hcaptchaSecretKey: null,
 			enableRecaptcha: false,
 			recaptchaSiteKey: null,
 			recaptchaSecretKey: null,
@@ -282,6 +310,9 @@ export default Vue.extend({
 		this.enableRegistration = !this.meta.disableRegistration;
 		this.enableLocalTimeline = !this.meta.disableLocalTimeline;
 		this.enableGlobalTimeline = !this.meta.disableGlobalTimeline;
+		this.enableHcaptcha = this.meta.enableHcaptcha;
+		this.hcaptchaSiteKey = this.meta.hcaptchaSiteKey;
+		this.hcaptchaSecretKey = this.meta.hcaptchaSecretKey;
 		this.enableRecaptcha = this.meta.enableRecaptcha;
 		this.recaptchaSiteKey = this.meta.recaptchaSiteKey;
 		this.recaptchaSecretKey = this.meta.recaptchaSecretKey;
@@ -327,24 +358,33 @@ export default Vue.extend({
 		const renderRecaptchaPreview = () => {
 			if (!(window as any).grecaptcha) return;
 			if (!this.$refs.recaptcha) return;
+			if (!this.enableRecaptcha) return;
 			if (!this.recaptchaSiteKey) return;
 			(window as any).grecaptcha.render(this.$refs.recaptcha, {
 				sitekey: this.recaptchaSiteKey
 			});
 		};
-		window.onRecaotchaLoad = () => {
-			renderRecaptchaPreview();
+		let recaptchaLoaded: boolean = false;
+		const requestRenderRecaptchaPreview = () => {
+			if (window.onRecaptchaLoad) { // loading
+				return;
+			}
+
+			if (recaptchaLoaded) { // loaded
+				delete window.onRecaptchaLoad;
+				renderRecaptchaPreview();
+			} else { // init
+				window.onRecaptchaLoad = () => {
+					recaptchaLoaded = true;
+					renderRecaptchaPreview();
+				};
+				const script = document.createElement('script');
+				script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad');
+				document.head.appendChild(script);
+			}
 		};
-		const head = document.getElementsByTagName('head')[0];
-		const script = document.createElement('script');
-		script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad');
-		head.appendChild(script);
-		this.$watch('enableRecaptcha', () => {
-			renderRecaptchaPreview();
-		});
-		this.$watch('recaptchaSiteKey', () => {
-			renderRecaptchaPreview();
-		});
+		this.$watch('enableRecaptcha', requestRenderRecaptchaPreview);
+		this.$watch('recaptchaSiteKey', requestRenderRecaptchaPreview);
 	},
 
 	methods: {
@@ -391,6 +431,9 @@ export default Vue.extend({
 				disableRegistration: !this.enableRegistration,
 				disableLocalTimeline: !this.enableLocalTimeline,
 				disableGlobalTimeline: !this.enableGlobalTimeline,
+				enableHcaptcha: this.enableHcaptcha,
+				hcaptchaSiteKey: this.hcaptchaSiteKey,
+				hcaptchaSecretKey: this.hcaptchaSecretKey,
 				enableRecaptcha: this.enableRecaptcha,
 				recaptchaSiteKey: this.recaptchaSiteKey,
 				recaptchaSecretKey: this.recaptchaSecretKey,
diff --git a/src/models/entities/meta.ts b/src/models/entities/meta.ts
index bb463c52f..622d28bea 100644
--- a/src/models/entities/meta.ts
+++ b/src/models/entities/meta.ts
@@ -124,6 +124,23 @@ export class Meta {
 	@JoinColumn()
 	public proxyAccount: User | null;
 
+	@Column('boolean', {
+		default: false,
+	})
+	public enableHcaptcha: boolean;
+
+	@Column('varchar', {
+		length: 64,
+		nullable: true
+	})
+	public hcaptchaSiteKey: string | null;
+
+	@Column('varchar', {
+		length: 64,
+		nullable: true
+	})
+	public hcaptchaSecretKey: string | null;
+
 	@Column('boolean', {
 		default: false,
 	})
diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts
index 1bc20029e..dffe7ffe7 100644
--- a/src/server/api/endpoints/admin/update-meta.ts
+++ b/src/server/api/endpoints/admin/update-meta.ts
@@ -145,6 +145,27 @@ export const meta = {
 			}
 		},
 
+		enableHcaptcha: {
+			validator: $.optional.bool,
+			desc: {
+				'ja-JP': 'hCaptchaを使用するか否か'
+			}
+		},
+
+		hcaptchaSiteKey: {
+			validator: $.optional.nullable.str,
+			desc: {
+				'ja-JP': 'hCaptcha site key'
+			}
+		},
+
+		hcaptchaSecretKey: {
+			validator: $.optional.nullable.str,
+			desc: {
+				'ja-JP': 'hCaptcha secret key'
+			}
+		},
+
 		enableRecaptcha: {
 			validator: $.optional.bool,
 			desc: {
@@ -472,6 +493,18 @@ export default define(meta, async (ps, me) => {
 		set.proxyRemoteFiles = ps.proxyRemoteFiles;
 	}
 
+	if (ps.enableHcaptcha !== undefined) {
+		set.enableHcaptcha = ps.enableHcaptcha;
+	}
+
+	if (ps.hcaptchaSiteKey !== undefined) {
+		set.hcaptchaSiteKey = ps.hcaptchaSiteKey;
+	}
+
+	if (ps.hcaptchaSecretKey !== undefined) {
+		set.hcaptchaSecretKey = ps.hcaptchaSecretKey;
+	}
+
 	if (ps.enableRecaptcha !== undefined) {
 		set.enableRecaptcha = ps.enableRecaptcha;
 	}
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 179355489..eefc37012 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -122,6 +122,8 @@ export default define(meta, async (ps, me) => {
 		driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
 		cacheRemoteFiles: instance.cacheRemoteFiles,
 		proxyRemoteFiles: instance.proxyRemoteFiles,
+		enableHcaptcha: instance.enableHcaptcha,
+		hcaptchaSiteKey: instance.hcaptchaSiteKey,
 		enableRecaptcha: instance.enableRecaptcha,
 		recaptchaSiteKey: instance.recaptchaSiteKey,
 		swPublickey: instance.swPublicKey,
@@ -149,6 +151,7 @@ export default define(meta, async (ps, me) => {
 			localTimeLine: !instance.disableLocalTimeline,
 			globalTimeLine: !instance.disableGlobalTimeline,
 			elasticsearch: config.elasticsearch ? true : false,
+			hcaptcha: instance.enableHcaptcha,
 			recaptcha: instance.enableRecaptcha,
 			objectStorage: instance.useObjectStorage,
 			twitter: instance.enableTwitterIntegration,
@@ -164,6 +167,7 @@ export default define(meta, async (ps, me) => {
 		response.pinnedUsers = instance.pinnedUsers;
 		response.hiddenTags = instance.hiddenTags;
 		response.blockedHosts = instance.blockedHosts;
+		response.hcaptchaSecretKey = instance.hcaptchaSecretKey;
 		response.recaptchaSecretKey = instance.recaptchaSecretKey;
 		response.proxyAccountId = instance.proxyAccountId;
 		response.twitterConsumerKey = instance.twitterConsumerKey;
diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts
index 79ee74389..e23fe4358 100644
--- a/src/server/api/private/signup.ts
+++ b/src/server/api/private/signup.ts
@@ -1,5 +1,6 @@
 import * as Koa from 'koa';
 import { fetchMeta } from '../../../misc/fetch-meta';
+import { verify } from 'hcaptcha';
 import * as recaptcha from 'recaptcha-promise';
 import { Users, RegistrationTickets } from '../../../models';
 import { signup } from '../common/signup';
@@ -9,8 +10,18 @@ export default async (ctx: Koa.Context) => {
 
 	const instance = await fetchMeta(true);
 
-	// Verify recaptcha
+	// Verify *Captcha
 	// ただしテスト時はこの機構は障害となるため無効にする
+	if (process.env.NODE_ENV !== 'test' && instance.enableHcaptcha && instance.hcaptchaSecretKey) {
+		const success = await verify(instance.hcaptchaSecretKey, body['hcaptcha-response']).then(
+			({ 'error-codes': x }) => !x || !x.length,
+			() => false,
+		);
+
+		if (!success) {
+			ctx.throw(400, 'hcaptcha-failed');
+		}
+	}
 	if (process.env.NODE_ENV !== 'test' && instance.enableRecaptcha && instance.recaptchaSecretKey) {
 		recaptcha.init({
 			secret_key: instance.recaptchaSecretKey
diff --git a/src/server/nodeinfo.ts b/src/server/nodeinfo.ts
index 2ff924e68..442e946df 100644
--- a/src/server/nodeinfo.ts
+++ b/src/server/nodeinfo.ts
@@ -65,6 +65,7 @@ const nodeinfo2 = async () => {
 			disableRegistration: meta.disableRegistration,
 			disableLocalTimeline: meta.disableLocalTimeline,
 			disableGlobalTimeline: meta.disableGlobalTimeline,
+			enableHcaptcha: meta.enableHcaptcha,
 			enableRecaptcha: meta.enableRecaptcha,
 			maxNoteTextLength: meta.maxNoteTextLength,
 			enableTwitterIntegration: meta.enableTwitterIntegration,
diff --git a/src/server/web/views/info.pug b/src/server/web/views/info.pug
index 992e652a6..4553d2e2b 100644
--- a/src/server/web/views/info.pug
+++ b/src/server/web/views/info.pug
@@ -106,6 +106,9 @@ html
 				tr
 					th Registration
 					td= !meta.disableRegistration ? 'yes' : 'no'
+				tr
+					th hCaptcha enabled
+					td= meta.enableHcaptcha ? 'enabled' : 'disabled'
 				tr
 					th reCAPTCHA enabled
 					td= meta.enableRecaptcha ? 'enabled' : 'disabled'
diff --git a/yarn.lock b/yarn.lock
index 07fe0f88d..1958dde9f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4302,6 +4302,11 @@ hash-sum@^1.0.2:
   resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04"
   integrity sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=
 
+hcaptcha@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/hcaptcha/-/hcaptcha-0.0.1.tgz#e8c5e25a943083d06630bf077bae8a3053fa3da5"
+  integrity sha512-xGU7wSg3BENwEsOplfMghyR7SL/AXKllmCRMkmt3WQHxhINVNs2u7pP7V5FhigNFBNt6zz32GDRzLqfeDzqPyA==
+
 he@1.2.0, he@^1.1.0, he@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"

From b862c055ae81a0ed1f239fb43edf699cd70f711e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Tue, 28 Apr 2020 14:55:12 +0900
Subject: [PATCH 02/12] Fix bug

---
 src/client/components/hcaptcha.vue | 15 ++++++++++++---
 1 file changed, 12 insertions(+), 3 deletions(-)

diff --git a/src/client/components/hcaptcha.vue b/src/client/components/hcaptcha.vue
index e54eb314a..4711b303d 100644
--- a/src/client/components/hcaptcha.vue
+++ b/src/client/components/hcaptcha.vue
@@ -23,7 +23,7 @@ export default Vue.extend({
 	props: {
 		sitekey: {
 			type: String,
-      required: true,
+			required: true,
 		},
 		value: {
 			type: String,
@@ -37,13 +37,22 @@ export default Vue.extend({
 	},
 
 	created() {
-		if (window.hcaptcha) {
+		if (window.hcaptcha) { // loaded
 			this.available = true;
 		} else {
+			const alreadyLoading = document.getElementById('hcaptcha');
+
+			if (alreadyLoading) { // loading
+				alreadyLoading.addEventListener('load', () => this.available = true);
+
+				return;
+			} // init
+
 			const script = document.createElement('script');
 			script.addEventListener('load', () => this.available = true);
-			script.src = 'https://hcaptcha.com/1/api.js?render=explicit';
 			script.async = true;
+			script.id = 'hcaptcha';
+			script.src = 'https://hcaptcha.com/1/api.js?render=explicit';
 			document.head.appendChild(script);
 		}
 	},

From 9daa9007935578ed1d12d46916d913d802816087 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Wed, 29 Apr 2020 05:30:58 +0900
Subject: [PATCH 03/12] Refactor codes

---
 src/client/components/hcaptcha.vue     | 20 ++++++--------------
 src/client/pages/instance/settings.vue |  3 +--
 src/server/api/private/signup.ts       |  2 +-
 3 files changed, 8 insertions(+), 17 deletions(-)

diff --git a/src/client/components/hcaptcha.vue b/src/client/components/hcaptcha.vue
index 4711b303d..f49336648 100644
--- a/src/client/components/hcaptcha.vue
+++ b/src/client/components/hcaptcha.vue
@@ -40,20 +40,12 @@ export default Vue.extend({
 		if (window.hcaptcha) { // loaded
 			this.available = true;
 		} else {
-			const alreadyLoading = document.getElementById('hcaptcha');
-
-			if (alreadyLoading) { // loading
-				alreadyLoading.addEventListener('load', () => this.available = true);
-
-				return;
-			} // init
-
-			const script = document.createElement('script');
-			script.addEventListener('load', () => this.available = true);
-			script.async = true;
-			script.id = 'hcaptcha';
-			script.src = 'https://hcaptcha.com/1/api.js?render=explicit';
-			document.head.appendChild(script);
+			(document.getElementById('hcaptcha') || (x => document.head.appendChild(Object.assign(x, {
+				async: true,
+				id: 'hcaptcha',
+				src: 'https://hcaptcha.com/1/api.js?render=explicit',
+			})))(document.createElement('script')))
+				.addEventListener('load', () => this.available = true);
 		}
 	},
 
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index 088db8763..1292316b2 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -371,11 +371,10 @@ export default Vue.extend({
 			}
 
 			if (recaptchaLoaded) { // loaded
-				delete window.onRecaptchaLoad;
 				renderRecaptchaPreview();
 			} else { // init
 				window.onRecaptchaLoad = () => {
-					recaptchaLoaded = true;
+					recaptchaLoaded = delete window.onRecaptchaLoad;
 					renderRecaptchaPreview();
 				};
 				const script = document.createElement('script');
diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts
index e23fe4358..04d0501b6 100644
--- a/src/server/api/private/signup.ts
+++ b/src/server/api/private/signup.ts
@@ -14,7 +14,7 @@ export default async (ctx: Koa.Context) => {
 	// ただしテスト時はこの機構は障害となるため無効にする
 	if (process.env.NODE_ENV !== 'test' && instance.enableHcaptcha && instance.hcaptchaSecretKey) {
 		const success = await verify(instance.hcaptchaSecretKey, body['hcaptcha-response']).then(
-			({ 'error-codes': x }) => !x || !x.length,
+			({ success }) => success,
 			() => false,
 		);
 

From 01411327b89c6b5c1a4b1d559d81d63c4b120c02 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Wed, 29 Apr 2020 06:04:05 +0900
Subject: [PATCH 04/12] Add *captcha settings guide

---
 locales/ja-JP.yml                      |  2 ++
 src/client/pages/instance/settings.vue | 38 ++++++++++++++++++++++++++
 2 files changed, 40 insertions(+)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index de44d89a0..12b28019d 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -107,6 +107,7 @@ customEmojis: "カスタム絵文字"
 emojiName: "絵文字名"
 emojiUrl: "絵文字画像URL"
 addEmoji: "絵文字を追加"
+settingGuide: "おすすめ設定"
 cacheRemoteFiles: "リモートのファイルをキャッシュする"
 cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。"
 flagAsBot: "Botとして設定"
@@ -307,6 +308,7 @@ recaptcha: "reCAPTCHA"
 enableRecaptcha: "reCAPTCHAを有効にする"
 recaptchaSiteKey: "サイトキー"
 recaptchaSecretKey: "シークレットキー"
+avoidMultiCaptchaConfirm: "単一のCaptchaのみの使用が推奨されます。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。"
 antennas: "アンテナ"
 manageAntennas: "アンテナの管理"
 name: "名前"
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index 1292316b2..008d6ef1a 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -239,6 +239,7 @@ export default Vue.extend({
 
 	data() {
 		return {
+			loaded: false,
 			url,
 			proxyAccount: null,
 			proxyAccountId: null,
@@ -298,6 +299,41 @@ export default Vue.extend({
 		},
 	},
 
+	watch: {
+		enableHcaptcha(enabled) {
+			if (enabled && this.loaded && this.enableRecaptcha) {
+				this.$root.dialog({
+					type: 'question', // warning だと間違って cancel するかもしれない
+					showCancelButton: true,
+					title: this.$t('settingGuide'),
+					text: this.$t('avoidMultiCaptchaConfirm'),
+				}).then(({ canceled }) => {
+					if (canceled) {
+						return;
+					}
+
+					this.enableRecaptcha = false;
+				});
+			}
+		},
+		enableRecaptcha(enabled) {
+			if (enabled && this.loaded && this.enableHcaptcha) {
+				this.$root.dialog({
+					type: 'question', // warning だと間違って cancel するかもしれない
+					showCancelButton: true,
+					title: this.$t('settingGuide'),
+					text: this.$t('avoidMultiCaptchaConfirm'),
+				}).then(({ canceled }) => {
+					if (canceled) {
+						return;
+					}
+
+					this.enableHcaptcha = false;
+				});
+			}
+		}
+	},
+
 	created() {
 		this.name = this.meta.name;
 		this.description = this.meta.description;
@@ -352,6 +388,8 @@ export default Vue.extend({
 				this.proxyAccount = proxyAccount;
 			});
 		}
+
+		this.loaded = true;
 	},
 
 	mounted() {

From 805472482e4197dc0bbda64e163d8fc6ff3d8d57 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Wed, 29 Apr 2020 06:09:17 +0900
Subject: [PATCH 05/12] Refactor code

---
 src/client/components/hcaptcha.vue | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/client/components/hcaptcha.vue b/src/client/components/hcaptcha.vue
index f49336648..4bc03342f 100644
--- a/src/client/components/hcaptcha.vue
+++ b/src/client/components/hcaptcha.vue
@@ -40,11 +40,11 @@ export default Vue.extend({
 		if (window.hcaptcha) { // loaded
 			this.available = true;
 		} else {
-			(document.getElementById('hcaptcha') || (x => document.head.appendChild(Object.assign(x, {
+			(document.getElementById('hcaptcha') || document.head.appendChild(Object.assign(document.createElement('script'), {
 				async: true,
 				id: 'hcaptcha',
 				src: 'https://hcaptcha.com/1/api.js?render=explicit',
-			})))(document.createElement('script')))
+			})))
 				.addEventListener('load', () => this.available = true);
 		}
 	},

From 77adf26236855b4b43a1072ad2dc6a904bef7d39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Wed, 29 Apr 2020 09:15:18 +0900
Subject: [PATCH 06/12] Factorize *captcha component

---
 src/client/components/captcha.vue       | 119 ++++++++++++++++++++++
 src/client/components/hcaptcha.vue      |  77 ---------------
 src/client/components/signup-dialog.vue |   2 +-
 src/client/components/signup.vue        |  33 ++++---
 src/client/pages/instance/settings.vue  | 125 ++++++++----------------
 5 files changed, 180 insertions(+), 176 deletions(-)
 create mode 100644 src/client/components/captcha.vue
 delete mode 100644 src/client/components/hcaptcha.vue

diff --git a/src/client/components/captcha.vue b/src/client/components/captcha.vue
new file mode 100644
index 000000000..6b1ee6f0b
--- /dev/null
+++ b/src/client/components/captcha.vue
@@ -0,0 +1,119 @@
+<template>
+<div>
+	<span v-if="!available">{{ $t('waiting') }}<mk-ellipsis/></span>
+	<div ref="captcha"></div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../i18n';
+
+type Captcha = {
+	render(container: string | Node, options: {
+		readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
+	}): string;
+	remove(id: string): void;
+	execute(id: string): void;
+	reset(id: string): void;
+	getResponse(id: string): string;
+};
+
+type CaptchaProvider = 'hcaptcha' | 'grecaptcha';
+
+type CaptchaContainer = {
+	readonly [_ in CaptchaProvider]?: Captcha;
+};
+
+declare global {
+	interface Window extends CaptchaContainer {
+	}
+}
+
+export default Vue.extend({
+	i18n,
+	props: {
+		provider: {
+			type: String,
+			required: true,
+		},
+		sitekey: {
+			type: String,
+			required: true,
+		},
+		value: {
+			type: String,
+		},
+	},
+
+	data() {
+		return {
+			available: false,
+		};
+	},
+
+	computed: {
+		loaded() {
+			return !!window[this.provider as CaptchaProvider];
+		},
+		src() {
+			const endpoint = ({
+				hcaptcha: 'https://hcaptcha.com/1',
+				grecaptcha: 'https://www.google.com/recaptcha',
+			} as Record<PropertyKey, unknown>)[this.provider];
+
+			return `${typeof endpoint == 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
+		},
+		captcha() {
+			return window[this.provider as CaptchaProvider] || {} as unknown as Captcha;
+		},
+	},
+
+	created() {
+		if (this.loaded) {
+			this.available = true;
+		} else {
+			(document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
+				async: true,
+				id: this.provider,
+				src: this.src,
+			})))
+				.addEventListener('load', () => this.available = true);
+		}
+	},
+
+	mounted() {
+		if (this.available) {
+			this.requestRender();
+		} else {
+			this.$watch('available', this.requestRender);
+		}
+	},
+
+	beforeDestroy() {
+		this.reset();
+	},
+
+	methods: {
+		reset() {
+			this.captcha?.reset();
+		},
+		requestRender() {
+			if (this.captcha.render && this.$refs.captcha instanceof Element) {
+				this.captcha.render(this.$refs.captcha, {
+					sitekey: this.sitekey,
+					theme: this.$store.state.device.darkMode ? 'dark' : 'light',
+					callback: this.callback,
+					'expired-callback': this.callback,
+					'error-callback': this.callback,
+				});
+			} else {
+				setTimeout(this.requestRender.bind(this), 1);
+			}
+		},
+		callback(response?: string) {
+			this.$emit('input', typeof response == 'string' ? response : null);
+		},
+	},
+});
+</script>
diff --git a/src/client/components/hcaptcha.vue b/src/client/components/hcaptcha.vue
deleted file mode 100644
index 4bc03342f..000000000
--- a/src/client/components/hcaptcha.vue
+++ /dev/null
@@ -1,77 +0,0 @@
-<template>
-	<div ref="hCaptcha"></div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-
-declare global {
-	interface Window {
-		hcaptcha?: {
-			render(container: string | Node, options: {
-				readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
-			}): string;
-			remove(id: string): void;
-			execute(id: string): void;
-			reset(id: string): void;
-			getResponse(id: string): string;
-		};
-	}
-}
-
-export default Vue.extend({
-	props: {
-		sitekey: {
-			type: String,
-			required: true,
-		},
-		value: {
-			type: String,
-		},
-	},
-
-	data() {
-		return {
-			available: false,
-		};
-	},
-
-	created() {
-		if (window.hcaptcha) { // loaded
-			this.available = true;
-		} else {
-			(document.getElementById('hcaptcha') || document.head.appendChild(Object.assign(document.createElement('script'), {
-				async: true,
-				id: 'hcaptcha',
-				src: 'https://hcaptcha.com/1/api.js?render=explicit',
-			})))
-				.addEventListener('load', () => this.available = true);
-		}
-	},
-
-	mounted() {
-		if (this.available) {
-			this.render();
-		} else {
-			this.$watch('available', this.render);
-		}
-	},
-
-	methods: {
-		render() {
-			if (this.$refs.hCaptcha instanceof Element) {
-				window.hcaptcha!.render(this.$refs.hCaptcha, {
-					sitekey: this.sitekey,
-					theme: this.$store.state.device.darkMode ? 'dark' : 'light',
-					callback: this.callback,
-					'expired-callback': this.callback,
-					'error-callback': this.callback,
-				});
-			}
-		},
-		callback(response?: string) {
-			this.$emit('input', typeof response == 'string' ? response : null);
-		},
-	},
-});
-</script>
diff --git a/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue
index 10cdf3a56..4db79af51 100644
--- a/src/client/components/signup-dialog.vue
+++ b/src/client/components/signup-dialog.vue
@@ -1,5 +1,5 @@
 <template>
-<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }">
+<x-window ref="window" :width="366" :height="506" @closed="() => { $emit('closed'); destroyDom(); }">
 	<template #header>{{ $t('signup') }}</template>
 	<x-signup :auto-set="autoSet" @signup="onSignup"/>
 </x-window>
diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue
index 5d16a82ba..7e8bac776 100644
--- a/src/client/components/signup.vue
+++ b/src/client/components/signup.vue
@@ -41,8 +41,8 @@
 				<a :href="meta.tosUrl" class="_link" target="_blank">{{ $t('tos') }}</a>
 			</i18n>
 		</mk-switch>
-		<div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div>
-		<h-captcha v-if="meta.enableHcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/>
+		<captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/>
+		<captcha v-if="meta.enableRecaptcha" class="captcha" provider="grecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/>
 		<mk-button type="submit" :disabled="shouldDisableSubmitting" primary>{{ $t('start') }}</mk-button>
 	</template>
 </form>
@@ -66,7 +66,7 @@ export default Vue.extend({
 		MkButton,
 		MkInput,
 		MkSwitch,
-		hCaptcha: () => import('./hcaptcha.vue').then(x => x.default),
+		captcha: () => import('./captcha.vue').then(x => x.default),
 	},
 
 	data() {
@@ -83,6 +83,7 @@ export default Vue.extend({
 			submitting: false,
 			ToSAgreement: false,
 			hCaptchaResponse: null,
+			reCaptchaResponse: null,
 			faLock, faExclamationTriangle, faSpinner, faCheck, faKey
 		}
 	},
@@ -124,14 +125,6 @@ export default Vue.extend({
 		}
 	},
 
-	mounted() {
-		if (this.meta.enableRecaptcha) {
-			const script = document.createElement('script');
-			script.setAttribute('src', 'https://www.google.com/recaptcha/api.js');
-			document.head.appendChild(script);
-		}
-	},
-
 	methods: {
 		onChangeUsername() {
 			if (this.username == '') {
@@ -189,7 +182,7 @@ export default Vue.extend({
 				password: this.password,
 				invitationCode: this.invitationCode,
 				'hcaptcha-response': this.hCaptchaResponse,
-				'g-recaptcha-response': this.meta.enableRecaptcha ? (window as any).grecaptcha.getResponse() : null
+				'g-recaptcha-response': this.meta.reCaptchaResponse,
 			}).then(() => {
 				this.$root.api('signin', {
 					username: this.username,
@@ -199,17 +192,25 @@ export default Vue.extend({
 				});
 			}).catch(() => {
 				this.submitting = false;
+				this.$refs.hcaptcha?.reset?.();
+				this.$refs.recaptcha?.reset?.();
 
 				this.$root.dialog({
 					type: 'error',
 					text: this.$t('error')
 				});
-
-				if (this.meta.enableRecaptcha) {
-					(window as any).grecaptcha.reset();
-				}
 			});
 		}
 	}
 });
 </script>
+
+<style lang="scss" scoped>
+.mk-signup {
+	padding: 32px 0 0;
+
+	.captcha {
+		margin: 16px 0;
+	}
+}
+</style>
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index 008d6ef1a..f0fa84699 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -41,15 +41,15 @@
 	<section class="_card">
 		<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div>
 		<div class="_content">
-			<mk-switch v-model="enableHcaptcha">{{ $t('enableHcaptcha') }}</mk-switch>
+			<mk-switch v-model="enableHcaptcha" @input="guide('enableHcaptcha')">{{ $t('enableHcaptcha') }}</mk-switch>
 			<template v-if="enableHcaptcha">
 				<mk-input v-model="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</mk-input>
 				<mk-input v-model="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</mk-input>
 			</template>
 		</div>
-		<div class="_content" v-if="enableHcaptcha && hcaptchaSiteKey">
+		<div class="_content" v-if="enableHcaptcha">
 			<header>{{ $t('preview') }}</header>
-			<h-captcha v-if="enableHcaptcha" :sitekey="hcaptchaSiteKey"/>
+			<captcha v-if="enableHcaptcha" provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
 		</div>
 		<div class="_footer">
 			<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
@@ -59,7 +59,7 @@
 	<section class="_card">
 		<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
 		<div class="_content">
-			<mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
+			<mk-switch v-model="enableRecaptcha" @input="guide('enableRecaptcha')">{{ $t('enableRecaptcha') }}</mk-switch>
 			<template v-if="enableRecaptcha">
 				<mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input>
 				<mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input>
@@ -67,7 +67,7 @@
 		</div>
 		<div class="_content" v-if="enableRecaptcha && recaptchaSiteKey">
 			<header>{{ $t('preview') }}</header>
-			<div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div>
+			<captcha v-if="enableRecaptcha" provider="grecaptcha" :sitekey="recaptchaSiteKey"/>
 		</div>
 		<div class="_footer">
 			<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
@@ -213,12 +213,6 @@ import { url } from '../../config';
 import i18n from '../../i18n';
 import getAcct from '../../../misc/acct/render';
 
-declare global {
-	interface Window {
-		onRecaptchaLoad?: Function;
-	}
-}
-
 export default Vue.extend({
 	i18n,
 
@@ -234,12 +228,11 @@ export default Vue.extend({
 		MkTextarea,
 		MkSwitch,
 		MkInfo,
-		hCaptcha: () => import('../../components/hcaptcha.vue').then(x => x.default),
+		Captcha: () => import('../../components/captcha.vue').then(x => x.default),
 	},
 
 	data() {
 		return {
-			loaded: false,
 			url,
 			proxyAccount: null,
 			proxyAccountId: null,
@@ -299,41 +292,6 @@ export default Vue.extend({
 		},
 	},
 
-	watch: {
-		enableHcaptcha(enabled) {
-			if (enabled && this.loaded && this.enableRecaptcha) {
-				this.$root.dialog({
-					type: 'question', // warning だと間違って cancel するかもしれない
-					showCancelButton: true,
-					title: this.$t('settingGuide'),
-					text: this.$t('avoidMultiCaptchaConfirm'),
-				}).then(({ canceled }) => {
-					if (canceled) {
-						return;
-					}
-
-					this.enableRecaptcha = false;
-				});
-			}
-		},
-		enableRecaptcha(enabled) {
-			if (enabled && this.loaded && this.enableHcaptcha) {
-				this.$root.dialog({
-					type: 'question', // warning だと間違って cancel するかもしれない
-					showCancelButton: true,
-					title: this.$t('settingGuide'),
-					text: this.$t('avoidMultiCaptchaConfirm'),
-				}).then(({ canceled }) => {
-					if (canceled) {
-						return;
-					}
-
-					this.enableHcaptcha = false;
-				});
-			}
-		}
-	},
-
 	created() {
 		this.name = this.meta.name;
 		this.description = this.meta.description;
@@ -388,43 +346,46 @@ export default Vue.extend({
 				this.proxyAccount = proxyAccount;
 			});
 		}
-
-		this.loaded = true;
-	},
-
-	mounted() {
-		const renderRecaptchaPreview = () => {
-			if (!(window as any).grecaptcha) return;
-			if (!this.$refs.recaptcha) return;
-			if (!this.enableRecaptcha) return;
-			if (!this.recaptchaSiteKey) return;
-			(window as any).grecaptcha.render(this.$refs.recaptcha, {
-				sitekey: this.recaptchaSiteKey
-			});
-		};
-		let recaptchaLoaded: boolean = false;
-		const requestRenderRecaptchaPreview = () => {
-			if (window.onRecaptchaLoad) { // loading
-				return;
-			}
-
-			if (recaptchaLoaded) { // loaded
-				renderRecaptchaPreview();
-			} else { // init
-				window.onRecaptchaLoad = () => {
-					recaptchaLoaded = delete window.onRecaptchaLoad;
-					renderRecaptchaPreview();
-				};
-				const script = document.createElement('script');
-				script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad');
-				document.head.appendChild(script);
-			}
-		};
-		this.$watch('enableRecaptcha', requestRenderRecaptchaPreview);
-		this.$watch('recaptchaSiteKey', requestRenderRecaptchaPreview);
 	},
 
 	methods: {
+		guide(key: 'enableHcaptcha' | 'enableRecaptcha') {
+			({
+				enableHcaptcha() {
+					if (this.enableHcaptcha && this.enableRecaptcha) {
+						this.$root.dialog({
+							type: 'question', // warning だと間違って cancel するかもしれない
+							showCancelButton: true,
+							title: this.$t('settingGuide'),
+							text: this.$t('avoidMultiCaptchaConfirm'),
+						}).then(({ canceled }) => {
+							if (canceled) {
+								return;
+							}
+
+							this.enableRecaptcha = false;
+						});
+					}
+				},
+				enableRecaptcha() {
+					if (this.enableRecaptcha && this.enableHcaptcha) {
+						this.$root.dialog({
+							type: 'question', // warning だと間違って cancel するかもしれない
+							showCancelButton: true,
+							title: this.$t('settingGuide'),
+							text: this.$t('avoidMultiCaptchaConfirm'),
+						}).then(({ canceled }) => {
+							if (canceled) {
+								return;
+							}
+
+							this.enableHcaptcha = false;
+						});
+					}
+				},
+			})[key]();
+		},
+
 		invite() {
 			this.$root.api('admin/invite').then(x => {
 				this.$root.dialog({

From 9a611b24f3f03dcfe841ff30dd7ae273eda905df Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Wed, 29 Apr 2020 09:35:37 +0900
Subject: [PATCH 07/12] Fix bug

---
 locales/ja-JP.yml                      |  2 +-
 src/client/pages/instance/settings.vue | 77 +++++++++++++-------------
 2 files changed, 39 insertions(+), 40 deletions(-)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 12b28019d..f22a76917 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -308,7 +308,7 @@ recaptcha: "reCAPTCHA"
 enableRecaptcha: "reCAPTCHAを有効にする"
 recaptchaSiteKey: "サイトキー"
 recaptchaSecretKey: "シークレットキー"
-avoidMultiCaptchaConfirm: "単一のCaptchaのみの使用が推奨されます。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。"
+avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。"
 antennas: "アンテナ"
 manageAntennas: "アンテナの管理"
 name: "名前"
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index f0fa84699..afd6d4cc6 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -41,7 +41,7 @@
 	<section class="_card">
 		<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div>
 		<div class="_content">
-			<mk-switch v-model="enableHcaptcha" @input="guide('enableHcaptcha')">{{ $t('enableHcaptcha') }}</mk-switch>
+			<mk-switch v-model="enableHcaptcha" ref="enableHcaptcha">{{ $t('enableHcaptcha') }}</mk-switch>
 			<template v-if="enableHcaptcha">
 				<mk-input v-model="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</mk-input>
 				<mk-input v-model="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</mk-input>
@@ -59,7 +59,7 @@
 	<section class="_card">
 		<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
 		<div class="_content">
-			<mk-switch v-model="enableRecaptcha" @input="guide('enableRecaptcha')">{{ $t('enableRecaptcha') }}</mk-switch>
+			<mk-switch v-model="enableRecaptcha" ref="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
 			<template v-if="enableRecaptcha">
 				<mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input>
 				<mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input>
@@ -348,44 +348,43 @@ export default Vue.extend({
 		}
 	},
 
+	mounted() {
+		this.$refs.enableHcaptcha.$on('change', () => {
+			if (this.enableHcaptcha && this.enableRecaptcha) {
+				this.$root.dialog({
+					type: 'question', // warning だと間違って cancel するかもしれない
+					showCancelButton: true,
+					title: this.$t('settingGuide'),
+					text: this.$t('avoidMultiCaptchaConfirm'),
+				}).then(({ canceled }) => {
+					if (canceled) {
+						return;
+					}
+
+					this.enableRecaptcha = false;
+				});
+			}
+		});
+
+		this.$refs.enableRecaptcha.$on('change', () => {
+			if (this.enableRecaptcha && this.enableHcaptcha) {
+				this.$root.dialog({
+					type: 'question', // warning だと間違って cancel するかもしれない
+					showCancelButton: true,
+					title: this.$t('settingGuide'),
+					text: this.$t('avoidMultiCaptchaConfirm'),
+				}).then(({ canceled }) => {
+					if (canceled) {
+						return;
+					}
+
+					this.enableHcaptcha = false;
+				});
+			}
+		});
+	},
+
 	methods: {
-		guide(key: 'enableHcaptcha' | 'enableRecaptcha') {
-			({
-				enableHcaptcha() {
-					if (this.enableHcaptcha && this.enableRecaptcha) {
-						this.$root.dialog({
-							type: 'question', // warning だと間違って cancel するかもしれない
-							showCancelButton: true,
-							title: this.$t('settingGuide'),
-							text: this.$t('avoidMultiCaptchaConfirm'),
-						}).then(({ canceled }) => {
-							if (canceled) {
-								return;
-							}
-
-							this.enableRecaptcha = false;
-						});
-					}
-				},
-				enableRecaptcha() {
-					if (this.enableRecaptcha && this.enableHcaptcha) {
-						this.$root.dialog({
-							type: 'question', // warning だと間違って cancel するかもしれない
-							showCancelButton: true,
-							title: this.$t('settingGuide'),
-							text: this.$t('avoidMultiCaptchaConfirm'),
-						}).then(({ canceled }) => {
-							if (canceled) {
-								return;
-							}
-
-							this.enableHcaptcha = false;
-						});
-					}
-				},
-			})[key]();
-		},
-
 		invite() {
 			this.$root.api('admin/invite').then(x => {
 				this.$root.dialog({

From ca6a70e5557ed8c99470133cde3755de5a2318fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Wed, 29 Apr 2020 09:54:21 +0900
Subject: [PATCH 08/12] Update signup.vue

---
 src/client/components/signup.vue | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue
index 7e8bac776..6452afc88 100644
--- a/src/client/components/signup.vue
+++ b/src/client/components/signup.vue
@@ -105,6 +105,7 @@ export default Vue.extend({
 			return this.submitting ||
 				this.meta.tosUrl && !this.ToSAgreement ||
 				this.meta.enableHcaptcha && !this.hCaptchaResponse ||
+				this.meta.enableRecaptcha && !this.reCaptchaResponse ||
 				this.passwordRetypeState == 'not-match';
 		},
 

From 9053b9635e1b2f1daf233f644d6f8f3bb8411cc0 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 2 May 2020 10:28:45 +0900
Subject: [PATCH 09/12] refactor

---
 src/server/api/private/signup.ts | 33 +++++++++++++++++---------------
 1 file changed, 18 insertions(+), 15 deletions(-)

diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts
index 04d0501b6..4e475e198 100644
--- a/src/server/api/private/signup.ts
+++ b/src/server/api/private/signup.ts
@@ -12,25 +12,28 @@ export default async (ctx: Koa.Context) => {
 
 	// Verify *Captcha
 	// ただしテスト時はこの機構は障害となるため無効にする
-	if (process.env.NODE_ENV !== 'test' && instance.enableHcaptcha && instance.hcaptchaSecretKey) {
-		const success = await verify(instance.hcaptchaSecretKey, body['hcaptcha-response']).then(
-			({ success }) => success,
-			() => false,
-		);
+	if (process.env.NODE_ENV !== 'test') {
+		if (instance.enableHcaptcha && instance.hcaptchaSecretKey) {	
+			const success = await verify(instance.hcaptchaSecretKey, body['hcaptcha-response']).then(
+				({ success }) => success,
+				() => false,
+			);
 
-		if (!success) {
-			ctx.throw(400, 'hcaptcha-failed');
+			if (!success) {
+				ctx.throw(400, 'hcaptcha-failed');
+			}
 		}
-	}
-	if (process.env.NODE_ENV !== 'test' && instance.enableRecaptcha && instance.recaptchaSecretKey) {
-		recaptcha.init({
-			secret_key: instance.recaptchaSecretKey
-		});
 
-		const success = await recaptcha(body['g-recaptcha-response']);
+		if (instance.enableRecaptcha && instance.recaptchaSecretKey) {
+			recaptcha.init({
+				secret_key: instance.recaptchaSecretKey
+			});
 
-		if (!success) {
-			ctx.throw(400, 'recaptcha-failed');
+			const success = await recaptcha(body['g-recaptcha-response']);
+
+			if (!success) {
+				ctx.throw(400, 'recaptcha-failed');
+			}
 		}
 	}
 

From b2c703c173ccd19439bf7ec38a9570fd2eb432e8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Sat, 2 May 2020 10:31:37 +0900
Subject: [PATCH 10/12] Update src/server/api/private/signup.ts

---
 src/server/api/private/signup.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts
index 4e475e198..6dc252ac4 100644
--- a/src/server/api/private/signup.ts
+++ b/src/server/api/private/signup.ts
@@ -13,7 +13,7 @@ export default async (ctx: Koa.Context) => {
 	// Verify *Captcha
 	// ただしテスト時はこの機構は障害となるため無効にする
 	if (process.env.NODE_ENV !== 'test') {
-		if (instance.enableHcaptcha && instance.hcaptchaSecretKey) {	
+		if (instance.enableHcaptcha && instance.hcaptchaSecretKey) {
 			const success = await verify(instance.hcaptchaSecretKey, body['hcaptcha-response']).then(
 				({ success }) => success,
 				() => false,

From f57888eb4baeaa254d06e0169db2934fcef93122 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Sat, 2 May 2020 10:50:29 +0900
Subject: [PATCH 11/12] Update src/@types/hcaptcha.d.ts

---
 src/@types/hcaptcha.d.ts | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/@types/hcaptcha.d.ts b/src/@types/hcaptcha.d.ts
index ef3d44256..694c8388a 100644
--- a/src/@types/hcaptcha.d.ts
+++ b/src/@types/hcaptcha.d.ts
@@ -1,9 +1,11 @@
 declare module 'hcaptcha' {
-	export function verify(secret: string, token: string): Promise<{
+  interface IVerifyResponse {
 		success: boolean;
 		challenge_ts: string;
 		hostname: string;
 		credit?: boolean;
 		'error-codes'?: unknown[];
-	}>;
+  }
+
+	export function verify(secret: string, token: string): Promise<IVerifyResponse>;
 }

From a3ff1bfda74a3f7b8fdffd90c64530fb28cf1f8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Sat, 2 May 2020 10:51:12 +0900
Subject: [PATCH 12/12] Update hcaptcha.d.ts

---
 src/@types/hcaptcha.d.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/@types/hcaptcha.d.ts b/src/@types/hcaptcha.d.ts
index 694c8388a..afed58756 100644
--- a/src/@types/hcaptcha.d.ts
+++ b/src/@types/hcaptcha.d.ts
@@ -1,11 +1,11 @@
 declare module 'hcaptcha' {
-  interface IVerifyResponse {
+	interface IVerifyResponse {
 		success: boolean;
 		challenge_ts: string;
 		hostname: string;
 		credit?: boolean;
 		'error-codes'?: unknown[];
-  }
+	}
 
 	export function verify(secret: string, token: string): Promise<IVerifyResponse>;
 }