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;
adminKeySet?: boolean;
requireReview?: boolean;
blockedIps?: string[];
};
export type EmailNotifySettingsResponse = {
@@ -137,10 +138,15 @@ export function saveCommentSettings(data: {
allowedDomains?: string[];
adminKey?: string;
requireReview?: boolean;
blockedIps?: string[];
}): Promise<{ message: string }> {
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[]> {
return get<any[]>('/admin/comments/export');
}

View File

@@ -38,6 +38,9 @@
<div class="cell-author-name">{{ item.name }}</div>
<div class="cell-author-email">{{ item.email }}</div>
<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>
@@ -176,6 +179,7 @@ import {
fetchComments,
deleteComment,
updateCommentStatus,
blockIp,
} from "../api/admin";
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(() => {
loadComments();
});
@@ -621,4 +640,12 @@ onMounted(() => {
border: 1px solid #d0d7de;
font-size: 12px;
}
.cell-ip-text {
cursor: pointer;
}
.cell-ip-text:hover {
text-decoration: underline;
}
</style>

View File

@@ -48,6 +48,15 @@
placeholder="例如: example.com, test.com"
></textarea>
</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">
<label class="form-label">管理员评论密钥</label>
<div class="form-hint" style="margin-bottom: 4px;">
@@ -305,6 +314,7 @@ const commentAdminBadge = ref("");
const avatarPrefix = ref("");
const commentAdminEnabled = ref(false);
const allowedDomains = ref("");
const blockedIps = ref("");
const commentAdminKey = ref("");
const adminKeySet = ref(false);
const requireReview = ref(false);
@@ -365,6 +375,9 @@ async function load() {
allowedDomains.value = commentRes.allowedDomains
? commentRes.allowedDomains.join(", ")
: "";
blockedIps.value = commentRes.blockedIps
? commentRes.blockedIps.join(", ")
: "";
commentAdminKey.value = commentRes.adminKey || "";
adminKeySet.value = !!commentRes.adminKeySet;
requireReview.value = !!commentRes.requireReview;
@@ -498,6 +511,10 @@ async function saveComment() {
.filter(Boolean),
adminKey: commentAdminKey.value || undefined,
requireReview: requireReview.value,
blockedIps: blockedIps.value
.split(/[,\n]/)
.map((d) => d.trim())
.filter(Boolean),
});
showToast(res.message || "保存成功", "success");

View File

@@ -52,6 +52,17 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
.first<string>('value');
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;
if (adminEmail && email === adminEmail) {
@@ -63,7 +74,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
const lockKey = `admin_lock:${ip}`;
const isLocked = await c.env.CWD_AUTH_KV.get(lockKey);
if (isLocked) {
return c.json({ message: "验证失败次数过多请30分钟后再试" }, 403);
return c.json({ message: "验证失败次数过多,请 30 分钟后再试" }, 403);
}
if (!adminToken) {
@@ -79,7 +90,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
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);
return c.json({ message: "验证失败次数过多,请 30 分钟后再试" }, 403);
} else {
await c.env.CWD_AUTH_KV.put(failKey, fails.toString(), { expirationTtl: 3600 });
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_ADMIN_KEY_HASH_KEY = 'comment_admin_key_hash';
const COMMENT_REQUIRE_REVIEW_KEY = 'comment_require_review';
const COMMENT_BLOCKED_IPS_KEY = 'comment_blocked_ips';
async function loadCommentSettings(env: Bindings) {
@@ -45,10 +46,11 @@ async function loadCommentSettings(env: Bindings) {
COMMENT_ADMIN_ENABLED_KEY,
COMMENT_ALLOWED_DOMAINS_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(
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?, ?, ?)'
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(...keys)
.all<{ key: string; value: string }>();
@@ -66,6 +68,11 @@ async function loadCommentSettings(env: Bindings) {
const requireReviewRaw = map.get(COMMENT_REQUIRE_REVIEW_KEY) ?? null;
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 allowedDomains = allowedDomainsRaw
@@ -79,6 +86,7 @@ async function loadCommentSettings(env: Bindings) {
adminEnabled,
allowedDomains,
requireReview,
blockedIps,
adminKey: map.get(COMMENT_ADMIN_KEY_HASH_KEY) ?? null,
adminKeySet: !!map.get(COMMENT_ADMIN_KEY_HASH_KEY)
};
@@ -94,6 +102,7 @@ async function saveCommentSettings(
allowedDomains?: string[];
adminKey?: string;
requireReview?: boolean;
blockedIps?: string[];
}
) {
await env.CWD_DB.prepare(
@@ -134,6 +143,10 @@ async function saveCommentSettings(
? '1'
: '0'
: 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 {
adminKey,
adminKeySet,
blockedIps,
...publicSettings
} = settings as any;
@@ -257,6 +271,7 @@ app.put('/admin/settings/comments', async (c) => {
const rawAllowedDomains = Array.isArray(body.allowedDomains) ? body.allowedDomains : [];
const rawAdminKey = typeof body.adminKey === 'string' ? body.adminKey : undefined;
const rawRequireReview = body.requireReview;
const rawBlockedIps = Array.isArray(body.blockedIps) ? body.blockedIps : [];
const adminEmail = rawAdminEmail.trim();
const adminBadge = rawAdminBadge.trim();
@@ -273,6 +288,9 @@ app.put('/admin/settings/comments', async (c) => {
typeof rawRequireReview === 'boolean'
? rawRequireReview
: rawRequireReview === '1' || rawRequireReview === 1;
const blockedIps = rawBlockedIps
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
.filter(Boolean);
if (adminEmail && !isValidEmail(adminEmail)) {
return c.json({ message: '邮箱格式不正确' }, 400);
@@ -285,7 +303,8 @@ app.put('/admin/settings/comments', async (c) => {
adminEnabled,
allowedDomains,
adminKey,
requireReview
requireReview,
blockedIps
});
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;