feat(comments): 添加 IP 黑名单功能

- 在后台管理界面添加 IP 黑名单设置项
- 支持在评论列表中直接屏蔽 IP
- 实现 API 层 IP 校验逻辑,阻止黑名单 IP 发表评论
- 新增 IP 黑名单相关数据库字段和接口
This commit is contained in:
anghunk
2026-01-21 09:55:10 +08:00
parent bf28ce2e2a
commit 49962db38d
5 changed files with 124 additions and 5 deletions

View File

@@ -43,6 +43,7 @@ export type CommentSettingsResponse = {
adminKey?: string | null; adminKey?: string | null;
adminKeySet?: boolean; adminKeySet?: boolean;
requireReview?: boolean; requireReview?: boolean;
blockedIps?: string[];
}; };
export type EmailNotifySettingsResponse = { export type EmailNotifySettingsResponse = {
@@ -137,10 +138,15 @@ export function saveCommentSettings(data: {
allowedDomains?: string[]; allowedDomains?: string[];
adminKey?: string; adminKey?: string;
requireReview?: boolean; requireReview?: boolean;
blockedIps?: string[];
}): Promise<{ message: string }> { }): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/comments', data); return put<{ message: string }>('/admin/settings/comments', data);
} }
export function blockIp(ip: string): Promise<{ message: string }> {
return post<{ message: string }>('/admin/comments/block-ip', { ip });
}
export function exportComments(): Promise<any[]> { export function exportComments(): Promise<any[]> {
return get<any[]>('/admin/comments/export'); return get<any[]>('/admin/comments/export');
} }

View File

@@ -38,6 +38,9 @@
<div class="cell-author-name">{{ item.name }}</div> <div class="cell-author-name">{{ item.name }}</div>
<div class="cell-author-email">{{ item.email }}</div> <div class="cell-author-email">{{ item.email }}</div>
<span class="cell-time">{{ formatDate(item.created) }}</span> <span class="cell-time">{{ formatDate(item.created) }}</span>
<div v-if="item.ipAddress" class="cell-author-ip">
<span class="cell-ip-text" @click="handleBlockIp(item)" title="屏蔽该 IP">{{ item.ipAddress }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -176,6 +179,7 @@ import {
fetchComments, fetchComments,
deleteComment, deleteComment,
updateCommentStatus, updateCommentStatus,
blockIp,
} from "../api/admin"; } from "../api/admin";
const comments = ref<CommentItem[]>([]); const comments = ref<CommentItem[]>([]);
@@ -292,6 +296,21 @@ async function removeComment(item: CommentItem) {
} }
} }
async function handleBlockIp(item: CommentItem) {
if (!item.ipAddress) {
return;
}
if (!window.confirm(`确认将 IP ${item.ipAddress} 加入黑名单吗?`)) {
return;
}
try {
const res = await blockIp(item.ipAddress);
window.alert(res.message || "已加入 IP 黑名单");
} catch (e: any) {
error.value = e.message || "屏蔽 IP 失败";
}
}
onMounted(() => { onMounted(() => {
loadComments(); loadComments();
}); });
@@ -621,4 +640,12 @@ onMounted(() => {
border: 1px solid #d0d7de; border: 1px solid #d0d7de;
font-size: 12px; font-size: 12px;
} }
.cell-ip-text {
cursor: pointer;
}
.cell-ip-text:hover {
text-decoration: underline;
}
</style> </style>

View File

@@ -48,6 +48,15 @@
placeholder="例如: example.com, test.com" placeholder="例如: example.com, test.com"
></textarea> ></textarea>
</div> </div>
<div class="form-item">
<label class="form-label">IP 黑名单多个 IP 用逗号或换行分隔留空则不限制</label>
<textarea
v-model="blockedIps"
class="form-input"
rows="3"
placeholder="例如: 1.1.1.1, 2.2.2.2"
></textarea>
</div>
<div class="form-item"> <div class="form-item">
<label class="form-label">管理员评论密钥</label> <label class="form-label">管理员评论密钥</label>
<div class="form-hint" style="margin-bottom: 4px;"> <div class="form-hint" style="margin-bottom: 4px;">
@@ -305,6 +314,7 @@ const commentAdminBadge = ref("");
const avatarPrefix = ref(""); const avatarPrefix = ref("");
const commentAdminEnabled = ref(false); const commentAdminEnabled = ref(false);
const allowedDomains = ref(""); const allowedDomains = ref("");
const blockedIps = ref("");
const commentAdminKey = ref(""); const commentAdminKey = ref("");
const adminKeySet = ref(false); const adminKeySet = ref(false);
const requireReview = ref(false); const requireReview = ref(false);
@@ -365,6 +375,9 @@ async function load() {
allowedDomains.value = commentRes.allowedDomains allowedDomains.value = commentRes.allowedDomains
? commentRes.allowedDomains.join(", ") ? commentRes.allowedDomains.join(", ")
: ""; : "";
blockedIps.value = commentRes.blockedIps
? commentRes.blockedIps.join(", ")
: "";
commentAdminKey.value = commentRes.adminKey || ""; commentAdminKey.value = commentRes.adminKey || "";
adminKeySet.value = !!commentRes.adminKeySet; adminKeySet.value = !!commentRes.adminKeySet;
requireReview.value = !!commentRes.requireReview; requireReview.value = !!commentRes.requireReview;
@@ -498,6 +511,10 @@ async function saveComment() {
.filter(Boolean), .filter(Boolean),
adminKey: commentAdminKey.value || undefined, adminKey: commentAdminKey.value || undefined,
requireReview: requireReview.value, requireReview: requireReview.value,
blockedIps: blockedIps.value
.split(/[,\n]/)
.map((d) => d.trim())
.filter(Boolean),
}); });
showToast(res.message || "保存成功", "success"); showToast(res.message || "保存成功", "success");

View File

@@ -52,6 +52,17 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
.first<string>('value'); .first<string>('value');
const requireReview = requireReviewRaw === '1'; const requireReview = requireReviewRaw === '1';
const blockedIpsRow = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind('comment_blocked_ips')
.first<{ value: string }>();
const blockedIpsValue = blockedIpsRow?.value || '';
const blockedIps = blockedIpsValue
? blockedIpsValue.split(',').map((d) => d.trim()).filter(Boolean)
: [];
if (blockedIps.length && blockedIps.includes(ip)) {
return c.json({ message: '当前 IP 已被限制评论,请联系站长进行处理' }, 403);
}
let isAdminComment = false; let isAdminComment = false;
if (adminEmail && email === adminEmail) { if (adminEmail && email === adminEmail) {
@@ -63,7 +74,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
const lockKey = `admin_lock:${ip}`; const lockKey = `admin_lock:${ip}`;
const isLocked = await c.env.CWD_AUTH_KV.get(lockKey); const isLocked = await c.env.CWD_AUTH_KV.get(lockKey);
if (isLocked) { if (isLocked) {
return c.json({ message: "验证失败次数过多请30分钟后再试" }, 403); return c.json({ message: "验证失败次数过多,请 30 分钟后再试" }, 403);
} }
if (!adminToken) { if (!adminToken) {
@@ -79,7 +90,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
if (fails >= 3) { if (fails >= 3) {
await c.env.CWD_AUTH_KV.put(lockKey, '1', { expirationTtl: 1800 }); await c.env.CWD_AUTH_KV.put(lockKey, '1', { expirationTtl: 1800 });
await c.env.CWD_AUTH_KV.delete(failKey); await c.env.CWD_AUTH_KV.delete(failKey);
return c.json({ message: "验证失败次数过多请30分钟后再试" }, 403); return c.json({ message: "验证失败次数过多,请 30 分钟后再试" }, 403);
} else { } else {
await c.env.CWD_AUTH_KV.put(failKey, fails.toString(), { expirationTtl: 3600 }); await c.env.CWD_AUTH_KV.put(failKey, fails.toString(), { expirationTtl: 3600 });
return c.json({ message: "密钥错误" }, 401); return c.json({ message: "密钥错误" }, 401);

View File

@@ -32,6 +32,7 @@ const COMMENT_ADMIN_ENABLED_KEY = 'comment_admin_enabled';
const COMMENT_ALLOWED_DOMAINS_KEY = 'comment_allowed_domains'; const COMMENT_ALLOWED_DOMAINS_KEY = 'comment_allowed_domains';
const COMMENT_ADMIN_KEY_HASH_KEY = 'comment_admin_key_hash'; const COMMENT_ADMIN_KEY_HASH_KEY = 'comment_admin_key_hash';
const COMMENT_REQUIRE_REVIEW_KEY = 'comment_require_review'; const COMMENT_REQUIRE_REVIEW_KEY = 'comment_require_review';
const COMMENT_BLOCKED_IPS_KEY = 'comment_blocked_ips';
async function loadCommentSettings(env: Bindings) { async function loadCommentSettings(env: Bindings) {
@@ -45,10 +46,11 @@ async function loadCommentSettings(env: Bindings) {
COMMENT_ADMIN_ENABLED_KEY, COMMENT_ADMIN_ENABLED_KEY,
COMMENT_ALLOWED_DOMAINS_KEY, COMMENT_ALLOWED_DOMAINS_KEY,
COMMENT_ADMIN_KEY_HASH_KEY, COMMENT_ADMIN_KEY_HASH_KEY,
COMMENT_REQUIRE_REVIEW_KEY COMMENT_REQUIRE_REVIEW_KEY,
COMMENT_BLOCKED_IPS_KEY
]; ];
const { results } = await env.CWD_DB.prepare( 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) .bind(...keys)
.all<{ key: string; value: string }>(); .all<{ key: string; value: string }>();
@@ -66,6 +68,11 @@ async function loadCommentSettings(env: Bindings) {
const requireReviewRaw = map.get(COMMENT_REQUIRE_REVIEW_KEY) ?? null; const requireReviewRaw = map.get(COMMENT_REQUIRE_REVIEW_KEY) ?? null;
const requireReview = requireReviewRaw === '1'; const requireReview = requireReviewRaw === '1';
const blockedIpsRaw = map.get(COMMENT_BLOCKED_IPS_KEY) ?? '';
const blockedIps = blockedIpsRaw
? blockedIpsRaw.split(',').map((d) => d.trim()).filter(Boolean)
: [];
// 解析允许的域名列表 // 解析允许的域名列表
const allowedDomainsRaw = map.get(COMMENT_ALLOWED_DOMAINS_KEY) ?? ''; const allowedDomainsRaw = map.get(COMMENT_ALLOWED_DOMAINS_KEY) ?? '';
const allowedDomains = allowedDomainsRaw const allowedDomains = allowedDomainsRaw
@@ -79,6 +86,7 @@ async function loadCommentSettings(env: Bindings) {
adminEnabled, adminEnabled,
allowedDomains, allowedDomains,
requireReview, requireReview,
blockedIps,
adminKey: map.get(COMMENT_ADMIN_KEY_HASH_KEY) ?? null, adminKey: map.get(COMMENT_ADMIN_KEY_HASH_KEY) ?? null,
adminKeySet: !!map.get(COMMENT_ADMIN_KEY_HASH_KEY) adminKeySet: !!map.get(COMMENT_ADMIN_KEY_HASH_KEY)
}; };
@@ -94,6 +102,7 @@ async function saveCommentSettings(
allowedDomains?: string[]; allowedDomains?: string[];
adminKey?: string; adminKey?: string;
requireReview?: boolean; requireReview?: boolean;
blockedIps?: string[];
} }
) { ) {
await env.CWD_DB.prepare( await env.CWD_DB.prepare(
@@ -134,6 +143,10 @@ async function saveCommentSettings(
? '1' ? '1'
: '0' : '0'
: undefined : undefined
},
{
key: COMMENT_BLOCKED_IPS_KEY,
value: settings.blockedIps ? settings.blockedIps.join(',') : undefined
} }
]; ];
@@ -192,6 +205,7 @@ app.get('/api/config/comments', async (c) => {
const { const {
adminKey, adminKey,
adminKeySet, adminKeySet,
blockedIps,
...publicSettings ...publicSettings
} = settings as any; } = settings as any;
@@ -257,6 +271,7 @@ app.put('/admin/settings/comments', async (c) => {
const rawAllowedDomains = Array.isArray(body.allowedDomains) ? body.allowedDomains : []; const rawAllowedDomains = Array.isArray(body.allowedDomains) ? body.allowedDomains : [];
const rawAdminKey = typeof body.adminKey === 'string' ? body.adminKey : undefined; const rawAdminKey = typeof body.adminKey === 'string' ? body.adminKey : undefined;
const rawRequireReview = body.requireReview; const rawRequireReview = body.requireReview;
const rawBlockedIps = Array.isArray(body.blockedIps) ? body.blockedIps : [];
const adminEmail = rawAdminEmail.trim(); const adminEmail = rawAdminEmail.trim();
const adminBadge = rawAdminBadge.trim(); const adminBadge = rawAdminBadge.trim();
@@ -273,6 +288,9 @@ app.put('/admin/settings/comments', async (c) => {
typeof rawRequireReview === 'boolean' typeof rawRequireReview === 'boolean'
? rawRequireReview ? rawRequireReview
: rawRequireReview === '1' || rawRequireReview === 1; : rawRequireReview === '1' || rawRequireReview === 1;
const blockedIps = rawBlockedIps
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
.filter(Boolean);
if (adminEmail && !isValidEmail(adminEmail)) { if (adminEmail && !isValidEmail(adminEmail)) {
return c.json({ message: '邮箱格式不正确' }, 400); return c.json({ message: '邮箱格式不正确' }, 400);
@@ -285,7 +303,8 @@ app.put('/admin/settings/comments', async (c) => {
adminEnabled, adminEnabled,
allowedDomains, allowedDomains,
adminKey, adminKey,
requireReview requireReview,
blockedIps
}); });
return c.json({ message: '保存成功' }); return c.json({ message: '保存成功' });
@@ -294,4 +313,43 @@ app.put('/admin/settings/comments', async (c) => {
} }
}); });
app.post('/admin/comments/block-ip', async (c) => {
try {
const body = await c.req.json();
const rawIp = typeof body.ip === 'string' ? body.ip : '';
const ip = rawIp.trim();
if (!ip) {
return c.json({ message: 'IP 地址不能为空' }, 400);
}
await c.env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const row = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind(COMMENT_BLOCKED_IPS_KEY)
.first<{ value: string }>();
const existing = row?.value || '';
const list = existing
? existing.split(',').map((d) => d.trim()).filter(Boolean)
: [];
if (!list.includes(ip)) {
list.push(ip);
const joined = list.join(',');
await c.env.CWD_DB.prepare(
'REPLACE INTO Settings (key, value) VALUES (?, ?)'
)
.bind(COMMENT_BLOCKED_IPS_KEY, joined)
.run();
}
return c.json({ message: '已加入 IP 黑名单' });
} catch (e: any) {
return c.json({ message: e.message || '操作失败' }, 500);
}
});
export default app; export default app;