feat(admin): 添加域名白名单功能并支持Artalk导入
- 在评论设置中添加域名白名单配置,限制组件调用 - 支持从Artalk系统导入评论数据 - 完善Twikoo和Artalk数据结构的映射处理 - 在组件端实现域名白名单检查逻辑
This commit is contained in:
@@ -39,6 +39,7 @@ export type CommentSettingsResponse = {
|
|||||||
adminBadge: string | null;
|
adminBadge: string | null;
|
||||||
avatarPrefix: string | null;
|
avatarPrefix: string | null;
|
||||||
adminEnabled: boolean;
|
adminEnabled: boolean;
|
||||||
|
allowedDomains?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmailNotifySettingsResponse = {
|
export type EmailNotifySettingsResponse = {
|
||||||
@@ -122,6 +123,7 @@ export function saveCommentSettings(data: {
|
|||||||
adminBadge?: string;
|
adminBadge?: string;
|
||||||
avatarPrefix?: string;
|
avatarPrefix?: string;
|
||||||
adminEnabled?: boolean;
|
adminEnabled?: boolean;
|
||||||
|
allowedDomains?: string[];
|
||||||
}): Promise<{ message: string }> {
|
}): Promise<{ message: string }> {
|
||||||
return put<{ message: string }>('/admin/settings/comments', data);
|
return put<{ message: string }>('/admin/settings/comments', data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">来源系统</label>
|
<label class="form-label">来源系统</label>
|
||||||
<select v-model="importSource" class="form-select">
|
<select v-model="importSource" class="form-select">
|
||||||
<option value="twikoo">Twikoo</option>
|
<option value="twikoo">Twikoo (.json)</option>
|
||||||
|
<option value="artalk">Artalk (.json)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,8 +189,8 @@ async function handleFileChange(event: Event) {
|
|||||||
// 检测前缀逻辑
|
// 检测前缀逻辑
|
||||||
let missingCount = 0;
|
let missingCount = 0;
|
||||||
for (const item of comments) {
|
for (const item of comments) {
|
||||||
// 优先检查 Twikoo 字段 href,其次检查 CWD 字段 post_slug
|
// 优先检查 Twikoo 字段 href,其次 Artalk 字段 page_key,最后 CWD 字段 post_slug
|
||||||
const url = item.href || item.post_slug;
|
const url = item.href || item.page_key || item.post_slug;
|
||||||
if (url && typeof url === "string") {
|
if (url && typeof url === "string") {
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
missingCount++;
|
missingCount++;
|
||||||
@@ -250,6 +251,16 @@ async function confirmPrefix() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Artalk
|
||||||
|
if (newItem.page_key && typeof newItem.page_key === "string") {
|
||||||
|
if (
|
||||||
|
!newItem.page_key.startsWith("http://") &&
|
||||||
|
!newItem.page_key.startsWith("https://")
|
||||||
|
) {
|
||||||
|
newItem.page_key = joinUrl(prefix, newItem.page_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CWD
|
// CWD
|
||||||
if (newItem.post_slug && typeof newItem.post_slug === "string") {
|
if (newItem.post_slug && typeof newItem.post_slug === "string") {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -31,6 +31,15 @@
|
|||||||
<label class="form-label">头像前缀(默认:https://gravatar.com/avatar)</label>
|
<label class="form-label">头像前缀(默认:https://gravatar.com/avatar)</label>
|
||||||
<input v-model="avatarPrefix" class="form-input" type="text" />
|
<input v-model="avatarPrefix" class="form-input" type="text" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<label class="form-label">允许调用的域名(多个域名用逗号分隔,留空则不限制)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="allowedDomains"
|
||||||
|
class="form-input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="例如: example.com, test.com"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="card-button" :disabled="savingComment" @click="saveComment">
|
<button class="card-button" :disabled="savingComment" @click="saveComment">
|
||||||
<span v-if="savingComment">保存中...</span>
|
<span v-if="savingComment">保存中...</span>
|
||||||
@@ -134,6 +143,7 @@ const commentAdminEmail = ref("");
|
|||||||
const commentAdminBadge = ref("");
|
const commentAdminBadge = ref("");
|
||||||
const avatarPrefix = ref("");
|
const avatarPrefix = ref("");
|
||||||
const commentAdminEnabled = ref(false);
|
const commentAdminEnabled = ref(false);
|
||||||
|
const allowedDomains = ref("");
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
const testingEmail = ref(false);
|
const testingEmail = ref(false);
|
||||||
const savingComment = ref(false);
|
const savingComment = ref(false);
|
||||||
@@ -181,6 +191,7 @@ async function load() {
|
|||||||
commentAdminBadge.value = commentRes.adminBadge || "博主";
|
commentAdminBadge.value = commentRes.adminBadge || "博主";
|
||||||
avatarPrefix.value = commentRes.avatarPrefix || "";
|
avatarPrefix.value = commentRes.avatarPrefix || "";
|
||||||
commentAdminEnabled.value = !!commentRes.adminEnabled;
|
commentAdminEnabled.value = !!commentRes.adminEnabled;
|
||||||
|
allowedDomains.value = commentRes.allowedDomains ? commentRes.allowedDomains.join(", ") : "";
|
||||||
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
|
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
|
||||||
|
|
||||||
if (emailNotifyRes.smtp) {
|
if (emailNotifyRes.smtp) {
|
||||||
@@ -287,6 +298,7 @@ async function saveComment() {
|
|||||||
adminBadge: commentAdminBadge.value,
|
adminBadge: commentAdminBadge.value,
|
||||||
avatarPrefix: avatarPrefix.value,
|
avatarPrefix: avatarPrefix.value,
|
||||||
adminEnabled: commentAdminEnabled.value,
|
adminEnabled: commentAdminEnabled.value,
|
||||||
|
allowedDomains: allowedDomains.value.split(/[,,\n]/).map(d => d.trim()).filter(Boolean)
|
||||||
});
|
});
|
||||||
showToast(res.message || "保存成功", "success");
|
showToast(res.message || "保存成功", "success");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -10,14 +10,51 @@ export const importComments = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
return c.json({ message: '导入数据为空' }, 400);
|
return c.json({ message: '导入数据为空' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 映射 Twikoo 数据结构到 CWD 结构
|
// 映射 Twikoo / Artalk 数据结构到 CWD 结构
|
||||||
const comments = rawComments.map((item: any) => {
|
const comments = rawComments.map((item: any) => {
|
||||||
// 简单的特征检测:如果有 href/nick/mail/comment 且没有 post_slug/name (或者我们优先尝试映射)
|
// Twikoo 特征检测
|
||||||
// 这里的判断标准:如果包含 Twikoo 特有的字段名,则进行映射
|
const isTwikoo =
|
||||||
const isTwikoo = item.href !== undefined || item.nick !== undefined || item.comment !== undefined;
|
item.href !== undefined || item.nick !== undefined || item.comment !== undefined;
|
||||||
|
// Artalk 特征检测 (page_key 是 Artalk 特有的)
|
||||||
if (isTwikoo) {
|
const isArtalk = item.page_key !== undefined && item.content !== undefined;
|
||||||
// 处理 ID: 如果 _id 是数字则保留,否则丢弃(让数据库自增)
|
|
||||||
|
if (isArtalk) {
|
||||||
|
// Artalk 映射逻辑
|
||||||
|
// 处理 ID: Artalk ID 通常是数字
|
||||||
|
let id = undefined;
|
||||||
|
if (typeof item.id === 'number') {
|
||||||
|
id = item.id;
|
||||||
|
} else if (typeof item.id === 'string' && /^\d+$/.test(item.id)) {
|
||||||
|
id = parseInt(item.id, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理时间
|
||||||
|
let created = Date.now();
|
||||||
|
if (item.created_at) {
|
||||||
|
created = new Date(item.created_at).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, // >>> id
|
||||||
|
created, // >>> created_at 转为时间戳
|
||||||
|
post_slug: item.page_key || '', // >>> page_key
|
||||||
|
name: item.nick || 'Anonymous', // >>> nick
|
||||||
|
email: item.email || '', // >>> email
|
||||||
|
url: item.link || null, // >>> link
|
||||||
|
ip_address: item.ip || null, // >>> ip
|
||||||
|
device: null, // >>> 保持空
|
||||||
|
os: null, // >>> 保持空
|
||||||
|
browser: null, // >>> 保持空
|
||||||
|
ua: item.ua || null, // >>> ua
|
||||||
|
content_text: item.content || '', // >>> content
|
||||||
|
content_html: item.content || '', // >>> content
|
||||||
|
parent_id: null, // >>> 保持 null
|
||||||
|
status: 'approved' // >>> 保持 "approved"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTwikoo) {
|
||||||
|
// 处理 ID: 如果 _id 是数字则保留,否则丢弃(让数据库自增)
|
||||||
// Twikoo 的 _id 通常是 ObjectId 字符串,无法直接存入 INTEGER PRIMARY KEY
|
// Twikoo 的 _id 通常是 ObjectId 字符串,无法直接存入 INTEGER PRIMARY KEY
|
||||||
// 除非 _id 恰好是数字
|
// 除非 _id 恰好是数字
|
||||||
let id = undefined;
|
let id = undefined;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const COMMENT_ADMIN_EMAIL_KEY = 'comment_admin_email';
|
|||||||
const COMMENT_ADMIN_BADGE_KEY = 'comment_admin_badge';
|
const COMMENT_ADMIN_BADGE_KEY = 'comment_admin_badge';
|
||||||
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
|
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
|
||||||
const COMMENT_ADMIN_ENABLED_KEY = 'comment_admin_enabled';
|
const COMMENT_ADMIN_ENABLED_KEY = 'comment_admin_enabled';
|
||||||
|
const COMMENT_ALLOWED_DOMAINS_KEY = 'comment_allowed_domains';
|
||||||
|
|
||||||
async function loadCommentSettings(env: Bindings) {
|
async function loadCommentSettings(env: Bindings) {
|
||||||
await env.CWD_DB.prepare(
|
await env.CWD_DB.prepare(
|
||||||
@@ -36,10 +37,11 @@ async function loadCommentSettings(env: Bindings) {
|
|||||||
COMMENT_ADMIN_EMAIL_KEY,
|
COMMENT_ADMIN_EMAIL_KEY,
|
||||||
COMMENT_ADMIN_BADGE_KEY,
|
COMMENT_ADMIN_BADGE_KEY,
|
||||||
COMMENT_AVATAR_PREFIX_KEY,
|
COMMENT_AVATAR_PREFIX_KEY,
|
||||||
COMMENT_ADMIN_ENABLED_KEY
|
COMMENT_ADMIN_ENABLED_KEY,
|
||||||
|
COMMENT_ALLOWED_DOMAINS_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 }>();
|
||||||
@@ -54,11 +56,18 @@ async function loadCommentSettings(env: Bindings) {
|
|||||||
const enabledRaw = map.get(COMMENT_ADMIN_ENABLED_KEY) ?? null;
|
const enabledRaw = map.get(COMMENT_ADMIN_ENABLED_KEY) ?? null;
|
||||||
const adminEnabled = enabledRaw === '1';
|
const adminEnabled = enabledRaw === '1';
|
||||||
|
|
||||||
|
// 解析允许的域名列表
|
||||||
|
const allowedDomainsRaw = map.get(COMMENT_ALLOWED_DOMAINS_KEY) ?? '';
|
||||||
|
const allowedDomains = allowedDomainsRaw
|
||||||
|
? allowedDomainsRaw.split(',').map((d) => d.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adminEmail: map.get(COMMENT_ADMIN_EMAIL_KEY) ?? null,
|
adminEmail: map.get(COMMENT_ADMIN_EMAIL_KEY) ?? null,
|
||||||
adminBadge: map.get(COMMENT_ADMIN_BADGE_KEY) ?? null,
|
adminBadge: map.get(COMMENT_ADMIN_BADGE_KEY) ?? null,
|
||||||
avatarPrefix: map.get(COMMENT_AVATAR_PREFIX_KEY) ?? null,
|
avatarPrefix: map.get(COMMENT_AVATAR_PREFIX_KEY) ?? null,
|
||||||
adminEnabled
|
adminEnabled,
|
||||||
|
allowedDomains
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +78,7 @@ async function saveCommentSettings(
|
|||||||
adminBadge?: string;
|
adminBadge?: string;
|
||||||
avatarPrefix?: string;
|
avatarPrefix?: string;
|
||||||
adminEnabled?: boolean;
|
adminEnabled?: boolean;
|
||||||
|
allowedDomains?: string[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await env.CWD_DB.prepare(
|
await env.CWD_DB.prepare(
|
||||||
@@ -87,6 +97,10 @@ async function saveCommentSettings(
|
|||||||
? '1'
|
? '1'
|
||||||
: '0'
|
: '0'
|
||||||
: undefined
|
: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: COMMENT_ALLOWED_DOMAINS_KEY,
|
||||||
|
value: settings.allowedDomains ? settings.allowedDomains.join(',') : undefined
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -198,6 +212,7 @@ app.put('/admin/settings/comments', async (c) => {
|
|||||||
const rawAdminBadge = typeof body.adminBadge === 'string' ? body.adminBadge : '';
|
const rawAdminBadge = typeof body.adminBadge === 'string' ? body.adminBadge : '';
|
||||||
const rawAvatarPrefix = typeof body.avatarPrefix === 'string' ? body.avatarPrefix : '';
|
const rawAvatarPrefix = typeof body.avatarPrefix === 'string' ? body.avatarPrefix : '';
|
||||||
const rawAdminEnabled = body.adminEnabled;
|
const rawAdminEnabled = body.adminEnabled;
|
||||||
|
const rawAllowedDomains = Array.isArray(body.allowedDomains) ? body.allowedDomains : [];
|
||||||
|
|
||||||
const adminEmail = rawAdminEmail.trim();
|
const adminEmail = rawAdminEmail.trim();
|
||||||
const adminBadge = rawAdminBadge.trim();
|
const adminBadge = rawAdminBadge.trim();
|
||||||
@@ -206,6 +221,9 @@ app.put('/admin/settings/comments', async (c) => {
|
|||||||
typeof rawAdminEnabled === 'boolean'
|
typeof rawAdminEnabled === 'boolean'
|
||||||
? rawAdminEnabled
|
? rawAdminEnabled
|
||||||
: rawAdminEnabled === '1' || rawAdminEnabled === 1;
|
: rawAdminEnabled === '1' || rawAdminEnabled === 1;
|
||||||
|
const allowedDomains = rawAllowedDomains
|
||||||
|
.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);
|
||||||
@@ -215,7 +233,8 @@ app.put('/admin/settings/comments', async (c) => {
|
|||||||
adminEmail,
|
adminEmail,
|
||||||
adminBadge,
|
adminBadge,
|
||||||
avatarPrefix,
|
avatarPrefix,
|
||||||
adminEnabled
|
adminEnabled,
|
||||||
|
allowedDomains
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ message: '保存成功' });
|
return c.json({ message: '保存成功' });
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export class CWDComments {
|
|||||||
adminBadge: data.adminBadge || '',
|
adminBadge: data.adminBadge || '',
|
||||||
adminEnabled: !!data.adminEnabled,
|
adminEnabled: !!data.adminEnabled,
|
||||||
avatarPrefix: data.avatarPrefix || '',
|
avatarPrefix: data.avatarPrefix || '',
|
||||||
|
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : [],
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {};
|
return {};
|
||||||
@@ -125,6 +126,28 @@ export class CWDComments {
|
|||||||
if (!this._mounted) {
|
if (!this._mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查域名限制
|
||||||
|
if (
|
||||||
|
serverConfig.allowedDomains &&
|
||||||
|
serverConfig.allowedDomains.length > 0 &&
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
) {
|
||||||
|
const currentHostname = window.location.hostname;
|
||||||
|
const isAllowed = serverConfig.allowedDomains.some((domain) => {
|
||||||
|
return currentHostname === domain || currentHostname.endsWith('.' + domain);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
this.mountPoint.innerHTML = `
|
||||||
|
<div style="padding: 20px; text-align: center; color: #666; font-size: 14px; border: 1px solid #eee; border-radius: 8px; background: #f9f9f9;">
|
||||||
|
当前域名 (${currentHostname}) 未获得评论组件调用授权
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (serverConfig.avatarPrefix) {
|
if (serverConfig.avatarPrefix) {
|
||||||
this.config.avatarPrefix = serverConfig.avatarPrefix;
|
this.config.avatarPrefix = serverConfig.avatarPrefix;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user