feat(comments): 添加 IP 黑名单功能
- 在后台管理界面添加 IP 黑名单设置项 - 支持在评论列表中直接屏蔽 IP - 实现 API 层 IP 校验逻辑,阻止黑名单 IP 发表评论 - 新增 IP 黑名单相关数据库字段和接口
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user