chore: 重构项目结构

This commit is contained in:
anghunk
2026-01-21 10:53:11 +08:00
parent fc86934094
commit 643d5c92e8
59 changed files with 74 additions and 74 deletions

23
cwd-api/src/utils/auth.ts Normal file
View 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
View 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,
})
}

View 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
View 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('未配置管理员通知邮箱或格式不正确');
}

View 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`;
};