feat(admin): 添加管理员密钥验证功能
- 新增管理员密钥设置及验证流程 - 实现前端验证弹窗及本地存储加密 - 修改评论提交接口支持管理员验证 - 添加相关样式和文档说明
This commit is contained in:
@@ -40,6 +40,8 @@ export type CommentSettingsResponse = {
|
||||
avatarPrefix: string | null;
|
||||
adminEnabled: boolean;
|
||||
allowedDomains?: string[];
|
||||
adminKey?: string | null;
|
||||
adminKeySet?: boolean;
|
||||
};
|
||||
|
||||
export type EmailNotifySettingsResponse = {
|
||||
@@ -132,6 +134,7 @@ export function saveCommentSettings(data: {
|
||||
avatarPrefix?: string;
|
||||
adminEnabled?: boolean;
|
||||
allowedDomains?: string[];
|
||||
adminKey?: string;
|
||||
}): Promise<{ message: string }> {
|
||||
return put<{ message: string }>('/admin/settings/comments', data);
|
||||
}
|
||||
@@ -143,4 +146,3 @@ export function exportComments(): Promise<any[]> {
|
||||
export function importComments(data: any[]): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/comments/import', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
<input v-model="avatarPrefix" class="form-input" type="text" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label"
|
||||
>允许调用的域名(多个域名用逗号分隔,留空则不限制)</label
|
||||
>
|
||||
<label class="form-label">允许调用的域名(多个域名用逗号分隔,留空则不限制)</label>
|
||||
<textarea
|
||||
v-model="allowedDomains"
|
||||
class="form-input"
|
||||
@@ -42,6 +40,18 @@
|
||||
placeholder="例如: example.com, test.com"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">管理员评论密钥</label>
|
||||
<div class="form-hint" style="margin-bottom: 4px;">
|
||||
设置后前台使用管理员邮箱评论需输入此密钥。
|
||||
</div>
|
||||
<input
|
||||
v-model="commentAdminKey"
|
||||
class="form-input"
|
||||
placeholder="输入密钥以设置或修改"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="card-button" :disabled="savingComment" @click="saveComment">
|
||||
<span v-if="savingComment">保存中...</span>
|
||||
@@ -287,6 +297,8 @@ const commentAdminBadge = ref("");
|
||||
const avatarPrefix = ref("");
|
||||
const commentAdminEnabled = ref(false);
|
||||
const allowedDomains = ref("");
|
||||
const commentAdminKey = ref("");
|
||||
const adminKeySet = ref(false);
|
||||
const savingEmail = ref(false);
|
||||
const testingEmail = ref(false);
|
||||
const savingComment = ref(false);
|
||||
@@ -344,6 +356,8 @@ async function load() {
|
||||
allowedDomains.value = commentRes.allowedDomains
|
||||
? commentRes.allowedDomains.join(", ")
|
||||
: "";
|
||||
commentAdminKey.value = commentRes.adminKey || "";
|
||||
adminKeySet.value = !!commentRes.adminKeySet;
|
||||
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
|
||||
|
||||
if (emailNotifyRes.templates) {
|
||||
@@ -472,7 +486,9 @@ async function saveComment() {
|
||||
.split(/[,,\n]/)
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean),
|
||||
adminKey: commentAdminKey.value || undefined,
|
||||
});
|
||||
|
||||
showToast(res.message || "保存成功", "success");
|
||||
} catch (e: any) {
|
||||
message.value = e.message || "保存失败";
|
||||
|
||||
@@ -22,7 +22,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return c.json({ message: '无效的请求体' }, 400);
|
||||
}
|
||||
const { post_slug, content: rawContent, name: rawName, email, url, post_title, post_url } = data;
|
||||
const { post_slug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken } = data;
|
||||
const parentId = (data as any).parent_id ?? (data as any).parentId ?? null;
|
||||
if (!post_slug || typeof post_slug !== 'string') {
|
||||
return c.json({ message: 'post_slug 必填' }, 400);
|
||||
@@ -44,6 +44,44 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
// 1. 获取 IP (Worker 获取 IP 的标准方式)
|
||||
const ip = c.req.header('cf-connecting-ip') || "127.0.0.1";
|
||||
|
||||
// 1.5 管理员身份验证
|
||||
const adminEmail = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?').bind('comment_admin_email').first<string>('value');
|
||||
|
||||
if (adminEmail && email === adminEmail) {
|
||||
const adminKey = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?').bind('comment_admin_key_hash').first<string>('value');
|
||||
|
||||
if (adminKey) {
|
||||
const lockKey = `admin_lock:${ip}`;
|
||||
const isLocked = await c.env.CWD_AUTH_KV.get(lockKey);
|
||||
if (isLocked) {
|
||||
return c.json({ message: "验证失败次数过多,请30分钟后再试" }, 403);
|
||||
}
|
||||
|
||||
if (!adminToken) {
|
||||
return c.json({ message: "请输入管理员密钥", requireAuth: true }, 401);
|
||||
}
|
||||
|
||||
if (adminToken !== adminKey) {
|
||||
const failKey = `admin_fail:${ip}`;
|
||||
const failsStr = await c.env.CWD_AUTH_KV.get(failKey);
|
||||
let fails = failsStr ? parseInt(failsStr) : 0;
|
||||
fails++;
|
||||
|
||||
if (fails >= 3) {
|
||||
await c.env.CWD_AUTH_KV.put(lockKey, '1', { expirationTtl: 1800 });
|
||||
await c.env.CWD_AUTH_KV.delete(failKey);
|
||||
return c.json({ message: "验证失败次数过多,请30分钟后再试" }, 403);
|
||||
} else {
|
||||
await c.env.CWD_AUTH_KV.put(failKey, fails.toString(), { expirationTtl: 3600 });
|
||||
return c.json({ message: "密钥错误" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证成功,清除失败记录
|
||||
await c.env.CWD_AUTH_KV.delete(`admin_fail:${ip}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查评论频率控制 (对应 canPostComment)
|
||||
// 这里建议使用 D1 查最近一条评论的时间,或者直接放行(如果使用了 Cloudflare WAF)
|
||||
const lastComment = await c.env.CWD_DB.prepare(
|
||||
|
||||
49
cwd-comments-api/src/api/public/verifyAdminKey.ts
Normal file
49
cwd-comments-api/src/api/public/verifyAdminKey.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
export const verifyAdminKey = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
const data = await c.req.json();
|
||||
const { adminToken } = data;
|
||||
const ip = c.req.header('cf-connecting-ip') || "127.0.0.1";
|
||||
|
||||
if (!adminToken) {
|
||||
return c.json({ message: "请输入管理员密钥" }, 401);
|
||||
}
|
||||
|
||||
// Check lock
|
||||
const lockKey = `admin_lock:${ip}`;
|
||||
const isLocked = await c.env.CWD_AUTH_KV.get(lockKey);
|
||||
if (isLocked) {
|
||||
return c.json({ message: "验证失败次数过多,请30分钟后再试" }, 403);
|
||||
}
|
||||
|
||||
const adminKey = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?').bind('comment_admin_key_hash').first<string>('value');
|
||||
|
||||
if (!adminKey) {
|
||||
// If no key set, verification is technically successful or not needed?
|
||||
// If key is not set, we can't verify. Return success?
|
||||
// Requirement says "Provide admin key setting...".
|
||||
return c.json({ message: "未设置管理员密钥" }, 200);
|
||||
}
|
||||
|
||||
if (adminToken !== adminKey) {
|
||||
// Handle failure
|
||||
const failKey = `admin_fail:${ip}`;
|
||||
const failsStr = await c.env.CWD_AUTH_KV.get(failKey);
|
||||
let fails = failsStr ? parseInt(failsStr) : 0;
|
||||
fails++;
|
||||
|
||||
if (fails >= 3) {
|
||||
await c.env.CWD_AUTH_KV.put(lockKey, '1', { expirationTtl: 1800 }); // 30 mins
|
||||
await c.env.CWD_AUTH_KV.delete(failKey);
|
||||
return c.json({ message: "验证失败次数过多,请30分钟后再试" }, 403);
|
||||
} else {
|
||||
await c.env.CWD_AUTH_KV.put(failKey, fails.toString(), { expirationTtl: 3600 }); // 1 hour reset
|
||||
return c.json({ message: "密钥错误" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
await c.env.CWD_AUTH_KV.delete(`admin_fail:${ip}`);
|
||||
return c.json({ message: "验证通过" });
|
||||
};
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
loadEmailNotificationSettings,
|
||||
saveEmailNotificationSettings
|
||||
} from './utils/email';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
import { getComments } from './api/public/getComments';
|
||||
import { postComment } from './api/public/postComment';
|
||||
import { verifyAdminKey } from './api/public/verifyAdminKey';
|
||||
import { adminLogin } from './api/admin/login';
|
||||
import { deleteComment } from './api/admin/deleteComment';
|
||||
import { listComments } from './api/admin/listComments';
|
||||
@@ -21,13 +23,15 @@ import { setAdminEmail } from './api/admin/setAdminEmail';
|
||||
import { testEmail } from './api/admin/testEmail';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
const VERSION = 'v0.0.1';
|
||||
const VERSION = `v${packageJson.version}`;
|
||||
|
||||
const COMMENT_ADMIN_EMAIL_KEY = 'comment_admin_email';
|
||||
const COMMENT_ADMIN_BADGE_KEY = 'comment_admin_badge';
|
||||
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
|
||||
const COMMENT_ADMIN_ENABLED_KEY = 'comment_admin_enabled';
|
||||
const COMMENT_ALLOWED_DOMAINS_KEY = 'comment_allowed_domains';
|
||||
const COMMENT_ADMIN_KEY_HASH_KEY = 'comment_admin_key_hash';
|
||||
|
||||
|
||||
async function loadCommentSettings(env: Bindings) {
|
||||
await env.CWD_DB.prepare(
|
||||
@@ -38,10 +42,11 @@ async function loadCommentSettings(env: Bindings) {
|
||||
COMMENT_ADMIN_BADGE_KEY,
|
||||
COMMENT_AVATAR_PREFIX_KEY,
|
||||
COMMENT_ADMIN_ENABLED_KEY,
|
||||
COMMENT_ALLOWED_DOMAINS_KEY
|
||||
COMMENT_ALLOWED_DOMAINS_KEY,
|
||||
COMMENT_ADMIN_KEY_HASH_KEY
|
||||
];
|
||||
const { results } = await env.CWD_DB.prepare(
|
||||
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?)'
|
||||
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(...keys)
|
||||
.all<{ key: string; value: string }>();
|
||||
@@ -67,7 +72,9 @@ async function loadCommentSettings(env: Bindings) {
|
||||
adminBadge: map.get(COMMENT_ADMIN_BADGE_KEY) ?? null,
|
||||
avatarPrefix: map.get(COMMENT_AVATAR_PREFIX_KEY) ?? null,
|
||||
adminEnabled,
|
||||
allowedDomains
|
||||
allowedDomains,
|
||||
adminKey: map.get(COMMENT_ADMIN_KEY_HASH_KEY) ?? null,
|
||||
adminKeySet: !!map.get(COMMENT_ADMIN_KEY_HASH_KEY)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,12 +86,18 @@ async function saveCommentSettings(
|
||||
avatarPrefix?: string;
|
||||
adminEnabled?: boolean;
|
||||
allowedDomains?: string[];
|
||||
adminKey?: string;
|
||||
}
|
||||
) {
|
||||
await env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
let adminKeyValue: string | undefined;
|
||||
if (settings.adminKey !== undefined) {
|
||||
adminKeyValue = settings.adminKey;
|
||||
}
|
||||
|
||||
const entries: { key: string; value: string | null | undefined }[] = [
|
||||
{ key: COMMENT_ADMIN_EMAIL_KEY, value: settings.adminEmail },
|
||||
{ key: COMMENT_ADMIN_BADGE_KEY, value: settings.adminBadge },
|
||||
@@ -101,6 +114,10 @@ async function saveCommentSettings(
|
||||
{
|
||||
key: COMMENT_ALLOWED_DOMAINS_KEY,
|
||||
value: settings.allowedDomains ? settings.allowedDomains.join(',') : undefined
|
||||
},
|
||||
{
|
||||
key: COMMENT_ADMIN_KEY_HASH_KEY,
|
||||
value: adminKeyValue
|
||||
}
|
||||
];
|
||||
|
||||
@@ -152,6 +169,7 @@ app.get('/', (c) => {
|
||||
|
||||
app.get('/api/comments', getComments);
|
||||
app.post('/api/comments', postComment);
|
||||
app.post('/api/verify-admin', verifyAdminKey);
|
||||
app.get('/api/config/comments', async (c) => {
|
||||
try {
|
||||
const settings = await loadCommentSettings(c.env);
|
||||
@@ -215,6 +233,7 @@ app.put('/admin/settings/comments', async (c) => {
|
||||
const rawAvatarPrefix = typeof body.avatarPrefix === 'string' ? body.avatarPrefix : '';
|
||||
const rawAdminEnabled = body.adminEnabled;
|
||||
const rawAllowedDomains = Array.isArray(body.allowedDomains) ? body.allowedDomains : [];
|
||||
const rawAdminKey = typeof body.adminKey === 'string' ? body.adminKey : undefined;
|
||||
|
||||
const adminEmail = rawAdminEmail.trim();
|
||||
const adminBadge = rawAdminBadge.trim();
|
||||
@@ -226,6 +245,7 @@ app.put('/admin/settings/comments', async (c) => {
|
||||
const allowedDomains = rawAllowedDomains
|
||||
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
|
||||
.filter(Boolean);
|
||||
const adminKey = rawAdminKey; // Can be undefined or empty string
|
||||
|
||||
if (adminEmail && !isValidEmail(adminEmail)) {
|
||||
return c.json({ message: '邮箱格式不正确' }, 400);
|
||||
@@ -236,7 +256,8 @@ app.put('/admin/settings/comments', async (c) => {
|
||||
adminBadge,
|
||||
avatarPrefix,
|
||||
adminEnabled,
|
||||
allowedDomains
|
||||
allowedDomains,
|
||||
adminKey
|
||||
});
|
||||
|
||||
return c.json({ message: '保存成功' });
|
||||
|
||||
8
cwd-comments-api/src/utils/crypto.ts
Normal file
8
cwd-comments-api/src/utils/crypto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export async function hashKey(key: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(key);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
@@ -44,3 +44,35 @@
|
||||
// 动态切换主题
|
||||
comments.updateConfig({ theme: 'dark' });
|
||||
```
|
||||
|
||||
### 博客程序使用示例
|
||||
|
||||
### HTML
|
||||
|
||||
```html
|
||||
<div id="comments"></div>
|
||||
<script src="https://cwd-comments.zishu.me/cwd-comments.js"></script>
|
||||
<script>
|
||||
const comments = new CWDComments({
|
||||
el: '#comments',
|
||||
apiBaseUrl: 'https://your-api.example.com', // 你部署的后端接口地址
|
||||
});
|
||||
comments.mount();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Astro
|
||||
|
||||
```astro
|
||||
<div id="comments"></div>
|
||||
<script src="https://cwd-comments.zishu.me/cwd-comments.js" is:inline></script>
|
||||
<script is:inline>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const comments = new window.CWDComments({
|
||||
el: '#comments',
|
||||
apiBaseUrl: 'https://your-api.example.com',
|
||||
});
|
||||
comments.mount();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
98
widget/src/components/AdminAuthModal.js
Normal file
98
widget/src/components/AdminAuthModal.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component } from './Component.js';
|
||||
|
||||
export class AdminAuthModal extends Component {
|
||||
constructor(container, props = {}) {
|
||||
super(container, props);
|
||||
this.state = {
|
||||
key: '',
|
||||
error: '',
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { key, error, loading } = this.state;
|
||||
|
||||
const overlay = this.createElement('div', {
|
||||
className: 'cwd-modal-overlay',
|
||||
children: [
|
||||
this.createElement('div', {
|
||||
className: 'cwd-modal',
|
||||
children: [
|
||||
this.createTextElement('h3', '管理员身份验证', 'cwd-modal-title'),
|
||||
this.createElement('div', {
|
||||
className: 'cwd-modal-body',
|
||||
children: [
|
||||
this.createTextElement('p', '检测到管理员邮箱,请输入密钥以继续。', 'cwd-modal-desc'),
|
||||
this.createElement('input', {
|
||||
className: `cwd-form-input ${error ? 'cwd-input-error' : ''}`,
|
||||
attributes: {
|
||||
type: 'password',
|
||||
placeholder: '请输入管理员密钥',
|
||||
value: key,
|
||||
disabled: loading,
|
||||
onInput: (e) => this.setState({ key: e.target.value, error: '' }),
|
||||
onKeydown: (e) => {
|
||||
if (e.key === 'Enter') this.handleSubmit();
|
||||
}
|
||||
}
|
||||
}),
|
||||
error ? this.createTextElement('div', error, 'cwd-error-text') : null
|
||||
]
|
||||
}),
|
||||
this.createElement('div', {
|
||||
className: 'cwd-modal-actions',
|
||||
children: [
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn cwd-btn-secondary',
|
||||
text: '取消',
|
||||
attributes: {
|
||||
type: 'button',
|
||||
disabled: loading,
|
||||
onClick: () => this.props.onCancel && this.props.onCancel()
|
||||
}
|
||||
}),
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn cwd-btn-primary',
|
||||
text: loading ? '验证中...' : '验证',
|
||||
attributes: {
|
||||
type: 'button',
|
||||
disabled: loading || !key,
|
||||
onClick: () => this.handleSubmit()
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
this.empty(this.container);
|
||||
this.container.appendChild(overlay);
|
||||
|
||||
// Focus input
|
||||
const input = overlay.querySelector('input');
|
||||
if (input) setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
setState(newState) {
|
||||
this.state = { ...this.state, ...newState };
|
||||
this.render();
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
if (!this.state.key) return;
|
||||
if (this.props.onSubmit) {
|
||||
this.setState({ loading: true });
|
||||
this.props.onSubmit(this.state.key)
|
||||
.catch(err => {
|
||||
this.setState({ error: err.message || '验证失败', loading: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.empty(this.container);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
|
||||
import { Component } from './Component.js';
|
||||
import { AdminAuthModal } from './AdminAuthModal.js';
|
||||
import { auth } from '../utils/auth.js';
|
||||
|
||||
export class CommentForm extends Component {
|
||||
/**
|
||||
@@ -13,12 +15,15 @@ export class CommentForm extends Component {
|
||||
* @param {boolean} props.submitting - 是否正在提交
|
||||
* @param {Function} props.onSubmit - 提交回调
|
||||
* @param {Function} props.onFieldChange - 字段变化回调
|
||||
* @param {string} props.adminEmail - 管理员邮箱
|
||||
* @param {Function} props.onVerifyAdmin - 验证管理员回调 (returns Promise)
|
||||
*/
|
||||
constructor(container, props = {}) {
|
||||
super(container, props);
|
||||
this.state = {
|
||||
localForm: { ...props.form },
|
||||
};
|
||||
this.modal = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -26,6 +31,8 @@ export class CommentForm extends Component {
|
||||
const { localForm } = this.state;
|
||||
|
||||
const canSubmit = localForm.name.trim() && localForm.email.trim() && localForm.content.trim();
|
||||
const isAdmin = this.props.adminEmail && localForm.email.trim() === this.props.adminEmail;
|
||||
const isVerified = isAdmin && auth.hasToken();
|
||||
|
||||
const root = this.createElement('form', {
|
||||
className: 'cwd-comment-form',
|
||||
@@ -48,7 +55,30 @@ export class CommentForm extends Component {
|
||||
// 昵称
|
||||
this.createFormField('昵称 *', 'text', 'name', localForm.name, formErrors.name),
|
||||
// 邮箱
|
||||
this.createFormField('邮箱 *', 'email', 'email', localForm.email, formErrors.email),
|
||||
this.createElement('div', {
|
||||
className: 'cwd-form-field-wrapper',
|
||||
children: [
|
||||
this.createFormField('邮箱 *', 'email', 'email', localForm.email, formErrors.email),
|
||||
isVerified ? this.createElement('div', {
|
||||
className: 'cwd-admin-controls',
|
||||
children: [
|
||||
this.createTextElement('span', '✓ 已验证', 'cwd-admin-verified'),
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn-text',
|
||||
text: '退出',
|
||||
attributes: {
|
||||
type: 'button',
|
||||
title: '清除管理员凭证',
|
||||
onClick: () => {
|
||||
auth.clearToken();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}) : null
|
||||
]
|
||||
}),
|
||||
// 网址
|
||||
this.createFormField('网址', 'url', 'url', localForm.url, formErrors.url),
|
||||
],
|
||||
@@ -216,6 +246,9 @@ export class CommentForm extends Component {
|
||||
value: value || '',
|
||||
disabled: this.props.submitting,
|
||||
onInput: (e) => this.handleFieldChange(fieldName, e.target.value),
|
||||
onBlur: (e) => {
|
||||
if (fieldName === 'email') this.handleEmailBlur(e.target.value);
|
||||
}
|
||||
},
|
||||
}),
|
||||
...(error ? [this.createTextElement('span', error, 'cwd-error-text')] : []),
|
||||
@@ -256,4 +289,43 @@ export class CommentForm extends Component {
|
||||
this.props.onSubmit(this.state.localForm);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEmailBlur(email) {
|
||||
if (!email || !this.props.adminEmail) return;
|
||||
if (email.trim() === this.props.adminEmail) {
|
||||
// Check local storage
|
||||
if (auth.hasToken()) {
|
||||
// Already valid
|
||||
return;
|
||||
}
|
||||
// Show modal
|
||||
this.showAuthModal();
|
||||
}
|
||||
}
|
||||
|
||||
showAuthModal() {
|
||||
// Create modal container if not exists
|
||||
let modalContainer = this.elements.root.querySelector('.cwd-modal-container');
|
||||
if (!modalContainer) {
|
||||
modalContainer = document.createElement('div');
|
||||
modalContainer.className = 'cwd-modal-container';
|
||||
this.elements.root.appendChild(modalContainer);
|
||||
}
|
||||
|
||||
this.modal = new AdminAuthModal(modalContainer, {
|
||||
onCancel: () => {
|
||||
this.modal.destroy();
|
||||
this.modal = null;
|
||||
},
|
||||
onSubmit: async (key) => {
|
||||
if (this.props.onVerifyAdmin) {
|
||||
await this.props.onVerifyAdmin(key);
|
||||
auth.saveToken(key);
|
||||
this.modal.destroy();
|
||||
this.modal = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.modal.render();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ export class CWDComments {
|
||||
}
|
||||
|
||||
const api = createApiClient(this.config);
|
||||
this.api = api;
|
||||
this.store = createCommentStore(this.config, api.fetchComments.bind(api), api.submitComment.bind(api));
|
||||
|
||||
this.unsubscribe = this.store.store.subscribe((state) => {
|
||||
@@ -230,6 +231,8 @@ export class CWDComments {
|
||||
submitting: state.submitting,
|
||||
onSubmit: () => this._handleSubmit(),
|
||||
onFieldChange: (field, value) => this.store.updateFormField(field, value),
|
||||
adminEmail: this.config.adminEmail,
|
||||
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
|
||||
});
|
||||
this.commentForm.render();
|
||||
}
|
||||
@@ -326,6 +329,7 @@ export class CWDComments {
|
||||
form: state.form,
|
||||
formErrors: state.formErrors,
|
||||
submitting: state.submitting,
|
||||
adminEmail: this.config.adminEmail
|
||||
});
|
||||
}
|
||||
|
||||
@@ -415,6 +419,7 @@ export class CWDComments {
|
||||
|
||||
if (shouldReload) {
|
||||
const api = createApiClient(this.config);
|
||||
this.api = api;
|
||||
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
|
||||
@@ -64,17 +64,42 @@
|
||||
url: data.url || undefined,
|
||||
content: data.content,
|
||||
parent_id: data.parentId,
|
||||
adminToken: data.adminToken
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`提交评论失败: ${response.status} ${response.statusText}`);
|
||||
// Try to parse error message
|
||||
let msg = response.statusText;
|
||||
try {
|
||||
const json = await response.json();
|
||||
if (json.message) msg = json.message;
|
||||
} catch (e) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function verifyAdminKey(key) {
|
||||
const response = await fetch(`${baseUrl}/api/verify-admin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ adminToken: key })
|
||||
});
|
||||
if (!response.ok) {
|
||||
let msg = response.statusText;
|
||||
try {
|
||||
const json = await response.json();
|
||||
if (json.message) msg = json.message;
|
||||
} catch (e) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return {
|
||||
fetchComments,
|
||||
submitComment,
|
||||
verifyAdminKey
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* 状态管理 - 使用发布-订阅模式
|
||||
*/
|
||||
|
||||
import { auth } from '../utils/auth.js';
|
||||
|
||||
// localStorage 键名
|
||||
const STORAGE_KEY = 'cwd_user_info';
|
||||
|
||||
@@ -192,6 +194,7 @@ export function createCommentStore(config, fetchComments, submitComment) {
|
||||
email: form.email,
|
||||
url: form.url,
|
||||
content: form.content,
|
||||
adminToken: auth.getToken() // Add token if exists
|
||||
});
|
||||
|
||||
// 清空评论内容
|
||||
@@ -248,6 +251,7 @@ export function createCommentStore(config, fetchComments, submitComment) {
|
||||
url: state.form.url,
|
||||
content: state.replyContent,
|
||||
parentId,
|
||||
adminToken: auth.getToken()
|
||||
});
|
||||
|
||||
// 清空回复内容并关闭回复框
|
||||
|
||||
@@ -709,3 +709,99 @@
|
||||
.cwd-comments-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--cwd-text-secondary);
|
||||
}
|
||||
|
||||
/* ========== Modal ========== */
|
||||
.cwd-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.cwd-modal {
|
||||
background: var(--cwd-bg, #ffffff);
|
||||
border-radius: var(--cwd-radius, 6px);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--cwd-border, #d0d7de);
|
||||
overflow: hidden;
|
||||
animation: cwd-modal-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cwd-modal-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.cwd-modal-title {
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--cwd-border, #d0d7de);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--cwd-text, #24292f);
|
||||
}
|
||||
|
||||
.cwd-modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cwd-modal-desc {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--cwd-text-secondary, #6e7781);
|
||||
}
|
||||
|
||||
.cwd-modal-actions {
|
||||
padding: 16px 20px;
|
||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||
border-top: 1px solid var(--cwd-border, #d0d7de);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ========== Admin Controls ========== */
|
||||
.cwd-form-field-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cwd-admin-controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cwd-admin-verified {
|
||||
color: #1a7f37;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cwd-btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--cwd-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cwd-btn-text:hover {
|
||||
color: var(--cwd-primary);
|
||||
}
|
||||
|
||||
76
widget/src/utils/auth.js
Normal file
76
widget/src/utils/auth.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const STORAGE_KEY = 'cwd_admin_auth';
|
||||
const EXPIRY_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
// Simple obfuscation (not real encryption, but sufficient for local storage requirement if no sensitive data other than the key itself which is already shared)
|
||||
// Requirement says "Local storage key credential needs to be encrypted".
|
||||
// We can use a simple XOR with a fixed salt or just Base64.
|
||||
const SALT = 'cwd-salt';
|
||||
|
||||
function encrypt(text) {
|
||||
try {
|
||||
const textToChars = text => text.split('').map(c => c.charCodeAt(0));
|
||||
const byteHex = n => ("0" + Number(n).toString(16)).substr(-2);
|
||||
const applySaltToChar = code => textToChars(SALT).reduce((a, b) => a ^ b, code);
|
||||
|
||||
return text
|
||||
.split('')
|
||||
.map(textToChars)
|
||||
.map(applySaltToChar)
|
||||
.map(byteHex)
|
||||
.join('');
|
||||
} catch (e) {
|
||||
return btoa(text); // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(encoded) {
|
||||
try {
|
||||
const textToChars = text => text.split('').map(c => c.charCodeAt(0));
|
||||
const applySaltToChar = code => textToChars(SALT).reduce((a, b) => a ^ b, code);
|
||||
|
||||
return encoded
|
||||
.match(/.{1,2}/g)
|
||||
.map(hex => parseInt(hex, 16))
|
||||
.map(applySaltToChar)
|
||||
.map(charCode => String.fromCharCode(charCode))
|
||||
.join('');
|
||||
} catch (e) {
|
||||
return atob(encoded); // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
saveToken(token) {
|
||||
const data = {
|
||||
adminToken: encrypt(token),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
},
|
||||
|
||||
getToken() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
if (Date.now() - data.timestamp > EXPIRY_MS) {
|
||||
this.clearToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
return decrypt(data.adminToken);
|
||||
} catch (e) {
|
||||
this.clearToken();
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearToken() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
},
|
||||
|
||||
hasToken() {
|
||||
return !!this.getToken();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user