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}.`
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user