Skip to content

Commit 1549f84

Browse files
authored
feat: add CAPTCHA verification for anonymous comments to enhance security (#133)
#### What type of PR is this? /kind feature #### What this PR does / why we need it: 在匿名评论时增加图形验证码验证机制以提高安全性 #### Which issue(s) this PR fixes: Fixes #132 #### Does this PR introduce a user-facing change? ```release-note 在匿名评论时增加图形验证码验证机制以提高安全性 ```
1 parent 944bf37 commit 1549f84

22 files changed

+841
-66
lines changed

build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ repositories {
1616
}
1717

1818
dependencies {
19-
implementation platform('run.halo.tools.platform:plugin:2.9.0-SNAPSHOT')
19+
implementation platform('run.halo.tools.platform:plugin:2.13.0-SNAPSHOT')
2020
compileOnly 'run.halo.app:api'
2121

2222
testImplementation 'run.halo.app:api'
@@ -42,4 +42,5 @@ build {
4242

4343
halo {
4444
version = "2.15.0-rc.1"
45+
debug = true
4546
}

packages/comment-widget/src/base-form.ts

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import './emoji-button';
1+
import type { User } from '@halo-dev/api-client';
2+
import { consume } from '@lit/context';
23
import { css, html, LitElement } from 'lit';
4+
import { property, state } from 'lit/decorators.js';
35
import { createRef, Ref, ref } from 'lit/directives/ref.js';
46
import {
57
allowAnonymousCommentsContext,
68
baseUrlContext,
9+
captchaEnabledContext,
710
currentUserContext,
811
groupContext,
912
kindContext,
1013
nameContext,
14+
toastContext,
1115
} from './context';
12-
import { property, state } from 'lit/decorators.js';
13-
import type { User } from '@halo-dev/api-client';
16+
import './emoji-button';
17+
import './icons/icon-loading';
18+
import { ToastManager } from './lit-toast';
1419
import baseStyles from './styles/base';
15-
import { consume } from '@lit/context';
1620
import varStyles from './styles/var';
17-
import './icons/icon-loading';
1821

1922
export class BaseForm extends LitElement {
2023
@consume({ context: baseUrlContext })
@@ -29,6 +32,10 @@ export class BaseForm extends LitElement {
2932
@state()
3033
allowAnonymousComments = false;
3134

35+
@consume({ context: captchaEnabledContext, subscribe: true })
36+
@state()
37+
captchaEnabled = false;
38+
3239
@consume({ context: groupContext })
3340
@state()
3441
group = '';
@@ -41,9 +48,17 @@ export class BaseForm extends LitElement {
4148
@state()
4249
name = '';
4350

51+
@property({ type: String })
52+
@state()
53+
captcha = '';
54+
4455
@property({ type: Boolean })
4556
submitting = false;
4657

58+
@consume({ context: toastContext, subscribe: true })
59+
@state()
60+
toastManager: ToastManager | undefined;
61+
4762
textareaRef: Ref<HTMLTextAreaElement> = createRef<HTMLTextAreaElement>();
4863

4964
get customAccount() {
@@ -58,6 +73,25 @@ export class BaseForm extends LitElement {
5873
return `/console/login?redirect_uri=${encodeURIComponent(window.location.href + parentDomId)}`;
5974
}
6075

76+
get showCaptcha() {
77+
return this.captchaEnabled && !this.currentUser;
78+
}
79+
80+
async handleFetchCaptcha() {
81+
if (!this.showCaptcha) {
82+
return;
83+
}
84+
85+
const response = await fetch(`/apis/api.commentwidget.halo.run/v1alpha1/captcha/-/generate`);
86+
87+
if (!response.ok) {
88+
this.toastManager?.error('获取验证码失败');
89+
return;
90+
}
91+
92+
this.captcha = await response.text();
93+
}
94+
6195
handleOpenLoginPage() {
6296
window.location.href = this.loginUrl;
6397
}
@@ -124,6 +158,7 @@ export class BaseForm extends LitElement {
124158
override connectedCallback(): void {
125159
super.connectedCallback();
126160
this.addEventListener('keydown', this.onKeydown);
161+
this.handleFetchCaptcha();
127162
}
128163

129164
override disconnectedCallback(): void {
@@ -182,6 +217,20 @@ export class BaseForm extends LitElement {
182217
</button> `
183218
: ''}
184219
<div class="form__actions">
220+
${this.showCaptcha
221+
? html`
222+
<div class="form__action--captcha">
223+
<input name="captchaCode" type="text" placeholder="请输入验证码" />
224+
<img
225+
@click=${this.handleFetchCaptcha}
226+
src="${this.captcha}"
227+
alt="captcha"
228+
width="100%"
229+
/>
230+
</div>
231+
`
232+
: ''}
233+
185234
<emoji-button @emoji-select=${this.onEmojiSelect}></emoji-button>
186235
<button .disabled=${this.submitting} type="submit" class="form__button--submit">
187236
${this.submitting
@@ -286,7 +335,7 @@ export class BaseForm extends LitElement {
286335
border: 0.05em solid var(--component-form-input-border-color);
287336
font-size: 0.875em;
288337
display: block;
289-
height: 2.25em;
338+
height: 2.65em;
290339
max-width: 100%;
291340
outline: 0;
292341
padding: 0.4em 0.75em;
@@ -349,12 +398,26 @@ export class BaseForm extends LitElement {
349398
350399
.form__actions {
351400
display: flex;
401+
flex-wrap: wrap;
352402
align-items: center;
353403
gap: 0.75em;
354-
flex: 1 1 auto;
404+
width: 100%;
355405
justify-content: flex-end;
356406
}
357407
408+
.form__action--captcha {
409+
display: flex;
410+
align-items: center;
411+
gap: 0.3em;
412+
flex-direction: row-reverse;
413+
}
414+
415+
.form__action--captcha img {
416+
height: 2.25em;
417+
width: auto;
418+
border-radius: var(--base-border-radius);
419+
}
420+
358421
.form__button--submit {
359422
border-radius: var(--base-border-radius);
360423
background-color: var(--component-form-button-submit-bg-color);

packages/comment-widget/src/comment-form.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { html, LitElement } from 'lit';
2-
import { state } from 'lit/decorators.js';
1+
import { Comment, CommentRequest, User } from '@halo-dev/api-client';
32
import { consume } from '@lit/context';
3+
import { LitElement, html } from 'lit';
4+
import { state } from 'lit/decorators.js';
5+
import { Ref, createRef, ref } from 'lit/directives/ref.js';
6+
import './base-form';
7+
import { BaseForm } from './base-form';
48
import {
59
allowAnonymousCommentsContext,
610
baseUrlContext,
@@ -11,11 +15,8 @@ import {
1115
toastContext,
1216
versionContext,
1317
} from './context';
14-
import { Comment, CommentRequest, User } from '@halo-dev/api-client';
15-
import { createRef, Ref, ref } from 'lit/directives/ref.js';
16-
import { BaseForm } from './base-form';
17-
import './base-form';
1818
import { ToastManager } from './lit-toast';
19+
import { getCaptchaCodeHeader, isRequireCaptcha } from './utils/captcha';
1920

2021
export class CommentForm extends LitElement {
2122
@consume({ context: baseUrlContext })
@@ -53,11 +54,15 @@ export class CommentForm extends LitElement {
5354
@state()
5455
submitting = false;
5556

57+
@state()
58+
captcha = '';
59+
5660
baseFormRef: Ref<BaseForm> = createRef<BaseForm>();
5761

5862
override render() {
5963
return html` <base-form
6064
.submitting=${this.submitting}
65+
.captcha=${this.captcha}
6166
${ref(this.baseFormRef)}
6267
@submit="${this.onSubmit}"
6368
></base-form>`;
@@ -110,10 +115,20 @@ export class CommentForm extends LitElement {
110115
method: 'POST',
111116
headers: {
112117
'Content-Type': 'application/json',
118+
...getCaptchaCodeHeader(data.captchaCode),
113119
},
114120
body: JSON.stringify(commentRequest),
115121
});
116122

123+
if (isRequireCaptcha(response)) {
124+
const { captcha, detail } = await response.json();
125+
this.captcha = captcha;
126+
this.toastManager?.warn(detail);
127+
return;
128+
}
129+
130+
this.baseFormRef.value?.handleFetchCaptcha();
131+
117132
if (!response.ok) {
118133
throw new Error('评论失败,请稍后重试');
119134
}

packages/comment-widget/src/comment-widget.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,40 @@
1-
import { css, html, LitElement } from 'lit';
2-
import { property, state } from 'lit/decorators.js';
31
import { CommentVoList, User } from '@halo-dev/api-client';
4-
import { repeat } from 'lit/directives/repeat.js';
5-
import baseStyles from './styles/base';
62
import { provide } from '@lit/context';
3+
import { LitElement, css, html } from 'lit';
4+
import { property, state } from 'lit/decorators.js';
5+
import { repeat } from 'lit/directives/repeat.js';
76
import {
7+
AllUserPolicy,
8+
AnonymousUserPolicy,
9+
AvatarPolicyEnum,
10+
NoAvatarUserPolicy,
11+
setPolicyInstance,
12+
} from './avatar/avatar-policy';
13+
import { setAvatarProvider } from './avatar/providers';
14+
import './comment-form';
15+
import './comment-item';
16+
import './comment-pagination';
17+
import {
18+
allowAnonymousCommentsContext,
19+
avatarPolicyContext,
20+
avatarProviderContext,
21+
avatarProviderMirrorContext,
822
baseUrlContext,
23+
captchaEnabledContext,
924
currentUserContext,
1025
emojiDataUrlContext,
1126
groupContext,
1227
kindContext,
1328
nameContext,
1429
replySizeContext,
1530
toastContext,
31+
useAvatarProviderContext,
1632
versionContext,
1733
withRepliesContext,
18-
allowAnonymousCommentsContext,
19-
useAvatarProviderContext,
20-
avatarPolicyContext,
21-
avatarProviderContext,
22-
avatarProviderMirrorContext,
2334
} from './context';
24-
import './comment-form';
25-
import './comment-item';
26-
import './comment-pagination';
27-
import varStyles from './styles/var';
2835
import { ToastManager } from './lit-toast';
29-
import {
30-
AnonymousUserPolicy,
31-
AllUserPolicy,
32-
NoAvatarUserPolicy,
33-
AvatarPolicyEnum,
34-
setPolicyInstance,
35-
} from './avatar/avatar-policy';
36-
import { setAvatarProvider } from './avatar/providers';
36+
import baseStyles from './styles/base';
37+
import varStyles from './styles/var';
3738

3839
export class CommentWidget extends LitElement {
3940
@provide({ context: baseUrlContext })
@@ -98,6 +99,10 @@ export class CommentWidget extends LitElement {
9899
@state()
99100
allowAnonymousComments = false;
100101

102+
@provide({ context: captchaEnabledContext })
103+
@property({ type: Boolean, attribute: 'enable-captcha' })
104+
captchaEnabled = false;
105+
101106
@provide({ context: toastContext })
102107
@state()
103108
toastManager: ToastManager | undefined;

packages/comment-widget/src/context/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export const allowAnonymousCommentsContext = createContext<boolean>(
1818
Symbol('allowAnonymousComments')
1919
);
2020

21+
export const captchaEnabledContext = createContext<boolean>(Symbol('captchaEnabledContext'));
22+
2123
export const currentUserContext = createContext<User | undefined>(Symbol('currentUser'));
2224

2325
export const emojiDataUrlContext = createContext<string>(Symbol('emojiDataUrl'));

packages/comment-widget/src/reply-form.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import './base-form';
21
import { CommentVo, Reply, ReplyRequest, ReplyVo, User } from '@halo-dev/api-client';
3-
import { html, LitElement } from 'lit';
4-
import { createRef, Ref, ref } from 'lit/directives/ref.js';
2+
import { consume } from '@lit/context';
3+
import { LitElement, html } from 'lit';
4+
import { property, state } from 'lit/decorators.js';
5+
import { Ref, createRef, ref } from 'lit/directives/ref.js';
6+
import './base-form';
7+
import { BaseForm } from './base-form';
58
import {
69
allowAnonymousCommentsContext,
710
baseUrlContext,
811
currentUserContext,
912
toastContext,
1013
} from './context';
11-
import { property, state } from 'lit/decorators.js';
12-
import { BaseForm } from './base-form';
13-
import { consume } from '@lit/context';
1414
import { ToastManager } from './lit-toast';
15+
import { getCaptchaCodeHeader, isRequireCaptcha } from './utils/captcha';
1516

1617
export class ReplyForm extends LitElement {
1718
@consume({ context: baseUrlContext })
@@ -39,6 +40,9 @@ export class ReplyForm extends LitElement {
3940
@state()
4041
submitting = false;
4142

43+
@state()
44+
captcha = '';
45+
4246
baseFormRef: Ref<BaseForm> = createRef<BaseForm>();
4347

4448
override connectedCallback(): void {
@@ -53,6 +57,7 @@ export class ReplyForm extends LitElement {
5357
override render() {
5458
return html` <base-form
5559
.submitting=${this.submitting}
60+
.captcha=${this.captcha}
5661
${ref(this.baseFormRef)}
5762
@submit="${this.onSubmit}"
5863
></base-form>`;
@@ -105,11 +110,21 @@ export class ReplyForm extends LitElement {
105110
method: 'POST',
106111
headers: {
107112
'Content-Type': 'application/json',
113+
...getCaptchaCodeHeader(data.captchaCode),
108114
},
109115
body: JSON.stringify(replyRequest),
110116
}
111117
);
112118

119+
if (isRequireCaptcha(response)) {
120+
const { captcha, detail } = await response.json();
121+
this.captcha = captcha;
122+
this.toastManager?.warn(detail);
123+
return;
124+
}
125+
126+
this.baseFormRef.value?.handleFetchCaptcha();
127+
113128
if (!response.ok) {
114129
throw new Error('评论失败,请稍后重试');
115130
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const getCaptchaCodeHeader = (code: string): Record<string, string> => {
2+
if (!code || code.trim().length === 0) {
3+
return {};
4+
}
5+
return {
6+
'X-Captcha-Code': code,
7+
};
8+
};
9+
10+
export const isRequireCaptcha = (response: Response) => {
11+
return response.status === 403 && response.headers.get('X-Require-Captcha');
12+
};

0 commit comments

Comments
 (0)