Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add CAPTCHA verification for anonymous comments to enhance security #133

Merged
merged 6 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ repositories {
}

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

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

halo {
version = "2.15.0-rc.1"
debug = true
}
77 changes: 70 additions & 7 deletions packages/comment-widget/src/base-form.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import './emoji-button';
import type { User } from '@halo-dev/api-client';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import {
allowAnonymousCommentsContext,
baseUrlContext,
captchaEnabledContext,
currentUserContext,
groupContext,
kindContext,
nameContext,
toastContext,
} from './context';
import { property, state } from 'lit/decorators.js';
import type { User } from '@halo-dev/api-client';
import './emoji-button';
import './icons/icon-loading';
import { ToastManager } from './lit-toast';
import baseStyles from './styles/base';
import { consume } from '@lit/context';
import varStyles from './styles/var';
import './icons/icon-loading';

export class BaseForm extends LitElement {
@consume({ context: baseUrlContext })
Expand All @@ -29,6 +32,10 @@ export class BaseForm extends LitElement {
@state()
allowAnonymousComments = false;

@consume({ context: captchaEnabledContext, subscribe: true })
@state()
captchaEnabled = false;

@consume({ context: groupContext })
@state()
group = '';
Expand All @@ -41,9 +48,17 @@ export class BaseForm extends LitElement {
@state()
name = '';

@property({ type: String })
@state()
captcha = '';

@property({ type: Boolean })
submitting = false;

@consume({ context: toastContext, subscribe: true })
@state()
toastManager: ToastManager | undefined;

textareaRef: Ref<HTMLTextAreaElement> = createRef<HTMLTextAreaElement>();

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

get showCaptcha() {
return this.captchaEnabled && !this.currentUser;
}

async handleFetchCaptcha() {
if (!this.showCaptcha) {
return;
}

const response = await fetch(`/apis/api.commentwidget.halo.run/v1alpha1/captcha/-/generate`);

if (!response.ok) {
this.toastManager?.error('获取验证码失败');
return;
}

this.captcha = await response.text();
}

handleOpenLoginPage() {
window.location.href = this.loginUrl;
}
Expand Down Expand Up @@ -124,6 +158,7 @@ export class BaseForm extends LitElement {
override connectedCallback(): void {
super.connectedCallback();
this.addEventListener('keydown', this.onKeydown);
this.handleFetchCaptcha();
}

override disconnectedCallback(): void {
Expand Down Expand Up @@ -182,6 +217,20 @@ export class BaseForm extends LitElement {
</button> `
: ''}
<div class="form__actions">
${this.showCaptcha
? html`
<div class="form__action--captcha">
<input name="captchaCode" type="text" placeholder="请输入验证码" />
<img
@click=${this.handleFetchCaptcha}
src="${this.captcha}"
alt="captcha"
width="100%"
/>
</div>
`
: ''}

<emoji-button @emoji-select=${this.onEmojiSelect}></emoji-button>
<button .disabled=${this.submitting} type="submit" class="form__button--submit">
${this.submitting
Expand Down Expand Up @@ -286,7 +335,7 @@ export class BaseForm extends LitElement {
border: 0.05em solid var(--component-form-input-border-color);
font-size: 0.875em;
display: block;
height: 2.25em;
height: 2.65em;
max-width: 100%;
outline: 0;
padding: 0.4em 0.75em;
Expand Down Expand Up @@ -349,12 +398,26 @@ export class BaseForm extends LitElement {

.form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75em;
flex: 1 1 auto;
width: 100%;
justify-content: flex-end;
}

.form__action--captcha {
display: flex;
align-items: center;
gap: 0.3em;
flex-direction: row-reverse;
}

.form__action--captcha img {
height: 2.25em;
width: auto;
border-radius: var(--base-border-radius);
}

.form__button--submit {
border-radius: var(--base-border-radius);
background-color: var(--component-form-button-submit-bg-color);
Expand Down
27 changes: 21 additions & 6 deletions packages/comment-widget/src/comment-form.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { html, LitElement } from 'lit';
import { state } from 'lit/decorators.js';
import { Comment, CommentRequest, User } from '@halo-dev/api-client';
import { consume } from '@lit/context';
import { LitElement, html } from 'lit';
import { state } from 'lit/decorators.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import './base-form';
import { BaseForm } from './base-form';
import {
allowAnonymousCommentsContext,
baseUrlContext,
Expand All @@ -11,11 +15,8 @@ import {
toastContext,
versionContext,
} from './context';
import { Comment, CommentRequest, User } from '@halo-dev/api-client';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { BaseForm } from './base-form';
import './base-form';
import { ToastManager } from './lit-toast';
import { getCaptchaCodeHeader, isRequireCaptcha } from './utils/captcha';

export class CommentForm extends LitElement {
@consume({ context: baseUrlContext })
Expand Down Expand Up @@ -53,11 +54,15 @@ export class CommentForm extends LitElement {
@state()
submitting = false;

@state()
captcha = '';

baseFormRef: Ref<BaseForm> = createRef<BaseForm>();

override render() {
return html` <base-form
.submitting=${this.submitting}
.captcha=${this.captcha}
${ref(this.baseFormRef)}
@submit="${this.onSubmit}"
></base-form>`;
Expand Down Expand Up @@ -110,10 +115,20 @@ export class CommentForm extends LitElement {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getCaptchaCodeHeader(data.captchaCode),
},
body: JSON.stringify(commentRequest),
});

if (isRequireCaptcha(response)) {
const { captcha, detail } = await response.json();
this.captcha = captcha;
this.toastManager?.warn(detail);
return;
}

this.baseFormRef.value?.handleFetchCaptcha();

if (!response.ok) {
throw new Error('评论失败,请稍后重试');
}
Expand Down
47 changes: 26 additions & 21 deletions packages/comment-widget/src/comment-widget.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,40 @@
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { CommentVoList, User } from '@halo-dev/api-client';
import { repeat } from 'lit/directives/repeat.js';
import baseStyles from './styles/base';
import { provide } from '@lit/context';
import { LitElement, css, html } from 'lit';
import { property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import {
AllUserPolicy,
AnonymousUserPolicy,
AvatarPolicyEnum,
NoAvatarUserPolicy,
setPolicyInstance,
} from './avatar/avatar-policy';
import { setAvatarProvider } from './avatar/providers';
import './comment-form';
import './comment-item';
import './comment-pagination';
import {
allowAnonymousCommentsContext,
avatarPolicyContext,
avatarProviderContext,
avatarProviderMirrorContext,
baseUrlContext,
captchaEnabledContext,
currentUserContext,
emojiDataUrlContext,
groupContext,
kindContext,
nameContext,
replySizeContext,
toastContext,
useAvatarProviderContext,
versionContext,
withRepliesContext,
allowAnonymousCommentsContext,
useAvatarProviderContext,
avatarPolicyContext,
avatarProviderContext,
avatarProviderMirrorContext,
} from './context';
import './comment-form';
import './comment-item';
import './comment-pagination';
import varStyles from './styles/var';
import { ToastManager } from './lit-toast';
import {
AnonymousUserPolicy,
AllUserPolicy,
NoAvatarUserPolicy,
AvatarPolicyEnum,
setPolicyInstance,
} from './avatar/avatar-policy';
import { setAvatarProvider } from './avatar/providers';
import baseStyles from './styles/base';
import varStyles from './styles/var';

export class CommentWidget extends LitElement {
@provide({ context: baseUrlContext })
Expand Down Expand Up @@ -98,6 +99,10 @@ export class CommentWidget extends LitElement {
@state()
allowAnonymousComments = false;

@provide({ context: captchaEnabledContext })
@property({ type: Boolean, attribute: 'enable-captcha' })
captchaEnabled = false;

@provide({ context: toastContext })
@state()
toastManager: ToastManager | undefined;
Expand Down
2 changes: 2 additions & 0 deletions packages/comment-widget/src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const allowAnonymousCommentsContext = createContext<boolean>(
Symbol('allowAnonymousComments')
);

export const captchaEnabledContext = createContext<boolean>(Symbol('captchaEnabledContext'));

export const currentUserContext = createContext<User | undefined>(Symbol('currentUser'));

export const emojiDataUrlContext = createContext<string>(Symbol('emojiDataUrl'));
Expand Down
27 changes: 21 additions & 6 deletions packages/comment-widget/src/reply-form.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import './base-form';
import { CommentVo, Reply, ReplyRequest, ReplyVo, User } from '@halo-dev/api-client';
import { html, LitElement } from 'lit';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { consume } from '@lit/context';
import { LitElement, html } from 'lit';
import { property, state } from 'lit/decorators.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import './base-form';
import { BaseForm } from './base-form';
import {
allowAnonymousCommentsContext,
baseUrlContext,
currentUserContext,
toastContext,
} from './context';
import { property, state } from 'lit/decorators.js';
import { BaseForm } from './base-form';
import { consume } from '@lit/context';
import { ToastManager } from './lit-toast';
import { getCaptchaCodeHeader, isRequireCaptcha } from './utils/captcha';

export class ReplyForm extends LitElement {
@consume({ context: baseUrlContext })
Expand Down Expand Up @@ -39,6 +40,9 @@ export class ReplyForm extends LitElement {
@state()
submitting = false;

@state()
captcha = '';

baseFormRef: Ref<BaseForm> = createRef<BaseForm>();

override connectedCallback(): void {
Expand All @@ -53,6 +57,7 @@ export class ReplyForm extends LitElement {
override render() {
return html` <base-form
.submitting=${this.submitting}
.captcha=${this.captcha}
${ref(this.baseFormRef)}
@submit="${this.onSubmit}"
></base-form>`;
Expand Down Expand Up @@ -105,11 +110,21 @@ export class ReplyForm extends LitElement {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getCaptchaCodeHeader(data.captchaCode),
},
body: JSON.stringify(replyRequest),
}
);

if (isRequireCaptcha(response)) {
const { captcha, detail } = await response.json();
this.captcha = captcha;
this.toastManager?.warn(detail);
return;
}

this.baseFormRef.value?.handleFetchCaptcha();

if (!response.ok) {
throw new Error('评论失败,请稍后重试');
}
Expand Down
12 changes: 12 additions & 0 deletions packages/comment-widget/src/utils/captcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const getCaptchaCodeHeader = (code: string): Record<string, string> => {
if (!code || code.trim().length === 0) {
return {};
}
return {
'X-Captcha-Code': code,
};
};

export const isRequireCaptcha = (response: Response) => {
return response.status === 403 && response.headers.get('X-Require-Captcha');
};
Loading
Loading