chore: 重构项目结构
This commit is contained in:
23
cwd-api/src/api/admin/deleteComment.ts
Normal file
23
cwd-api/src/api/admin/deleteComment.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
export const deleteComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
const id = c.req.query('id');
|
||||
|
||||
if (!id) {
|
||||
return c.json({ message: "Missing id" }, 400);
|
||||
}
|
||||
|
||||
// 从数据库中直接删除评论
|
||||
const { success } = await c.env.CWD_DB.prepare(
|
||||
"DELETE FROM Comment WHERE id = ?"
|
||||
).bind(id).run();
|
||||
|
||||
if (!success) {
|
||||
return c.json({ message: "Delete operation failed" }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: `Comment deleted, id: ${id}.`
|
||||
});
|
||||
};
|
||||
14
cwd-api/src/api/admin/exportComments.ts
Normal file
14
cwd-api/src/api/admin/exportComments.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
export const exportComments = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const { results } = await c.env.CWD_DB.prepare(
|
||||
'SELECT * FROM Comment ORDER BY created DESC'
|
||||
).all();
|
||||
|
||||
return c.json(results);
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '导出失败' }, 500);
|
||||
}
|
||||
};
|
||||
18
cwd-api/src/api/admin/getAdminEmail.ts
Normal file
18
cwd-api/src/api/admin/getAdminEmail.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
export const getAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
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('admin_notify_email')
|
||||
.first<{ value: string }>();
|
||||
const email = row?.value || null;
|
||||
return c.json({ email });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
170
cwd-api/src/api/admin/importComments.ts
Normal file
170
cwd-api/src/api/admin/importComments.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
export const importComments = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const rawComments = Array.isArray(body) ? body : [body];
|
||||
|
||||
if (rawComments.length === 0) {
|
||||
return c.json({ message: '导入数据为空' }, 400);
|
||||
}
|
||||
|
||||
// 映射 Twikoo / Artalk 数据结构到 CWD 结构
|
||||
const comments = rawComments.map((item: any) => {
|
||||
// Twikoo 特征检测
|
||||
const isTwikoo =
|
||||
item.href !== undefined || item.nick !== undefined || item.comment !== undefined;
|
||||
// Artalk 特征检测 (page_key 是 Artalk 特有的)
|
||||
const isArtalk = item.page_key !== undefined && item.content !== undefined;
|
||||
|
||||
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
|
||||
// 除非 _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) {
|
||||
// 支持时间戳或 ISO 字符串
|
||||
created = new Date(item.created).getTime();
|
||||
}
|
||||
|
||||
return {
|
||||
id, // 可能为 undefined
|
||||
created, // >>> created
|
||||
post_slug: item.href || "", // >>> href
|
||||
name: item.nick || "Anonymous", // >>> nick
|
||||
email: item.mail || "", // >>> mail
|
||||
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.comment || "", // >>> comment
|
||||
content_html: item.comment || "", // >>> comment
|
||||
parent_id: null, // >>> 保持 null
|
||||
status: "approved" // >>> approved
|
||||
};
|
||||
}
|
||||
|
||||
// 否则假设已经是 CWD 格式
|
||||
return item;
|
||||
});
|
||||
|
||||
// 按 ID 升序排序,防止因外键约束导致插入失败(子评论先于父评论插入)
|
||||
// 对于 Twikoo 导入,id 可能不存在,或者被重置。
|
||||
// 如果 parent_id 全部为 null,则排序其实不重要(没有依赖)。
|
||||
comments.sort((a: any, b: any) => {
|
||||
const idA = a.id || 0;
|
||||
const idB = b.id || 0;
|
||||
return idA - idB;
|
||||
});
|
||||
|
||||
const stmts = comments.map((comment: any) => {
|
||||
const {
|
||||
id,
|
||||
created,
|
||||
post_slug,
|
||||
name,
|
||||
email,
|
||||
url,
|
||||
ip_address,
|
||||
device,
|
||||
os,
|
||||
browser,
|
||||
ua,
|
||||
content_text,
|
||||
content_html,
|
||||
parent_id,
|
||||
status
|
||||
} = comment;
|
||||
|
||||
const fields = [
|
||||
'created', 'post_slug', 'name', 'email', 'url',
|
||||
'ip_address', 'device', 'os', 'browser', 'ua',
|
||||
'content_text', 'content_html', 'parent_id', 'status'
|
||||
];
|
||||
const values = [
|
||||
created || Date.now(),
|
||||
post_slug || "",
|
||||
name || "Anonymous",
|
||||
email || "",
|
||||
url || null,
|
||||
ip_address || null,
|
||||
device || null,
|
||||
os || null,
|
||||
browser || null,
|
||||
ua || null,
|
||||
content_text || "",
|
||||
content_html || "",
|
||||
parent_id || null,
|
||||
status || "approved"
|
||||
];
|
||||
|
||||
if (id !== undefined && id !== null) {
|
||||
fields.unshift('id');
|
||||
values.unshift(id);
|
||||
}
|
||||
|
||||
const placeholders = fields.map(() => '?').join(', ');
|
||||
const sql = `INSERT OR REPLACE INTO Comment (${fields.join(', ')}) VALUES (${placeholders})`;
|
||||
|
||||
return c.env.CWD_DB.prepare(sql).bind(...values);
|
||||
});
|
||||
|
||||
// 批量执行,每批 50 条
|
||||
const BATCH_SIZE = 50;
|
||||
for (let i = 0; i < stmts.length; i += BATCH_SIZE) {
|
||||
const batch = stmts.slice(i, i + BATCH_SIZE);
|
||||
await c.env.CWD_DB.batch(batch);
|
||||
}
|
||||
|
||||
return c.json({ message: `成功导入 ${comments.length} 条评论` });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
return c.json({ message: e.message || '导入失败' }, 500);
|
||||
}
|
||||
};
|
||||
55
cwd-api/src/api/admin/listComments.ts
Normal file
55
cwd-api/src/api/admin/listComments.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
import { getCravatar } from '../../utils/getAvatar';
|
||||
|
||||
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
|
||||
|
||||
export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const limit = 10;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const totalCount = await c.env.CWD_DB.prepare(
|
||||
'SELECT COUNT(*) as count FROM Comment'
|
||||
).first<{ count: number }>();
|
||||
|
||||
const { results } = await c.env.CWD_DB.prepare(
|
||||
'SELECT * FROM Comment ORDER BY created DESC LIMIT ? OFFSET ?'
|
||||
)
|
||||
.bind(limit, offset)
|
||||
.all();
|
||||
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
const avatarRow = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
|
||||
.bind(COMMENT_AVATAR_PREFIX_KEY)
|
||||
.first<{ value: string }>();
|
||||
const avatarPrefix = avatarRow?.value || null;
|
||||
|
||||
const data = await Promise.all(
|
||||
results.map(async (row: any) => ({
|
||||
id: row.id,
|
||||
created: row.created,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
postSlug: row.post_slug,
|
||||
url: row.url,
|
||||
ipAddress: row.ip_address,
|
||||
contentText: row.content_text,
|
||||
contentHtml: row.content_html,
|
||||
status: row.status,
|
||||
ua: row.ua,
|
||||
avatar: await getCravatar(row.email, avatarPrefix || undefined)
|
||||
}))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: Math.ceil(((totalCount?.count as number) || 0) / limit)
|
||||
}
|
||||
});
|
||||
};
|
||||
62
cwd-api/src/api/admin/login.ts
Normal file
62
cwd-api/src/api/admin/login.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
// 简单配置:允许尝试 5 次,锁定 30 分钟
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 30 * 60; // 秒
|
||||
|
||||
export const adminLogin = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
const data = await c.req.json();
|
||||
const ip = c.req.header('cf-connecting-ip') || '127.0.0.1';
|
||||
|
||||
const blockKey = `block:${ip}`;
|
||||
const attemptKey = `attempts:${ip}`;
|
||||
|
||||
// 1. 检查 IP 是否被封禁
|
||||
const isBlocked = await c.env.CWD_AUTH_KV.get(blockKey);
|
||||
if (isBlocked) {
|
||||
return c.json({ message: 'IP is blocked due to multiple failed login attempts' }, 403);
|
||||
}
|
||||
|
||||
// 2. 验证用户名密码
|
||||
const ADMIN_NAME = c.env.ADMIN_NAME || 'Admin';
|
||||
const ADMIN_PASSWORD = c.env.ADMIN_PASSWORD || 'password';
|
||||
const isValid = data.name === ADMIN_NAME && data.password === ADMIN_PASSWORD;
|
||||
|
||||
if (!isValid) {
|
||||
// --- 登录失败逻辑 ---
|
||||
// 获取当前失败次数
|
||||
const attempts = parseInt((await c.env.CWD_AUTH_KV.get(attemptKey)) || '0') + 1;
|
||||
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
// 达到上限,封禁 30 分钟
|
||||
await c.env.CWD_AUTH_KV.put(blockKey, '1', { expirationTtl: LOCK_TIME });
|
||||
await c.env.CWD_AUTH_KV.delete(attemptKey); // 清除尝试计数
|
||||
return c.json({ message: 'IP is blocked due to multiple failed login attempts' }, 403);
|
||||
} else {
|
||||
// 记录失败次数,设置 10 分钟内连续失败才计数
|
||||
await c.env.CWD_AUTH_KV.put(attemptKey, attempts.toString(), { expirationTtl: 600 });
|
||||
return c.json({ message: 'Invalid username or password', failedAttempts: attempts }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. 登录成功逻辑 ---
|
||||
await c.env.CWD_AUTH_KV.delete(attemptKey);
|
||||
|
||||
// 生成 Token (你的 tempKey)
|
||||
const tempKey = crypto.randomUUID();
|
||||
|
||||
// 将 Token 存入 KV,有效期 24 小时(86400秒)
|
||||
await c.env.CWD_AUTH_KV.put(
|
||||
`token:${tempKey}`,
|
||||
JSON.stringify({
|
||||
user: data.name,
|
||||
ip: ip,
|
||||
}),
|
||||
{ expirationTtl: 86400 }
|
||||
);
|
||||
|
||||
return c.json({
|
||||
data: { key: tempKey },
|
||||
});
|
||||
};
|
||||
22
cwd-api/src/api/admin/setAdminEmail.ts
Normal file
22
cwd-api/src/api/admin/setAdminEmail.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
import { isValidEmail } from '../../utils/email';
|
||||
|
||||
export const setAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const { email } = await c.req.json();
|
||||
if (!email || !isValidEmail(email)) {
|
||||
return c.json({ message: '邮箱格式不正确' }, 400);
|
||||
}
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
await c.env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
|
||||
.bind('admin_notify_email', email)
|
||||
.run();
|
||||
return c.json({ message: '保存成功' });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
31
cwd-api/src/api/admin/testEmail.ts
Normal file
31
cwd-api/src/api/admin/testEmail.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
import { sendTestEmail, EmailNotificationSettings, isValidEmail } from '../../utils/email';
|
||||
|
||||
export const testEmail = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const toEmail = body.toEmail;
|
||||
|
||||
if (!toEmail || !isValidEmail(toEmail)) {
|
||||
return c.json({ message: '请输入有效的接收邮箱' }, 400);
|
||||
}
|
||||
|
||||
const smtp: EmailNotificationSettings['smtp'] = body.smtp;
|
||||
|
||||
if (!smtp || !smtp.user || !smtp.pass) {
|
||||
return c.json({ message: 'SMTP 配置不完整' }, 400);
|
||||
}
|
||||
|
||||
const result = await sendTestEmail(c.env, toEmail, smtp);
|
||||
|
||||
if (result.success) {
|
||||
return c.json({ message: '邮件发送成功' });
|
||||
} else {
|
||||
return c.json({ message: '邮件发送失败: ' + result.message }, 500);
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '测试失败' }, 500);
|
||||
}
|
||||
};
|
||||
23
cwd-api/src/api/admin/updateStatus.ts
Normal file
23
cwd-api/src/api/admin/updateStatus.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
export const updateStatus = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
const id = c.req.query('id');
|
||||
const status = c.req.query('status'); // 按照你规范中 URL 参数的形式
|
||||
|
||||
if (!id || !status) {
|
||||
return c.json({ message: "Missing id or status" }, 400);
|
||||
}
|
||||
|
||||
const { success } = await c.env.CWD_DB.prepare(
|
||||
"UPDATE Comment SET status = ? WHERE id = ?"
|
||||
).bind(status, id).run();
|
||||
|
||||
if (!success) {
|
||||
return c.json({ message: "Update failed" }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: `Comment status updated, id: ${id}, status: ${status}.`
|
||||
});
|
||||
};
|
||||
108
cwd-api/src/api/public/getComments.ts
Normal file
108
cwd-api/src/api/public/getComments.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Context } from 'hono'
|
||||
import { Bindings } from '../../bindings'
|
||||
import { getCravatar } from '../../utils/getAvatar'
|
||||
|
||||
export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
const post_slug = c.req.query('post_slug')
|
||||
const page = parseInt(c.req.query('page') || '1')
|
||||
const limit = Math.min(parseInt(c.req.query('limit') || '20'), 50)
|
||||
const nested = c.req.query('nested') !== 'false'
|
||||
const avatar_prefix = c.req.query('avatar_prefix')
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
if (!post_slug) return c.json({ message: "post_slug is required" }, 400)
|
||||
|
||||
try {
|
||||
// 1. 查询审核通过的评论
|
||||
const query = `
|
||||
SELECT id, name, email, url, content_text as contentText,
|
||||
content_html as contentHtml, created, parent_id as parentId,
|
||||
post_slug as postSlug
|
||||
FROM Comment
|
||||
WHERE post_slug = ? AND status = "approved"
|
||||
ORDER BY created DESC
|
||||
`
|
||||
const { results } = await c.env.CWD_DB.prepare(query).bind(post_slug).all()
|
||||
|
||||
// 2. 批量处理头像并格式化
|
||||
const allComments = await Promise.all(results.map(async (row: any) => ({
|
||||
...row,
|
||||
avatar: await getCravatar(row.email, avatar_prefix || undefined),
|
||||
replies: []
|
||||
})))
|
||||
|
||||
// 3. 处理嵌套逻辑(扁平化:2级往后的回复都放在根评论的 replies 中)
|
||||
if (nested) {
|
||||
const commentMap = new Map()
|
||||
const rootComments: any[] = []
|
||||
|
||||
// 建立评论映射
|
||||
allComments.forEach(comment => commentMap.set(comment.id, comment))
|
||||
|
||||
// 找出所有根评论
|
||||
allComments.forEach(comment => {
|
||||
if (!comment.parentId) {
|
||||
rootComments.push(comment)
|
||||
}
|
||||
})
|
||||
|
||||
// 为每个非根评论找到其根评论,并添加 replyToAuthor 字段
|
||||
allComments.forEach(comment => {
|
||||
if (comment.parentId) {
|
||||
// 获取直接父评论的作者名
|
||||
const parentComment = commentMap.get(comment.parentId)
|
||||
if (parentComment) {
|
||||
comment.replyToAuthor = parentComment.name
|
||||
}
|
||||
|
||||
// 向上查找根评论
|
||||
let rootId = comment.parentId
|
||||
let current = commentMap.get(rootId)
|
||||
while (current && current.parentId) {
|
||||
rootId = current.parentId
|
||||
current = commentMap.get(rootId)
|
||||
}
|
||||
|
||||
// 将回复添加到根评论的 replies 中
|
||||
const rootComment = commentMap.get(rootId)
|
||||
if (rootComment && !rootComment.parentId) {
|
||||
rootComment.replies.push(comment)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 对每个根评论的 replies 按时间正序排列
|
||||
rootComments.forEach(root => {
|
||||
root.replies.sort((a: any, b: any) =>
|
||||
a.created - b.created
|
||||
)
|
||||
})
|
||||
|
||||
// 对根评论进行分页
|
||||
const paginatedData = rootComments.slice(offset, offset + limit)
|
||||
return c.json({
|
||||
data: paginatedData,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: Math.ceil(rootComments.length / limit),
|
||||
totalCount: allComments.length,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 非嵌套逻辑直接分页
|
||||
const paginatedData = allComments.slice(offset, offset + limit)
|
||||
return c.json({
|
||||
data: paginatedData,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: Math.ceil(allComments.length / limit),
|
||||
totalCount: allComments.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message }, 500)
|
||||
}
|
||||
}
|
||||
272
cwd-api/src/api/public/postComment.ts
Normal file
272
cwd-api/src/api/public/postComment.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Context } from 'hono';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { marked } from 'marked';
|
||||
import xss from 'xss';
|
||||
import { Bindings } from '../../bindings';
|
||||
import {
|
||||
sendCommentNotification,
|
||||
sendCommentReplyNotification,
|
||||
isValidEmail,
|
||||
getAdminNotifyEmail,
|
||||
loadEmailNotificationSettings,
|
||||
EmailNotificationSettings
|
||||
} from '../../utils/email';
|
||||
|
||||
// 检查内容,将<script>标签之间的内容删除
|
||||
export function checkContent(content: string): string {
|
||||
return content.replace(/<script[\s\S]*?<\/script>/g, "");
|
||||
}
|
||||
|
||||
export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
const data = await c.req.json();
|
||||
if (!data || typeof data !== 'object') {
|
||||
return c.json({ message: '无效的请求体' }, 400);
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (!rawContent || typeof rawContent !== 'string') {
|
||||
return c.json({ message: '评论内容不能为空' }, 400);
|
||||
}
|
||||
if (!rawName || typeof rawName !== 'string') {
|
||||
return c.json({ message: '昵称不能为空' }, 400);
|
||||
}
|
||||
if (!email || typeof email !== 'string') {
|
||||
return c.json({ message: '邮箱不能为空' }, 400);
|
||||
}
|
||||
if (!isValidEmail(email)) {
|
||||
return c.json({ message: '邮箱格式不正确' }, 400);
|
||||
}
|
||||
const ua = c.req.header('user-agent') || "";
|
||||
|
||||
const ip = c.req.header('cf-connecting-ip') || "127.0.0.1";
|
||||
|
||||
const adminEmail = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
|
||||
.bind('comment_admin_email')
|
||||
.first<string>('value');
|
||||
|
||||
const requireReviewRaw = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
|
||||
.bind('comment_require_review')
|
||||
.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);
|
||||
}
|
||||
|
||||
const blockedEmailsRow = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
|
||||
.bind('comment_blocked_emails')
|
||||
.first<{ value: string }>();
|
||||
const blockedEmailsValue = blockedEmailsRow?.value || '';
|
||||
const blockedEmails = blockedEmailsValue
|
||||
? blockedEmailsValue.split(',').map((d) => d.trim()).filter(Boolean)
|
||||
: [];
|
||||
if (blockedEmails.length && blockedEmails.includes(email)) {
|
||||
return c.json({ message: '当前邮箱已被限制评论,请联系站长进行处理' }, 403);
|
||||
}
|
||||
|
||||
let isAdminComment = false;
|
||||
|
||||
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}`);
|
||||
isAdminComment = true;
|
||||
}
|
||||
}
|
||||
// 2. 检查评论频率控制 (对应 canPostComment)
|
||||
// 这里建议使用 D1 查最近一条评论的时间,或者直接放行(如果使用了 Cloudflare WAF)
|
||||
const lastComment = await c.env.CWD_DB.prepare(
|
||||
'SELECT created FROM Comment WHERE ip_address = ? ORDER BY created DESC LIMIT 1'
|
||||
).bind(ip).first<{ created: number }>();
|
||||
|
||||
if (lastComment) {
|
||||
const lastTime = lastComment.created;
|
||||
if (Date.now() - lastTime < 10 * 1000) {
|
||||
return c.json({ message: "评论频繁,等10s后再试" }, 429);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 准备数据
|
||||
const cleanedContent = checkContent(rawContent);
|
||||
const contentText = cleanedContent;
|
||||
const name = checkContent(rawName);
|
||||
|
||||
// Markdown 渲染与 XSS 过滤
|
||||
const html = await marked.parse(cleanedContent, { async: true });
|
||||
const contentHtml = xss(html, {
|
||||
whiteList: {
|
||||
...xss.whiteList,
|
||||
code: ['class'],
|
||||
span: ['class', 'style'],
|
||||
pre: ['class'],
|
||||
div: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'style']
|
||||
}
|
||||
});
|
||||
|
||||
console.log('PostComment:request', {
|
||||
postSlug: post_slug,
|
||||
hasParent: parentId !== null && parentId !== undefined,
|
||||
name,
|
||||
email,
|
||||
ip
|
||||
});
|
||||
const uaParser = new UAParser(ua);
|
||||
const uaResult = uaParser.getResult();
|
||||
|
||||
const defaultStatus = requireReview && !isAdminComment ? "pending" : "approved";
|
||||
|
||||
try {
|
||||
const { success } = await c.env.CWD_DB.prepare(`
|
||||
INSERT INTO Comment (
|
||||
created, post_slug, name, email, url, ip_address,
|
||||
os, browser, device, ua, content_text, content_html,
|
||||
parent_id, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).bind(
|
||||
Date.now(),
|
||||
post_slug,
|
||||
name,
|
||||
email,
|
||||
url || null,
|
||||
ip,
|
||||
`${uaResult.os.name || ""} ${uaResult.os.version || ""}`.trim(),
|
||||
`${uaResult.browser.name || ""} ${uaResult.browser.version || ""}`.trim(),
|
||||
uaResult.device.model || uaResult.device.type || "Desktop",
|
||||
ua,
|
||||
contentText,
|
||||
contentHtml,
|
||||
parentId || null,
|
||||
defaultStatus
|
||||
).run();
|
||||
|
||||
if (!success) throw new Error("Database insert failed");
|
||||
|
||||
console.log('PostComment:inserted', {
|
||||
postSlug: post_slug,
|
||||
hasParent: parentId !== null && parentId !== undefined,
|
||||
ip
|
||||
});
|
||||
|
||||
let notifySettings: EmailNotificationSettings = {
|
||||
globalEnabled: true
|
||||
};
|
||||
try {
|
||||
notifySettings = await loadEmailNotificationSettings(c.env);
|
||||
} catch (e) {
|
||||
console.error('PostComment:mailDispatch:loadEmailSettingsFailed', e);
|
||||
}
|
||||
|
||||
if (!notifySettings.globalEnabled) {
|
||||
console.log('PostComment:mailDispatch:disabledByGlobalConfig');
|
||||
} else {
|
||||
console.log('PostComment:mailDispatch:start', {
|
||||
hasParent: parentId !== null && parentId !== undefined
|
||||
});
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
try {
|
||||
if (parentId !== null && parentId !== undefined) {
|
||||
let adminEmail: string | null = null;
|
||||
try {
|
||||
adminEmail = await getAdminNotifyEmail(c.env);
|
||||
} catch (e) {
|
||||
console.error('PostComment:mailDispatch:userReply:getAdminEmailFailed', e);
|
||||
}
|
||||
const isAdminReply = !!adminEmail && email === adminEmail;
|
||||
|
||||
const parentComment = await c.env.CWD_DB.prepare(
|
||||
"SELECT name, email, content_html FROM Comment WHERE id = ?"
|
||||
).bind(parentId).first<{ name: string, email: string, content_html: string }>();
|
||||
|
||||
if (parentComment && parentComment.email && parentComment.email !== email) {
|
||||
if (isValidEmail(parentComment.email)) {
|
||||
console.log('PostComment:mailDispatch:userReply:send', {
|
||||
toEmail: parentComment.email,
|
||||
toName: parentComment.name
|
||||
});
|
||||
await sendCommentReplyNotification(c.env, {
|
||||
toEmail: parentComment.email,
|
||||
toName: parentComment.name,
|
||||
postTitle: data.post_title,
|
||||
parentComment: parentComment.content_html,
|
||||
replyAuthor: name,
|
||||
replyContent: contentHtml,
|
||||
postUrl: data.post_url,
|
||||
}, notifySettings.smtp, notifySettings.templates?.reply);
|
||||
console.log('PostComment:mailDispatch:userReply:sent', {
|
||||
toEmail: parentComment.email
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('PostComment:mailDispatch:admin:send');
|
||||
await sendCommentNotification(c.env, {
|
||||
postTitle: data.post_title,
|
||||
postUrl: data.post_url,
|
||||
commentAuthor: name,
|
||||
commentContent: contentHtml
|
||||
}, notifySettings.smtp, notifySettings.templates?.admin);
|
||||
console.log('PostComment:mailDispatch:admin:sent');
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error("Mail Notification Failed:", mailError);
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
if (defaultStatus === "pending") {
|
||||
return c.json({
|
||||
message: '已提交评论,待管理员审核后显示',
|
||||
status: defaultStatus
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: '评论已提交',
|
||||
status: defaultStatus
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Create Comment Error:", e);
|
||||
return c.json({ message: "Internal Server Error" }, 500);
|
||||
}
|
||||
};
|
||||
49
cwd-api/src/api/public/verifyAdminKey.ts
Normal file
49
cwd-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: "验证通过" });
|
||||
};
|
||||
9
cwd-api/src/bindings.ts
Normal file
9
cwd-api/src/bindings.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type Bindings = {
|
||||
CWD_DB: D1Database
|
||||
CWD_AUTH_KV: KVNamespace;
|
||||
ALLOW_ORIGIN: string
|
||||
MAIL_GATEWAY_URL?: string
|
||||
MAIL_GATEWAY_TOKEN?: string
|
||||
ADMIN_NAME: string
|
||||
ADMIN_PASSWORD: string
|
||||
}
|
||||
417
cwd-api/src/index.ts
Normal file
417
cwd-api/src/index.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { Hono } from 'hono';
|
||||
import { Bindings } from './bindings';
|
||||
import { customCors } from './utils/cors';
|
||||
import { adminAuth } from './utils/auth';
|
||||
import {
|
||||
isValidEmail,
|
||||
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';
|
||||
import { exportComments } from './api/admin/exportComments';
|
||||
import { importComments } from './api/admin/importComments';
|
||||
import { updateStatus } from './api/admin/updateStatus';
|
||||
import { getAdminEmail } from './api/admin/getAdminEmail';
|
||||
import { setAdminEmail } from './api/admin/setAdminEmail';
|
||||
import { testEmail } from './api/admin/testEmail';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
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';
|
||||
const COMMENT_REQUIRE_REVIEW_KEY = 'comment_require_review';
|
||||
const COMMENT_BLOCKED_IPS_KEY = 'comment_blocked_ips';
|
||||
const COMMENT_BLOCKED_EMAILS_KEY = 'comment_blocked_emails';
|
||||
|
||||
|
||||
async function loadCommentSettings(env: Bindings) {
|
||||
await env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
const keys = [
|
||||
COMMENT_ADMIN_EMAIL_KEY,
|
||||
COMMENT_ADMIN_BADGE_KEY,
|
||||
COMMENT_AVATAR_PREFIX_KEY,
|
||||
COMMENT_ADMIN_ENABLED_KEY,
|
||||
COMMENT_ALLOWED_DOMAINS_KEY,
|
||||
COMMENT_ADMIN_KEY_HASH_KEY,
|
||||
COMMENT_REQUIRE_REVIEW_KEY,
|
||||
COMMENT_BLOCKED_IPS_KEY,
|
||||
COMMENT_BLOCKED_EMAILS_KEY
|
||||
];
|
||||
const { results } = await env.CWD_DB.prepare(
|
||||
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(...keys)
|
||||
.all<{ key: string; value: string }>();
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const row of results) {
|
||||
if (row && row.key) {
|
||||
map.set(row.key, row.value);
|
||||
}
|
||||
}
|
||||
|
||||
const enabledRaw = map.get(COMMENT_ADMIN_ENABLED_KEY) ?? null;
|
||||
const adminEnabled = enabledRaw === '1';
|
||||
|
||||
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 blockedEmailsRaw = map.get(COMMENT_BLOCKED_EMAILS_KEY) ?? '';
|
||||
const blockedEmails = blockedEmailsRaw
|
||||
? blockedEmailsRaw.split(',').map((d) => d.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
// 解析允许的域名列表
|
||||
const allowedDomainsRaw = map.get(COMMENT_ALLOWED_DOMAINS_KEY) ?? '';
|
||||
const allowedDomains = allowedDomainsRaw
|
||||
? allowedDomainsRaw.split(',').map((d) => d.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
adminEmail: map.get(COMMENT_ADMIN_EMAIL_KEY) ?? null,
|
||||
adminBadge: map.get(COMMENT_ADMIN_BADGE_KEY) ?? null,
|
||||
avatarPrefix: map.get(COMMENT_AVATAR_PREFIX_KEY) ?? null,
|
||||
adminEnabled,
|
||||
allowedDomains,
|
||||
requireReview,
|
||||
blockedIps,
|
||||
blockedEmails,
|
||||
adminKey: map.get(COMMENT_ADMIN_KEY_HASH_KEY) ?? null,
|
||||
adminKeySet: !!map.get(COMMENT_ADMIN_KEY_HASH_KEY)
|
||||
};
|
||||
}
|
||||
|
||||
async function saveCommentSettings(
|
||||
env: Bindings,
|
||||
settings: {
|
||||
adminEmail?: string;
|
||||
adminBadge?: string;
|
||||
avatarPrefix?: string;
|
||||
adminEnabled?: boolean;
|
||||
allowedDomains?: string[];
|
||||
adminKey?: string;
|
||||
requireReview?: boolean;
|
||||
blockedIps?: string[];
|
||||
blockedEmails?: 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 },
|
||||
{ key: COMMENT_AVATAR_PREFIX_KEY, value: settings.avatarPrefix },
|
||||
{
|
||||
key: COMMENT_ADMIN_ENABLED_KEY,
|
||||
value:
|
||||
typeof settings.adminEnabled === 'boolean'
|
||||
? settings.adminEnabled
|
||||
? '1'
|
||||
: '0'
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
key: COMMENT_ALLOWED_DOMAINS_KEY,
|
||||
value: settings.allowedDomains ? settings.allowedDomains.join(',') : undefined
|
||||
},
|
||||
{
|
||||
key: COMMENT_ADMIN_KEY_HASH_KEY,
|
||||
value: adminKeyValue
|
||||
},
|
||||
{
|
||||
key: COMMENT_REQUIRE_REVIEW_KEY,
|
||||
value:
|
||||
typeof settings.requireReview === 'boolean'
|
||||
? settings.requireReview
|
||||
? '1'
|
||||
: '0'
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
key: COMMENT_BLOCKED_IPS_KEY,
|
||||
value: settings.blockedIps ? settings.blockedIps.join(',') : undefined
|
||||
},
|
||||
{
|
||||
key: COMMENT_BLOCKED_EMAILS_KEY,
|
||||
value: settings.blockedEmails ? settings.blockedEmails.join(',') : undefined
|
||||
}
|
||||
];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.value !== undefined) {
|
||||
const value = entry.value === null ? '' : entry.value;
|
||||
const trimmed = typeof value === 'string' ? value.trim() : value;
|
||||
if (trimmed) {
|
||||
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
|
||||
.bind(entry.key, trimmed)
|
||||
.run();
|
||||
} else {
|
||||
await env.CWD_DB.prepare('DELETE FROM Settings WHERE key = ?').bind(entry.key).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
console.log('Request:start', {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
url: c.req.url,
|
||||
hasDb: !!c.env.CWD_DB,
|
||||
hasAuthKv: !!c.env.CWD_AUTH_KV
|
||||
});
|
||||
const res = await next();
|
||||
console.log('Request:end', {
|
||||
method: c.req.method,
|
||||
path: c.req.path
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const corsMiddleware = customCors();
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
app.use('/admin/*', async (c, next) => {
|
||||
const corsMiddleware = customCors();
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.html(
|
||||
`CWD 评论部署成功,当前版本 ${VERSION},<a href="https://github.com/anghunk/cwd" target="_blank" rel="noreferrer">查看文档</a>`
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
const {
|
||||
adminKey,
|
||||
adminKeySet,
|
||||
blockedIps,
|
||||
blockedEmails,
|
||||
...publicSettings
|
||||
} = settings as any;
|
||||
|
||||
return c.json(publicSettings);
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '加载评论配置失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/login', adminLogin);
|
||||
app.use('/admin/*', adminAuth);
|
||||
app.delete('/admin/comments/delete', deleteComment);
|
||||
app.get('/admin/comments/list', listComments);
|
||||
app.get('/admin/comments/export', exportComments);
|
||||
app.post('/admin/comments/import', importComments);
|
||||
app.put('/admin/comments/status', updateStatus);
|
||||
app.get('/admin/settings/email', getAdminEmail);
|
||||
app.put('/admin/settings/email', setAdminEmail);
|
||||
app.get('/admin/settings/email-notify', async (c) => {
|
||||
try {
|
||||
const settings = await loadEmailNotificationSettings(c.env);
|
||||
return c.json(settings);
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '加载邮件通知配置失败' }, 500);
|
||||
}
|
||||
});
|
||||
app.put('/admin/settings/email-notify', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const globalEnabled =
|
||||
typeof body.globalEnabled === 'boolean' ? body.globalEnabled : undefined;
|
||||
const smtp = body.smtp && typeof body.smtp === 'object' ? body.smtp : undefined;
|
||||
const templates = body.templates && typeof body.templates === 'object' ? body.templates : undefined;
|
||||
|
||||
await saveEmailNotificationSettings(c.env, {
|
||||
globalEnabled,
|
||||
smtp,
|
||||
templates
|
||||
});
|
||||
|
||||
return c.json({ message: '保存成功' });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '保存失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/settings/email-test', testEmail);
|
||||
app.get('/admin/settings/comments', async (c) => {
|
||||
try {
|
||||
const settings = await loadCommentSettings(c.env);
|
||||
return c.json(settings);
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '加载评论配置失败' }, 500);
|
||||
}
|
||||
});
|
||||
app.put('/admin/settings/comments', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const rawAdminEmail = typeof body.adminEmail === 'string' ? body.adminEmail : '';
|
||||
const rawAdminBadge = typeof body.adminBadge === 'string' ? body.adminBadge : '';
|
||||
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 rawRequireReview = body.requireReview;
|
||||
const rawBlockedIps = Array.isArray(body.blockedIps) ? body.blockedIps : [];
|
||||
const rawBlockedEmails = Array.isArray(body.blockedEmails) ? body.blockedEmails : [];
|
||||
|
||||
const adminEmail = rawAdminEmail.trim();
|
||||
const adminBadge = rawAdminBadge.trim();
|
||||
const avatarPrefix = rawAvatarPrefix.trim();
|
||||
const adminEnabled =
|
||||
typeof rawAdminEnabled === 'boolean'
|
||||
? rawAdminEnabled
|
||||
: rawAdminEnabled === '1' || rawAdminEnabled === 1;
|
||||
const allowedDomains = rawAllowedDomains
|
||||
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
|
||||
.filter(Boolean);
|
||||
const adminKey = rawAdminKey; // Can be undefined or empty string
|
||||
const requireReview =
|
||||
typeof rawRequireReview === 'boolean'
|
||||
? rawRequireReview
|
||||
: rawRequireReview === '1' || rawRequireReview === 1;
|
||||
const blockedIps = rawBlockedIps
|
||||
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
|
||||
.filter(Boolean);
|
||||
const blockedEmails = rawBlockedEmails
|
||||
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
if (adminEmail && !isValidEmail(adminEmail)) {
|
||||
return c.json({ message: '邮箱格式不正确' }, 400);
|
||||
}
|
||||
|
||||
await saveCommentSettings(c.env, {
|
||||
adminEmail,
|
||||
adminBadge,
|
||||
avatarPrefix,
|
||||
adminEnabled,
|
||||
allowedDomains,
|
||||
adminKey,
|
||||
requireReview,
|
||||
blockedIps,
|
||||
blockedEmails
|
||||
});
|
||||
|
||||
return c.json({ message: '保存成功' });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '保存失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/comments/block-email', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const rawEmail = typeof body.email === 'string' ? body.email : '';
|
||||
const email = rawEmail.trim();
|
||||
|
||||
if (!email) {
|
||||
return c.json({ message: '邮箱不能为空' }, 400);
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return c.json({ message: '邮箱格式不正确' }, 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_EMAILS_KEY)
|
||||
.first<{ value: string }>();
|
||||
|
||||
const existing = row?.value || '';
|
||||
const list = existing
|
||||
? existing.split(',').map((d) => d.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (!list.includes(email)) {
|
||||
list.push(email);
|
||||
const joined = list.join(',');
|
||||
await c.env.CWD_DB.prepare(
|
||||
'REPLACE INTO Settings (key, value) VALUES (?, ?)'
|
||||
)
|
||||
.bind(COMMENT_BLOCKED_EMAILS_KEY, joined)
|
||||
.run();
|
||||
}
|
||||
|
||||
return c.json({ message: '已加入邮箱黑名单' });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '操作失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
23
cwd-api/src/utils/auth.ts
Normal file
23
cwd-api/src/utils/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { Bindings } from '../bindings';
|
||||
|
||||
export const adminAuth = async (c: Context<{ Bindings: Bindings }>, next: Next) => {
|
||||
const token = c.req.header('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) return c.json({ message: "Unauthorized" }, 401);
|
||||
|
||||
const sessionData = await c.env.CWD_AUTH_KV.get(`token:${token}`);
|
||||
if (!sessionData) {
|
||||
return c.json({ message: "Token expired or invalid" }, 401);
|
||||
}
|
||||
|
||||
const session = JSON.parse(sessionData);
|
||||
const currentIp = c.req.header('cf-connecting-ip');
|
||||
|
||||
// 安全检查:如果 IP 发生变化(比如 Token 被盗),要求重新登录
|
||||
// if (session.ip !== currentIp) {
|
||||
// await c.env.CWD_AUTH_KV.delete(`token:${token}`);
|
||||
// return c.json({ message: "Security alert: IP changed" }, 401);
|
||||
// }
|
||||
|
||||
await next();
|
||||
};
|
||||
12
cwd-api/src/utils/cors.ts
Normal file
12
cwd-api/src/utils/cors.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cors } from 'hono/cors'
|
||||
|
||||
export const customCors = () => {
|
||||
return cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
exposeHeaders: ['Content-Length'],
|
||||
maxAge: 600,
|
||||
credentials: false,
|
||||
})
|
||||
}
|
||||
8
cwd-api/src/utils/crypto.ts
Normal file
8
cwd-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('');
|
||||
}
|
||||
420
cwd-api/src/utils/email.ts
Normal file
420
cwd-api/src/utils/email.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { Bindings } from '../bindings';
|
||||
import { createTransport } from 'nodemailer';
|
||||
|
||||
export function isValidEmail(email: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
const EMAIL_NOTIFY_GLOBAL_KEY = 'email_notify_enabled';
|
||||
const SMTP_HOST_KEY = 'email_smtp_host';
|
||||
const SMTP_PORT_KEY = 'email_smtp_port';
|
||||
const SMTP_USER_KEY = 'email_smtp_user';
|
||||
const SMTP_PASS_KEY = 'email_smtp_pass';
|
||||
const SMTP_SECURE_KEY = 'email_smtp_secure';
|
||||
const EMAIL_TEMPLATE_REPLY_KEY = 'email_template_reply';
|
||||
const EMAIL_TEMPLATE_ADMIN_KEY = 'email_template_admin';
|
||||
|
||||
type MailGatewayPayload = {
|
||||
to: string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type EmailNotificationSettings = {
|
||||
globalEnabled: boolean;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
secure: boolean;
|
||||
};
|
||||
templates?: {
|
||||
reply?: string;
|
||||
admin?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
||||
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
|
||||
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#2563eb,#4f46e5);">
|
||||
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">评论回复 - \${postTitle}</h1>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:#e5e7eb;">你在文章下的评论收到了新的回复</p>
|
||||
</div>
|
||||
<div style="padding:24px 28px;">
|
||||
<p style="margin:0 0 8px 0;font-size:14px;color:#374151;">Hi <span style="font-weight:600;">\${toName}</span>,</p>
|
||||
<p style="margin:0 0 16px 0;font-size:14px;color:#4b5563;">
|
||||
<span style="font-weight:600;">\${replyAuthor}</span> 回复了你在
|
||||
<span style="font-weight:600;">《\${postTitle}》</span>
|
||||
中的评论:
|
||||
</p>
|
||||
<div style="margin:0 0 18px 0;padding:14px 16px;border-radius:10px;background:#f3f4f6;border:1px solid #e5e7eb;">
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:6px;">你之前的评论</div>
|
||||
<div style="font-size:14px;color:#374151;">\${parentComment}</div>
|
||||
</div>
|
||||
<div style="margin:0 0 24px 0;padding:14px 16px;border-radius:10px;background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div style="font-size:12px;color:#1d4ed8;margin-bottom:6px;">最新回复</div>
|
||||
<div style="font-size:14px;color:#1f2937;">\${replyContent}</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:8px;">
|
||||
<a href="\${postUrl}" style="display:inline-block;padding:10px 22px;border-radius:999px;background:#2563eb;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;">
|
||||
打开文章查看完整对话
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;">
|
||||
如果按钮无法点击,可以将链接复制到浏览器中打开:<br />
|
||||
<span style="word-break:break-all;color:#6b7280;">\${postUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:14px 20px;border-top:1px solid #e5e7eb;background:#f9fafb;text-align:center;">
|
||||
<p style="margin:0;font-size:11px;line-height:1.6;color:#9ca3af;">
|
||||
此邮件由系统自动发送,请勿直接回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const DEFAULT_ADMIN_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
||||
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
|
||||
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#0f766e,#059669);">
|
||||
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">新评论提醒</h1>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:#d1fae5;">你的文章收到了新的评论</p>
|
||||
</div>
|
||||
<div style="padding:24px 28px;">
|
||||
<p style="margin:0 0 10px 0;font-size:14px;color:#374151;">
|
||||
<span style="font-weight:600;">\${commentAuthor}</span> 在文章
|
||||
<span style="font-weight:600;">《\${postTitle}》</span>
|
||||
下发表了新评论:
|
||||
</p>
|
||||
<div style="margin:0 0 18px 0;padding:14px 16px;border-radius:10px;background:#f9fafb;border:1px solid #e5e7eb;">
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:6px;">评论内容</div>
|
||||
<div style="font-size:14px;color:#374151;">\${commentContent}</div>
|
||||
</div>
|
||||
<div style="margin:0 0 8px 0;">
|
||||
<a href="\${postUrl}" style="display:inline-block;padding:10px 22px;border-radius:999px;background:#047857;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;">
|
||||
打开后台查看并管理评论
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
如果按钮无法点击,可以将链接复制到浏览器中打开:<br />
|
||||
<span style="word-break:break-all;color:#6b7280;">\${postUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:14px 20px;border-top:1px solid #e5e7eb;background:#f9fafb;text-align:center;">
|
||||
<p style="margin:0;font-size:11px;line-height:1.6;color:#9ca3af;">
|
||||
此邮件由系统自动发送,如非本人操作可忽略本邮件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function replaceTemplate(template: string, variables: Record<string, string>) {
|
||||
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] || '');
|
||||
}
|
||||
|
||||
async function dispatchMail(
|
||||
env: Bindings,
|
||||
payload: MailGatewayPayload,
|
||||
smtpSettings?: EmailNotificationSettings['smtp']
|
||||
) {
|
||||
// 1. Try SMTP
|
||||
if (smtpSettings && smtpSettings.user && smtpSettings.pass) {
|
||||
try {
|
||||
console.log('MailDispatch:SMTP:start', { host: smtpSettings.host, user: smtpSettings.user });
|
||||
const transporter = createTransport({
|
||||
host: smtpSettings.host || 'smtp.qq.com',
|
||||
port: smtpSettings.port || 465,
|
||||
secure: smtpSettings.secure ?? true,
|
||||
auth: {
|
||||
user: smtpSettings.user,
|
||||
pass: smtpSettings.pass,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"评论通知" <${smtpSettings.user}>`,
|
||||
to: payload.to.join(', '),
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
});
|
||||
|
||||
console.log('MailDispatch:SMTP:success', { to: payload.to });
|
||||
return;
|
||||
} catch (e: any) {
|
||||
console.error('MailDispatch:SMTP:error', {
|
||||
message: e?.message || String(e),
|
||||
});
|
||||
// Fallback to gateway?
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.MAIL_GATEWAY_URL) {
|
||||
if (!smtpSettings?.user) {
|
||||
console.error('MailGateway:missingUrlAndSmtp');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(env.MAIL_GATEWAY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(env.MAIL_GATEWAY_TOKEN ? { 'X-Auth-Token': env.MAIL_GATEWAY_TOKEN } : {})
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error('MailGateway:sendFailed', {
|
||||
status: res.status,
|
||||
statusText: res.statusText
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('MailGateway:error', {
|
||||
message: e?.message || String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTestEmail(
|
||||
env: Bindings,
|
||||
to: string,
|
||||
smtp: EmailNotificationSettings['smtp']
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
if (!smtp || !smtp.user || !smtp.pass) {
|
||||
return { success: false, message: 'SMTP 配置不完整' };
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: { user: smtp.user, pass: smtp.pass }
|
||||
});
|
||||
|
||||
// 尝试验证连接配置
|
||||
await transporter.verify();
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"Test" <${smtp.user}>`,
|
||||
to: to,
|
||||
subject: 'CWD Comments 邮件配置测试',
|
||||
html: `
|
||||
<div style="padding: 20px; font-family: sans-serif;">
|
||||
<h2 style="color: #059669;">配置成功!</h2>
|
||||
<p>这就是一封来自 CWD Comments 的测试邮件。</p>
|
||||
<p>如果您收到了这封邮件,说明您的 SMTP 配置是正确的。</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">发送时间:${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
console.error('TestEmail:error', e);
|
||||
return { success: false, message: e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnabled(raw: string | undefined, defaultValue: boolean) {
|
||||
if (raw === undefined) return defaultValue;
|
||||
return raw === '1';
|
||||
}
|
||||
|
||||
export async function loadEmailNotificationSettings(
|
||||
env: Bindings
|
||||
): Promise<EmailNotificationSettings> {
|
||||
await env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
const keys = [
|
||||
EMAIL_NOTIFY_GLOBAL_KEY,
|
||||
SMTP_HOST_KEY,
|
||||
SMTP_PORT_KEY,
|
||||
SMTP_USER_KEY,
|
||||
SMTP_PASS_KEY,
|
||||
SMTP_SECURE_KEY,
|
||||
EMAIL_TEMPLATE_REPLY_KEY,
|
||||
EMAIL_TEMPLATE_ADMIN_KEY
|
||||
];
|
||||
|
||||
const { results } = await env.CWD_DB.prepare(
|
||||
`SELECT key, value FROM Settings WHERE key IN (${keys.map(() => '?').join(',')})`
|
||||
)
|
||||
.bind(...keys)
|
||||
.all<{ key: string; value: string }>();
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const row of results) {
|
||||
if (row && row.key) {
|
||||
map.set(row.key, row.value);
|
||||
}
|
||||
}
|
||||
|
||||
const globalEnabled = parseEnabled(map.get(EMAIL_NOTIFY_GLOBAL_KEY), true);
|
||||
|
||||
const smtp: EmailNotificationSettings['smtp'] = {
|
||||
host: map.get(SMTP_HOST_KEY) || 'smtp.qq.com',
|
||||
port: parseInt(map.get(SMTP_PORT_KEY) || '465', 10),
|
||||
user: map.get(SMTP_USER_KEY) || '',
|
||||
pass: map.get(SMTP_PASS_KEY) || '',
|
||||
secure: map.get(SMTP_SECURE_KEY) !== '0'
|
||||
};
|
||||
|
||||
const templates = {
|
||||
reply: map.get(EMAIL_TEMPLATE_REPLY_KEY) || DEFAULT_REPLY_TEMPLATE,
|
||||
admin: map.get(EMAIL_TEMPLATE_ADMIN_KEY) || DEFAULT_ADMIN_TEMPLATE
|
||||
};
|
||||
|
||||
return {
|
||||
globalEnabled,
|
||||
smtp,
|
||||
templates
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveEmailNotificationSettings(
|
||||
env: Bindings,
|
||||
settings: {
|
||||
globalEnabled?: boolean;
|
||||
smtp?: Partial<EmailNotificationSettings['smtp']>;
|
||||
templates?: Partial<NonNullable<EmailNotificationSettings['templates']>>;
|
||||
}
|
||||
) {
|
||||
await env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
const entries: { key: string; value: string | undefined }[] = [];
|
||||
|
||||
if (settings.globalEnabled !== undefined) {
|
||||
entries.push({ key: EMAIL_NOTIFY_GLOBAL_KEY, value: settings.globalEnabled ? '1' : '0' });
|
||||
}
|
||||
if (settings.smtp) {
|
||||
if (settings.smtp.host !== undefined) entries.push({ key: SMTP_HOST_KEY, value: settings.smtp.host });
|
||||
if (settings.smtp.port !== undefined) entries.push({ key: SMTP_PORT_KEY, value: String(settings.smtp.port) });
|
||||
if (settings.smtp.user !== undefined) entries.push({ key: SMTP_USER_KEY, value: settings.smtp.user });
|
||||
if (settings.smtp.pass !== undefined) entries.push({ key: SMTP_PASS_KEY, value: settings.smtp.pass });
|
||||
if (settings.smtp.secure !== undefined) entries.push({ key: SMTP_SECURE_KEY, value: settings.smtp.secure ? '1' : '0' });
|
||||
}
|
||||
|
||||
if (settings.templates) {
|
||||
if (settings.templates.reply !== undefined) entries.push({ key: EMAIL_TEMPLATE_REPLY_KEY, value: settings.templates.reply });
|
||||
if (settings.templates.admin !== undefined) entries.push({ key: EMAIL_TEMPLATE_ADMIN_KEY, value: settings.templates.admin });
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.value !== undefined) {
|
||||
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
|
||||
.bind(entry.key, entry.value)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendCommentReplyNotification(
|
||||
env: Bindings,
|
||||
params: {
|
||||
toEmail: string;
|
||||
toName: string;
|
||||
postTitle: string;
|
||||
parentComment: string;
|
||||
replyAuthor: string;
|
||||
replyContent: string;
|
||||
postUrl: string;
|
||||
},
|
||||
smtpSettings?: EmailNotificationSettings['smtp'],
|
||||
template?: string
|
||||
) {
|
||||
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
|
||||
|
||||
console.log('EmailReplyNotification:start', {
|
||||
toEmail,
|
||||
toName,
|
||||
postTitle
|
||||
});
|
||||
|
||||
const html = replaceTemplate(template || DEFAULT_REPLY_TEMPLATE, {
|
||||
toEmail,
|
||||
toName,
|
||||
postTitle,
|
||||
parentComment,
|
||||
replyAuthor,
|
||||
replyContent,
|
||||
postUrl
|
||||
});
|
||||
|
||||
if (!isValidEmail(toEmail)) {
|
||||
console.warn('EmailReplyNotification:invalidRecipient', { toEmail });
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatchMail(env, {
|
||||
to: [toEmail],
|
||||
subject: `评论回复 - ${postTitle}`,
|
||||
html
|
||||
}, smtpSettings);
|
||||
|
||||
console.log('EmailReplyNotification:sent', {
|
||||
toEmail
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 站长通知邮件
|
||||
*/
|
||||
export async function sendCommentNotification(
|
||||
env: Bindings,
|
||||
params: {
|
||||
postTitle: string;
|
||||
postUrl: string;
|
||||
commentAuthor: string;
|
||||
commentContent: string;
|
||||
},
|
||||
smtpSettings?: EmailNotificationSettings['smtp'],
|
||||
template?: string
|
||||
) {
|
||||
const { postTitle, postUrl, commentAuthor, commentContent } = params;
|
||||
const toEmail = await getAdminNotifyEmail(env);
|
||||
|
||||
const html = replaceTemplate(template || DEFAULT_ADMIN_TEMPLATE, {
|
||||
postTitle,
|
||||
postUrl,
|
||||
commentAuthor,
|
||||
commentContent
|
||||
});
|
||||
|
||||
if (!isValidEmail(toEmail)) {
|
||||
console.warn('EmailAdminNotification:invalidRecipient', { toEmail });
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatchMail(env, {
|
||||
to: [toEmail],
|
||||
subject: `新评论提醒 - ${postTitle}`,
|
||||
html
|
||||
}, smtpSettings);
|
||||
|
||||
console.log('EmailAdminNotification:sent', {
|
||||
toEmail
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminNotifyEmail(env: Bindings): Promise<string> {
|
||||
await env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
const row = await env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
|
||||
.bind('admin_notify_email')
|
||||
.first<{ value: string }>();
|
||||
if (row?.value && isValidEmail(row.value)) {
|
||||
const cleanEmail = row.value.trim();
|
||||
return cleanEmail;
|
||||
}
|
||||
throw new Error('未配置管理员通知邮箱或格式不正确');
|
||||
}
|
||||
19
cwd-api/src/utils/getAvatar.ts
Normal file
19
cwd-api/src/utils/getAvatar.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 默认 gravatar.com 前缀
|
||||
*/
|
||||
const DEFAULT_AVATAR_PREFIX = 'https://gravatar.com/avatar';
|
||||
|
||||
/**
|
||||
* 辅助函数:生成 gravatar.com 头像地址 (MD5 算法)
|
||||
* @param email - 邮箱地址
|
||||
* @param prefix - 头像服务前缀,默认为 https://gravatar.com/avatar
|
||||
*/
|
||||
export const getCravatar = async (email: string, prefix?: string): Promise<string> => {
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const msgUint8 = new TextEncoder().encode(cleanEmail);
|
||||
const hashBuffer = await crypto.subtle.digest('MD5', msgUint8);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const avatarPrefix = prefix || DEFAULT_AVATAR_PREFIX;
|
||||
return `${avatarPrefix}/${hashHex}?s=200&d=retro`;
|
||||
};
|
||||
Reference in New Issue
Block a user