refactor(cors): 简化CORS配置并移除未使用的参数

feat(email): 提取邮箱验证逻辑到共享模块并增强验证

docs: 更新文档移除不再使用的ALLOW_ORIGIN配置

style(admin): 为设置页面添加加载状态和样式优化

fix(comments): 在邮件发送前增加邮箱格式验证
This commit is contained in:
anghunk
2026-01-19 17:02:19 +08:00
parent e6e21dff61
commit 73bbe44fbc
9 changed files with 217 additions and 118 deletions

View File

@@ -62,14 +62,13 @@ src/
## 环境变量 ## 环境变量
| 变量名 | 说明 | 必填 | | 变量名 | 说明 | 必填 |
|--------|------|------| | ------------------- | --------------- | ---- |
| `ADMIN_NAME` | 管理员用户名 | 是 | | `ADMIN_NAME` | 管理员用户名 | 是 |
| `ADMIN_PASSWORD` | 管理员密码 | 是 | | `ADMIN_PASSWORD` | 管理员密码 | 是 |
| `ALLOW_ORIGIN` | CORS 白名单(逗号分隔) | 是 | | `RESEND_API_KEY` | Resend API 密钥 | 否 |
| `RESEND_API_KEY` | Resend API 密钥 | 否 | | `RESEND_FROM_EMAIL` | 发件邮箱 | 否 |
| `RESEND_FROM_EMAIL` | 发件邮箱 | 否 | | `EMAIL_ADDRESS` | 站长接收邮箱 | 否 |
| `EMAIL_ADDRESS` | 站长接收邮箱 | 否 |
## 本地开发 ## 本地开发

View File

@@ -5,15 +5,13 @@
Cloudflare Worker 版本基于 Cloudflare Workers + D1 + KV 实现,无需服务器即可部署运行的评论组件。 Cloudflare Worker 版本基于 Cloudflare Workers + D1 + KV 实现,无需服务器即可部署运行的评论组件。
> 基于 https://github.com/Motues/Momo-Backend 进行二次开发的 Cloudflare Worker 版本,做了大量扩展更新。
[文档地址](https://cwd-comments.zishu.me) [文档地址](https://cwd-comments.zishu.me)
## 特性 ## 特性
- ⚡️ **极速响应**:基于 Cloudflare 全球边缘网络 - ⚡️ **极速响应**:基于 Cloudflare 全球边缘网络
- 🔒 **安全可靠**内置管理员认证、CORS 保护 - 🔒 **安全可靠**内置管理员认证、CORS 保护
- 📧 **邮件通知**支持 Resend 邮件服务 - 📧 **邮件通知**基于 Cloudflare Workers 发送邮件
- 🎨 **易于集成**:提供完整的 REST API - 🎨 **易于集成**:提供完整的 REST API
## 前置要求 ## 前置要求
@@ -27,13 +25,18 @@ Cloudflare Worker 版本基于 Cloudflare Workers + D1 + KV 实现,无需服
```bash ```bash
# 克隆项目 # 克隆项目
git clone https://github.com/anghunk/cwd-comments git clone https://github.com/anghunk/cwd-comments
cd cwd-comments
# 安装依赖 # API 项目
cd cwd-comments-api
# 部署请查看文档
# 前端项目
cd cwd-comments-web
npm install npm install
``` ```
## 配置 ## 配置
- [后端配置](./backend-config.md) - [后端配置](https://cwd-comments-docs.zishu.me/guide/backend-config.html)
- [前端配置](./frontend-config.md) - [前端配置](https://cwd-comments-docs.zishu.me/guide/frontend-config.html)

View File

@@ -399,6 +399,38 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
gap: 12px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f0f0f0;
border-top-color: #0969da;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 14px;
color: #57606a;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.pagination { .pagination {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;

View File

@@ -1,151 +1,199 @@
<template> <template>
<div class="page"> <div class="page">
<h2 class="page-title">系统设置</h2> <h2 class="page-title">系统设置</h2>
<div class="card"> <div v-if="loading" class="page-hint">加载中...</div>
<h3 class="card-title">通知邮箱设置</h3> <div v-else>
<div class="form-item"> <div class="card">
<label class="form-label">管理员通知邮箱</label> <h3 class="card-title">通知邮箱设置</h3>
<input v-model="email" class="form-input" type="email" /> <div class="form-item">
</div> <label class="form-label">管理员通知邮箱</label>
<div v-if="message" :class="['form-message', messageType === 'error' ? 'form-message-error' : 'form-message-success']"> <input v-model="email" class="form-input" type="email" />
{{ message }} </div>
</div> <div
<div class="card-actions"> v-if="message"
<button class="card-button" :disabled="saving" @click="save"> :class="[
<span v-if="saving">保存中...</span> 'form-message',
<span v-else>保存</span> messageType === 'error' ? 'form-message-error' : 'form-message-success',
</button> ]"
</div> >
</div> {{ message }}
</div> </div>
<div class="card-actions">
<button class="card-button" :disabled="saving" @click="save">
<span v-if="saving">保存中...</span>
<span v-else>保存</span>
</button>
</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from "vue";
import { fetchAdminEmail, saveAdminEmail } from '../api/admin'; import { fetchAdminEmail, saveAdminEmail } from "../api/admin";
const email = ref(''); const email = ref("");
const saving = ref(false); const saving = ref(false);
const message = ref(''); const loading = ref(false);
const messageType = ref<'success' | 'error'>('success'); const message = ref("");
const messageType = ref<"success" | "error">("success");
async function load() { async function load() {
try { loading.value = true;
const res = await fetchAdminEmail(); try {
email.value = res.email || ''; const res = await fetchAdminEmail();
} catch (e: any) { email.value = res.email || "";
message.value = e.message || '加载失败'; } catch (e: any) {
messageType.value = 'error'; message.value = e.message || "加载失败";
} messageType.value = "error";
} finally {
loading.value = false;
}
} }
async function save() { async function save() {
if (!email.value) { if (!email.value) {
message.value = '请输入邮箱'; message.value = "请输入邮箱";
messageType.value = 'error'; messageType.value = "error";
return; return;
} }
saving.value = true; saving.value = true;
message.value = ''; message.value = "";
try { try {
const res = await saveAdminEmail(email.value); const res = await saveAdminEmail(email.value);
message.value = res.message || '保存成功'; message.value = res.message || "保存成功";
messageType.value = 'success'; messageType.value = "success";
} catch (e: any) { } catch (e: any) {
message.value = e.message || '保存失败'; message.value = e.message || "保存失败";
messageType.value = 'error'; messageType.value = "error";
} finally { } finally {
saving.value = false; saving.value = false;
} }
} }
onMounted(() => { onMounted(() => {
load(); load();
}); });
</script> </script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
max-width: 520px; max-width: 520px;
} }
.page-title { .page-title {
margin: 0; margin: 0;
font-size: 18px; font-size: 18px;
color: #24292f; color: #24292f;
} }
.card { .card {
background-color: #ffffff; background-color: #ffffff;
border-radius: 6px; border-radius: 6px;
border: 1px solid #d0d7de; border: 1px solid #d0d7de;
padding: 16px 18px; padding: 16px 18px;
} }
.card-title { .card-title {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 15px; font-size: 15px;
} }
.form-item { .form-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.form-label { .form-label {
font-size: 14px; font-size: 14px;
color: #555555; color: #555555;
} }
.form-input { .form-input {
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #d0d7de; border: 1px solid #d0d7de;
font-size: 14px; font-size: 14px;
outline: none; outline: none;
} }
.form-input:focus { .form-input:focus {
border-color: #0969da; border-color: #0969da;
box-shadow: 0 0 0 1px rgba(9, 105, 218, 0.2); box-shadow: 0 0 0 1px rgba(9, 105, 218, 0.2);
} }
.card-actions { .card-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.card-button { .card-button {
padding: 8px 14px; padding: 8px 14px;
border-radius: 4px; border-radius: 4px;
border: none; border: none;
background-color: #0969da; background-color: #0969da;
color: #ffffff; color: #ffffff;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
} }
.card-button:disabled { .card-button:disabled {
opacity: 0.7; opacity: 0.7;
cursor: default; cursor: default;
} }
.form-message { .form-message {
font-size: 13px; font-size: 13px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.form-message-success { .form-message-success {
color: #1a7f37; color: #1a7f37;
} }
.form-message-error { .form-message-error {
color: #d1242f; color: #d1242f;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
gap: 12px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f0f0f0;
border-top-color: #0969da;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 14px;
color: #57606a;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.page-hint {
font-size: 14px;
color: #57606a;
} }
</style> </style>

View File

@@ -1,9 +1,6 @@
import { Context } from 'hono'; import { Context } from 'hono';
import { Bindings } from '../../bindings'; import { Bindings } from '../../bindings';
import { isValidEmail } from '../../utils/email';
function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export const setAdminEmail = async (c: Context<{ Bindings: Bindings }>) => { export const setAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
try { try {

View File

@@ -1,7 +1,7 @@
import { Context } from 'hono'; import { Context } from 'hono';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { Bindings } from '../../bindings'; import { Bindings } from '../../bindings';
import { sendCommentNotification, sendCommentReplyNotification } from '../../utils/email'; import { sendCommentNotification, sendCommentReplyNotification, isValidEmail } from '../../utils/email';
// 检查内容,将<script>标签之间的内容删除 // 检查内容,将<script>标签之间的内容删除
export function checkContent(content: string): string { export function checkContent(content: string): string {
@@ -26,6 +26,9 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
if (!email || typeof email !== 'string') { if (!email || typeof email !== 'string') {
return c.json({ message: '邮箱不能为空' }, 400); return c.json({ message: '邮箱不能为空' }, 400);
} }
if (!isValidEmail(email)) {
return c.json({ message: '邮箱格式不正确' }, 400);
}
const userAgent = c.req.header('user-agent') || ""; const userAgent = c.req.header('user-agent') || "";
// 1. 获取 IP (Worker 获取 IP 的标准方式) // 1. 获取 IP (Worker 获取 IP 的标准方式)
@@ -122,7 +125,8 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
"SELECT created_at FROM EmailLog WHERE recipient = ? AND type = 'user-reply' ORDER BY created_at DESC LIMIT 1" "SELECT created_at FROM EmailLog WHERE recipient = ? AND type = 'user-reply' ORDER BY created_at DESC LIMIT 1"
).bind(parentComment.email).first<{ created_at: string }>(); ).bind(parentComment.email).first<{ created_at: string }>();
const canSendUserMail = !recentUserMail || (Date.now() - new Date(recentUserMail.created_at).getTime() > 60 * 1000); const canSendUserMail = !recentUserMail || (Date.now() - new Date(recentUserMail.created_at).getTime() > 60 * 1000);
if (canSendUserMail) {
if (canSendUserMail && isValidEmail(parentComment.email)) {
console.log('PostComment:mailDispatch:userReply:send', { console.log('PostComment:mailDispatch:userReply:send', {
toEmail: parentComment.email, toEmail: parentComment.email,
toName: parentComment.author toName: parentComment.author

View File

@@ -1,6 +1,6 @@
import { cors } from 'hono/cors' import { cors } from 'hono/cors'
export const customCors = (_allowOriginStr: string | undefined) => { export const customCors = () => {
return cors({ return cors({
origin: '*', origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],

View File

@@ -1,5 +1,9 @@
import { Bindings } from '../bindings'; import { Bindings } from '../bindings';
export function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/** /**
* 回复通知邮件 * 回复通知邮件
*/ */
@@ -54,6 +58,11 @@ export async function sendCommentReplyNotification(
throw new Error('未配置邮件发送绑定或发件人地址'); throw new Error('未配置邮件发送绑定或发件人地址');
} }
if (!isValidEmail(toEmail)) {
console.warn('EmailReplyNotification:invalidRecipient', { toEmail });
return;
}
await env.SEND_EMAIL.send({ await env.SEND_EMAIL.send({
to: [{ email: toEmail }], to: [{ email: toEmail }],
from: { email: env.CF_FROM_EMAIL }, from: { email: env.CF_FROM_EMAIL },
@@ -106,6 +115,11 @@ export async function sendCommentNotification(
throw new Error('未配置邮件发送绑定或发件人地址'); throw new Error('未配置邮件发送绑定或发件人地址');
} }
if (!isValidEmail(toEmail)) {
console.warn('EmailAdminNotification:invalidRecipient', { toEmail });
return;
}
await env.SEND_EMAIL.send({ await env.SEND_EMAIL.send({
to: [{ email: toEmail }], to: [{ email: toEmail }],
from: { email: env.CF_FROM_EMAIL }, from: { email: env.CF_FROM_EMAIL },
@@ -125,18 +139,21 @@ async function getAdminNotifyEmail(env: Bindings): Promise<string> {
const row = await env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?') const row = await env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind('admin_notify_email') .bind('admin_notify_email')
.first<{ value: string }>(); .first<{ value: string }>();
if (row?.value) { if (row?.value && isValidEmail(row.value)) {
console.log('EmailAdminNotification:useDbEmail', { console.log('EmailAdminNotification:useDbEmail', {
email: row.value email: row.value
}); });
return row.value; return row.value;
} }
if (env.EMAIL_ADDRESS) { if (env.EMAIL_ADDRESS && isValidEmail(env.EMAIL_ADDRESS)) {
console.log('EmailAdminNotification:useEnvEmail', { console.log('EmailAdminNotification:useEnvEmail', {
email: env.EMAIL_ADDRESS email: env.EMAIL_ADDRESS
}); });
return env.EMAIL_ADDRESS; return env.EMAIL_ADDRESS;
} }
console.error('EmailAdminNotification:noAdminEmail'); console.error('EmailAdminNotification:noAdminEmail', {
throw new Error('未配置管理员通知邮箱'); dbValue: row?.value,
envValue: env.EMAIL_ADDRESS
});
throw new Error('未配置管理员通知邮箱或格式不正确');
} }

View File

@@ -89,7 +89,6 @@ npm install
| 变量名 | 描述 | | 变量名 | 描述 |
| -------------------- | ------------------------------------------------------------------------------------ | | -------------------- | ------------------------------------------------------------------------------------ |
| `ALLOW_ORIGIN` | 允许跨域请求的域名,用逗号分隔 |
| `CF_FROM_EMAIL` | 作为发件人显示的邮箱地址(需在 Cloudflare Email 路由中预先配置) | | `CF_FROM_EMAIL` | 作为发件人显示的邮箱地址(需在 Cloudflare Email 路由中预先配置) |
| `EMAIL_ADDRESS` | 管理员接收通知邮件的默认邮箱(可在后台设置中覆盖) | | `EMAIL_ADDRESS` | 管理员接收通知邮件的默认邮箱(可在后台设置中覆盖) |
| `ADMIN_NAME` | 管理员登录名称,默认为 admin | | `ADMIN_NAME` | 管理员登录名称,默认为 admin |