归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
This commit is contained in:
1 parent
855a001046
commit
a74b3c7127
98 files changed
+15657
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,585 @@
|
||||
import type { CommentRow, CookedComment, Env, UserRow } from '../types';
|
||||
import {
|
||||
defaultSiteName,
|
||||
findComment,
|
||||
findCommentRootId,
|
||||
findOrCreatePage,
|
||||
findOrCreateSite,
|
||||
findOrCreateUser,
|
||||
findUserById,
|
||||
hasCaptchaPassed,
|
||||
isCaptchaEnabled,
|
||||
isIPRegionEnabled,
|
||||
isPendingDefault,
|
||||
rateLimit,
|
||||
siteFirstUrl,
|
||||
touchUser,
|
||||
} from '../lib/db';
|
||||
import { cookComments, cookPage, pageAccessibleUrl } from '../lib/cook';
|
||||
import { mailEnabled, notifyByEmail } from '../lib/mail';
|
||||
import { isAdminByNameEmail, isAdminRequest } from '../lib/session';
|
||||
import { md5Lower } from '../lib/md5';
|
||||
import {
|
||||
fail,
|
||||
getClientIP,
|
||||
getUserAgent,
|
||||
isEmail,
|
||||
isUrl,
|
||||
now,
|
||||
ok,
|
||||
okMsg,
|
||||
qBool,
|
||||
qInt,
|
||||
qp,
|
||||
readBody,
|
||||
trimTo,
|
||||
} from '../lib/util';
|
||||
import { humanGate } from './human';
|
||||
import type { Ctx } from '../router';
|
||||
|
||||
const MAX_CONTENT = 10000;
|
||||
const MAX_NAME = 60;
|
||||
|
||||
// ============================================================ GET /comments
|
||||
|
||||
/**
|
||||
* 评论计数缓存的版本号:评论新增/删除后写一次时间戳,
|
||||
* 计数缓存 key 里带上它 → 有变化时旧缓存立刻失效(不用等 TTL 过期)。
|
||||
*/
|
||||
async function countVersion(env: Env, siteName: string, pageKey: string): Promise<string> {
|
||||
try {
|
||||
return (await env.RSS_KV.get(`cml:ver:${siteName}:${pageKey}`)) || '0';
|
||||
} catch {
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
async function bumpCountVersion(env: Env, siteName: string, pageKey: string): Promise<void> {
|
||||
try {
|
||||
await env.RSS_KV.put(`cml:ver:${siteName}:${pageKey}`, Date.now().toString());
|
||||
} catch {
|
||||
/* 缓存版本写失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
export async function listComments(ctx: Ctx): Promise<Response> {
|
||||
const { env, url, user } = ctx;
|
||||
|
||||
const pageKey = qp(url, 'page_key');
|
||||
const siteName = qp(url, 'site_name') || (await defaultSiteName(env));
|
||||
const limit = Math.min(Math.max(qInt(url, 'limit', 20), 1), 100);
|
||||
const offset = Math.max(qInt(url, 'offset', 0), 0);
|
||||
const flatMode = qBool(url, 'flat_mode');
|
||||
const sortBy = qp(url, 'sort_by') || 'date_desc';
|
||||
const search = qp(url, 'search').trim();
|
||||
const type = qp(url, 'type') || 'all';
|
||||
const scope = qp(url, 'scope') || 'page';
|
||||
const viewOnlyAdmin = qBool(url, 'view_only_admin');
|
||||
const name = qp(url, 'name');
|
||||
const email = qp(url, 'email');
|
||||
|
||||
const admin = await isAdminRequest(env, ctx.req, user);
|
||||
|
||||
// 站点必须存在(与官方 CheckSiteExist 一致)
|
||||
if (scope === 'page' && !(await siteExists(env, siteName))) {
|
||||
return fail(400, `Site "${siteName}" not found`);
|
||||
}
|
||||
|
||||
// 未登录时按 name/email 定位用户;若该账号是管理员则视为未登录
|
||||
let viewUser: UserRow | null = user;
|
||||
if (!viewUser && name && email) {
|
||||
const r = await env.DB.prepare('SELECT * FROM users WHERE name = ? AND email = ?')
|
||||
.bind(name, email)
|
||||
.first<UserRow>();
|
||||
if (r && r.is_admin) viewUser = null;
|
||||
else viewUser = r;
|
||||
}
|
||||
|
||||
const where: string[] = ['c.deleted_at = 0'];
|
||||
const binds: unknown[] = [];
|
||||
|
||||
if (!admin) where.push('c.is_pending = 0');
|
||||
|
||||
if (viewOnlyAdmin) {
|
||||
where.push('u.is_admin = 1');
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.push('(c.content LIKE ? OR u.name LIKE ? OR u.email LIKE ?)');
|
||||
const like = `%${search}%`;
|
||||
binds.push(like, like, like);
|
||||
}
|
||||
|
||||
if (scope === 'page') {
|
||||
where.push('c.page_key = ?', 'c.site_name = ?');
|
||||
binds.push(pageKey, siteName);
|
||||
} else if (scope === 'site') {
|
||||
where.push('c.site_name = ?');
|
||||
binds.push(siteName);
|
||||
if (type === 'pending') where.push('c.is_pending = 1');
|
||||
if (type === 'mine' && viewUser) {
|
||||
where.push('c.user_id = ?');
|
||||
binds.push(viewUser.id);
|
||||
}
|
||||
if (type === 'mentions' && viewUser) {
|
||||
where.push('c.rid IN (SELECT id FROM comments WHERE user_id = ? AND deleted_at = 0)');
|
||||
binds.push(viewUser.id);
|
||||
}
|
||||
} else {
|
||||
// scope=user:我参与的(我发的 + 回复我的)
|
||||
if (!viewUser) {
|
||||
return ok({ comments: [], count: 0, roots_count: 0 });
|
||||
}
|
||||
if (type === 'pending') {
|
||||
if (!admin) return fail(403, 'Admin access required');
|
||||
where.push('c.is_pending = 1');
|
||||
} else if (type === 'mine') {
|
||||
where.push('c.user_id = ?');
|
||||
binds.push(viewUser.id);
|
||||
} else if (type === 'mentions') {
|
||||
where.push('c.rid IN (SELECT id FROM comments WHERE user_id = ? AND deleted_at = 0)');
|
||||
binds.push(viewUser.id);
|
||||
} else {
|
||||
where.push(
|
||||
'(c.user_id = ? OR c.rid IN (SELECT id FROM comments WHERE user_id = ? AND deleted_at = 0))',
|
||||
);
|
||||
binds.push(viewUser.id, viewUser.id);
|
||||
}
|
||||
}
|
||||
|
||||
const whereSql = where.join(' AND ');
|
||||
// 前台按官方行为置顶优先;后台管理列表要的是"纯时间流",
|
||||
// 置顶条插在中间会打断"从上往下扫新评论"的操作节奏。
|
||||
const orderSql =
|
||||
sortBy === 'date_asc'
|
||||
? 'c.created_at ASC, c.id ASC'
|
||||
: sortBy === 'vote'
|
||||
? '(c.vote_up - c.vote_down) DESC, c.created_at DESC'
|
||||
: admin
|
||||
? 'c.created_at DESC, c.id DESC'
|
||||
: 'c.is_pinned DESC, c.created_at DESC, c.id DESC';
|
||||
|
||||
const fromSql = 'FROM comments c LEFT JOIN users u ON u.id = c.user_id';
|
||||
|
||||
// 计数只需要 comments 表:where 里没用到 u.* 就不要 JOIN users ——
|
||||
// LEFT JOIN 会让每行评论都多读一行用户记录(D1 按行计费,这是之前额度被打爆的主因之一)
|
||||
const needUsers = /(^|[^\w.])u\./.test(whereSql);
|
||||
const countFromSql = needUsers ? fromSql : 'FROM comments c';
|
||||
|
||||
// 计数走 KV 短缓存(评论增删会 bump 版本号,缓存立刻失效)
|
||||
let count = -1;
|
||||
let rootsCount = -1;
|
||||
let countCacheKey = '';
|
||||
try {
|
||||
const ver = await countVersion(env, siteName, pageKey);
|
||||
countCacheKey = `cml:cnt:${md5Lower(
|
||||
`${whereSql}|${binds.join(',')}|${siteName}|${pageKey}|${scope}|${type}|${sortBy}`,
|
||||
)}:${ver}`;
|
||||
const cached = await env.RSS_KV.get(countCacheKey);
|
||||
if (cached) {
|
||||
const o = JSON.parse(cached) as { c: number; r: number };
|
||||
if (typeof o.c === 'number' && typeof o.r === 'number') {
|
||||
count = o.c;
|
||||
rootsCount = o.r;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 缓存读失败 → 走原始查询 */
|
||||
}
|
||||
|
||||
if (count < 0 || rootsCount < 0) {
|
||||
// 一条 SQL 同时算「总数」和「顶层评论数」:COUNT(*) 与 SUM(root_id=0)
|
||||
// 共用同一次扫描 —— 原来跑两条 COUNT 就是扫两遍(留言页 996 条 → 白读 996 行)
|
||||
const countRow = await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n, SUM(CASE WHEN c.root_id = 0 THEN 1 ELSE 0 END) AS r
|
||||
${countFromSql} WHERE ${whereSql}`,
|
||||
)
|
||||
.bind(...binds)
|
||||
.first<{ n: number; r: number | null }>();
|
||||
count = countRow?.n ?? 0;
|
||||
rootsCount = countRow?.r ?? 0;
|
||||
|
||||
try {
|
||||
if (countCacheKey) {
|
||||
await env.RSS_KV.put(countCacheKey, JSON.stringify({ c: count, r: rootsCount }), {
|
||||
// 评论增删/审核会立刻 bump 版本让缓存失效(见 bumpCountVersion),
|
||||
// 所以 TTL 可以放心放长 —— 它决定"最坏情况下计数查询多久真跑一次":
|
||||
// 10 分钟 TTL = 每小时最多 6 次真 COUNT(*),比原来每次请求都跑省几十倍。
|
||||
expirationTtl: 600,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 写缓存失败无所谓 */
|
||||
}
|
||||
}
|
||||
|
||||
// nested:先取一页顶层,再把它们的全部子孙捞回来(与官方一致,不分页)
|
||||
let rows: CommentRow[];
|
||||
if (flatMode) {
|
||||
rows = (
|
||||
await env.DB.prepare(
|
||||
`SELECT c.* ${fromSql} WHERE ${whereSql} ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(...binds, limit, offset)
|
||||
.all<CommentRow>()
|
||||
).results ?? [];
|
||||
} else {
|
||||
const roots = (
|
||||
await env.DB.prepare(
|
||||
`SELECT c.* ${fromSql} WHERE ${whereSql} AND c.root_id = 0
|
||||
ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(...binds, limit, offset)
|
||||
.all<CommentRow>()
|
||||
).results ?? [];
|
||||
|
||||
rows = roots;
|
||||
if (roots.length) {
|
||||
const ids = roots.map((r) => r.id);
|
||||
for (let i = 0; i < ids.length; i += 80) {
|
||||
const chunk = ids.slice(i, i + 80);
|
||||
const ph = chunk.map(() => '?').join(',');
|
||||
// 优先走 idx_comments_children(按 root_id 逐个 seek);万一索引不存在
|
||||
// (老库/迁移未跑)就回退到普通写法,保证接口不报错。
|
||||
let children: CommentRow[];
|
||||
try {
|
||||
children =
|
||||
(
|
||||
await env.DB.prepare(
|
||||
`SELECT c.* FROM comments c INDEXED BY idx_comments_children
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE ${whereSql} AND c.root_id IN (${ph}) AND c.rid != 0
|
||||
ORDER BY c.created_at ASC, c.id ASC`,
|
||||
)
|
||||
.bind(...binds, ...chunk)
|
||||
.all<CommentRow>()
|
||||
).results ?? [];
|
||||
} catch {
|
||||
children =
|
||||
(
|
||||
await env.DB.prepare(
|
||||
`SELECT c.* ${fromSql} WHERE ${whereSql} AND c.root_id IN (${ph}) AND c.rid != 0
|
||||
ORDER BY c.created_at ASC, c.id ASC`,
|
||||
)
|
||||
.bind(...binds, ...chunk)
|
||||
.all<CommentRow>()
|
||||
).results ?? [];
|
||||
}
|
||||
rows = rows.concat(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flat 模式:把缺失的父评论补进来并标记 visible=false
|
||||
const visibility = new Map<number, boolean>();
|
||||
if (flatMode && rows.length) {
|
||||
const present = new Set(rows.map((r) => r.id));
|
||||
const missing = [...new Set(rows.filter((r) => r.rid && !present.has(r.rid)).map((r) => r.rid))];
|
||||
if (missing.length) {
|
||||
for (let i = 0; i < missing.length; i += 80) {
|
||||
const chunk = missing.slice(i, i + 80);
|
||||
const ph = chunk.map(() => '?').join(',');
|
||||
const extra = (
|
||||
await env.DB.prepare(`SELECT c.* ${fromSql} WHERE c.id IN (${ph}) AND c.deleted_at = 0`)
|
||||
.bind(...chunk)
|
||||
.all<CommentRow>()
|
||||
).results ?? [];
|
||||
for (const e of extra) {
|
||||
rows.push(e);
|
||||
visibility.set(e.id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const comments = await cookComments(env, rows, {
|
||||
ipRegion: await isIPRegionEnabled(env),
|
||||
visibility,
|
||||
includeMarked: admin, // 只有后台要 content_marked,前台不带(省一半流量)
|
||||
});
|
||||
|
||||
const resp: Record<string, unknown> = { comments, count, roots_count: rootsCount };
|
||||
|
||||
if (scope === 'page') {
|
||||
let page = await env.DB.prepare('SELECT * FROM pages WHERE key = ? AND site_name = ?')
|
||||
.bind(pageKey, siteName)
|
||||
.first<any>();
|
||||
if (!page) {
|
||||
page = { id: 0, key: pageKey, site_name: siteName, title: '', admin_only: 0, pv: 0, vote_up: 0, vote_down: 0, accessible_url: '', created_at: now(), updated_at: now() };
|
||||
}
|
||||
const site = await env.DB.prepare('SELECT urls FROM sites WHERE name = ?')
|
||||
.bind(siteName)
|
||||
.first<{ urls: string }>();
|
||||
resp.page = cookPage(page, (site?.urls || '').split(',')[0]?.trim() || env.SITE_URL);
|
||||
}
|
||||
|
||||
return ok(resp);
|
||||
}
|
||||
|
||||
async function siteExists(env: Env, siteName: string): Promise<boolean> {
|
||||
const r = await env.DB.prepare('SELECT id FROM sites WHERE name = ?').bind(siteName).first();
|
||||
return !!r;
|
||||
}
|
||||
|
||||
// ============================================================ POST /comments
|
||||
|
||||
export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
const { env, req, user } = ctx;
|
||||
const body = await readBody(req);
|
||||
|
||||
const name = trimTo(body.name || '', MAX_NAME).trim();
|
||||
const email = trimTo(body.email || '', 120).trim();
|
||||
const link = trimTo(body.link || '', 255).trim();
|
||||
const content = trimTo(body.content || '', MAX_CONTENT);
|
||||
const rid = Number(body.rid || 0);
|
||||
const pageKey = trimTo(body.page_key || '', 255).trim();
|
||||
const pageTitle = trimTo(body.page_title || '', 255);
|
||||
const siteName = trimTo(body.site_name || '', 120) || (await defaultSiteName(env));
|
||||
|
||||
if (!name || !email || !content || !pageKey) {
|
||||
return fail(400, 'Invalid parameters: name / email / content / page_key are required');
|
||||
}
|
||||
if (!isEmail(email)) return fail(400, 'Invalid Email');
|
||||
if (link && !isUrl(link)) return fail(400, 'Invalid Link');
|
||||
|
||||
const ip = getClientIP(req);
|
||||
const ua = trimTo(body.ua || getUserAgent(req), 255);
|
||||
|
||||
if (!(await siteExists(env, siteName))) {
|
||||
return fail(400, `Site "${siteName}" not found`);
|
||||
}
|
||||
|
||||
const admin = await isAdminRequest(env, req, user);
|
||||
|
||||
// 同一 IP 60 秒内最多 5 条;管理员不限
|
||||
if (!admin) {
|
||||
const allowed = await rateLimit(env, `comment:${ip}`, 5, 60);
|
||||
if (!allowed) return fail(429, 'Too many requests, please slow down');
|
||||
}
|
||||
|
||||
// 人机验证门禁:KV 通行证(自研一键验证)或图形验证码通行证,任一通过即可。
|
||||
// 管理员豁免;正常读者在页面加载时就静默拿到通行证,走到这里 0 额外 D1 读。
|
||||
{
|
||||
const gate = await humanGate(env, ip, admin);
|
||||
if (!gate.pass) {
|
||||
return fail(403, 'Human verification required', {
|
||||
need_captcha: true,
|
||||
need_human: gate.humanOn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const page = await findOrCreatePage(env, pageKey, pageTitle, siteName);
|
||||
if (!page?.key) return fail(500, 'Comment failed');
|
||||
|
||||
// 父评论校验
|
||||
let parent: CommentRow | null = null;
|
||||
if (rid) {
|
||||
parent = await findComment(env, rid);
|
||||
if (!parent) return fail(404, 'Parent comment not found');
|
||||
if (parent.page_key !== pageKey) {
|
||||
return fail(400, 'Inconsistent with the page_key of the parent comment');
|
||||
}
|
||||
if (parent.is_collapsed || parent.is_pending) {
|
||||
return fail(400, 'Cannot reply to this comment');
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员昵称/邮箱不允许匿名发表
|
||||
if (!admin && (await isAdminByNameEmail(env, name, email))) {
|
||||
return fail(403, 'Admin access required', { need_login: true });
|
||||
}
|
||||
|
||||
// 匿名用户:按 name+email 找或建
|
||||
let author = user;
|
||||
let isVerified = true;
|
||||
if (!author) {
|
||||
isVerified = false;
|
||||
author = await findOrCreateUser(env, name, email, link);
|
||||
await env.DB.prepare(
|
||||
'UPDATE users SET link = ?, last_ip = ?, last_ua = ?, name = ?, email = ?, updated_at = ? WHERE id = ?',
|
||||
)
|
||||
.bind(link, ip, ua, name, email, now(), author.id)
|
||||
.run();
|
||||
isVerified = false;
|
||||
} else {
|
||||
await touchUser(env, author.id, ip, ua);
|
||||
}
|
||||
if (author.is_admin) isVerified = true;
|
||||
|
||||
const rootId = rid ? await findCommentRootId(env, rid) : 0;
|
||||
const pending = !admin && (await isPendingDefault(env));
|
||||
|
||||
// Workers 自带 IP 归属(cf.country 是 ISO 国家码),存下来供 ip_region 开启时使用
|
||||
const cf = (req as unknown as { cf?: { country?: string; city?: string } }).cf;
|
||||
const ipRegion = cf?.country ? String(cf.country) : '';
|
||||
|
||||
const t = now();
|
||||
const res = await env.DB.prepare(
|
||||
`INSERT INTO comments
|
||||
(content, page_key, site_name, user_id, rid, root_id, is_pending, is_verified, ua, ip, ip_region, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
content,
|
||||
pageKey,
|
||||
siteName,
|
||||
author.id,
|
||||
rid,
|
||||
rootId,
|
||||
pending ? 1 : 0,
|
||||
isVerified ? 1 : 0,
|
||||
ua,
|
||||
ip,
|
||||
ipRegion,
|
||||
t,
|
||||
t,
|
||||
)
|
||||
.run();
|
||||
|
||||
const newId = Number(res.meta?.last_row_id ?? 0);
|
||||
await bumpCountVersion(env, siteName, pageKey); // 计数缓存失效
|
||||
const row = await findComment(env, newId);
|
||||
if (!row) return fail(500, 'Comment failed');
|
||||
|
||||
const [cooked] = await cookComments(env, [row], {
|
||||
ipRegion: await isIPRegionEnabled(env),
|
||||
includeMarked: !!author.is_admin,
|
||||
});
|
||||
|
||||
// 通知(站内):回复时给父评论作者写一条
|
||||
if (parent && parent.user_id !== author.id) {
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO notifies (user_id, comment_id, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.bind(parent.user_id, newId, t, t)
|
||||
.run();
|
||||
}
|
||||
|
||||
// 通知(邮件):用 waitUntil 异步发,不拖慢评论提交。
|
||||
// 没配 RESEND_API_KEY 时 mailEnabled() 直接返回 false,整段跳过。
|
||||
if (ctx.waitUntil && mailEnabled(env)) {
|
||||
ctx.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const parentAuthor = parent ? await findUserById(env, parent.user_id) : null;
|
||||
const siteUrl = await siteFirstUrl(env, siteName);
|
||||
await notifyByEmail(env, {
|
||||
newComment: row,
|
||||
author,
|
||||
parent,
|
||||
parentAuthor,
|
||||
siteName,
|
||||
siteUrl,
|
||||
pageTitle: page.title || pageTitle,
|
||||
pageUrl: pageAccessibleUrl(pageKey, siteUrl),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[mail] 通知流程失败:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(cooked);
|
||||
}
|
||||
|
||||
// ============================================================ 单条操作
|
||||
|
||||
export async function getComment(ctx: Ctx): Promise<Response> {
|
||||
const id = parseInt(ctx.params.id, 10);
|
||||
if (!Number.isFinite(id)) return fail(400, 'invalid comment id');
|
||||
|
||||
const row = await findComment(ctx.env, id);
|
||||
if (!row) return fail(404, 'Comment not found');
|
||||
|
||||
const admin = await isAdminRequest(ctx.env, ctx.req, ctx.user);
|
||||
if (row.is_pending && !admin) return fail(404, 'Comment not found');
|
||||
|
||||
const [cooked] = await cookComments(ctx.env, [row], { includeMarked: admin });
|
||||
let reply: CookedComment | undefined;
|
||||
if (row.rid) {
|
||||
const parentRow = await findComment(ctx.env, row.rid);
|
||||
if (parentRow) {
|
||||
[reply] = await cookComments(ctx.env, [parentRow], { includeMarked: admin });
|
||||
}
|
||||
}
|
||||
return ok({ comment: cooked, reply_comment: reply });
|
||||
}
|
||||
|
||||
export async function updateComment(ctx: Ctx): Promise<Response> {
|
||||
const { env, req, user } = ctx;
|
||||
const id = parseInt(ctx.params.id, 10);
|
||||
if (!Number.isFinite(id)) return fail(400, 'invalid comment id');
|
||||
|
||||
const row = await findComment(env, id);
|
||||
if (!row) return fail(404, 'Comment not found');
|
||||
|
||||
const admin = await isAdminRequest(env, req, user);
|
||||
const isOwner = !!user && user.id === row.user_id;
|
||||
if (!admin && !isOwner) return fail(403, 'Forbidden');
|
||||
|
||||
const body = await readBody(req);
|
||||
const sets: string[] = [];
|
||||
const binds: unknown[] = [];
|
||||
|
||||
// 普通用户只能改自己的正文;其余字段只有管理员能动
|
||||
if (body.content !== undefined) {
|
||||
sets.push('content = ?');
|
||||
binds.push(trimTo(body.content, MAX_CONTENT));
|
||||
}
|
||||
if (admin) {
|
||||
for (const [field, col] of [
|
||||
['is_collapsed', 'is_collapsed'],
|
||||
['is_pending', 'is_pending'],
|
||||
['is_pinned', 'is_pinned'],
|
||||
['is_verified', 'is_verified'],
|
||||
] as const) {
|
||||
if (body[field] !== undefined) {
|
||||
sets.push(`${col} = ?`);
|
||||
binds.push(body[field] ? 1 : 0);
|
||||
}
|
||||
}
|
||||
if (body.rid !== undefined) {
|
||||
sets.push('rid = ?');
|
||||
binds.push(Number(body.rid) || 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sets.length) return okMsg();
|
||||
|
||||
sets.push('updated_at = ?');
|
||||
binds.push(now(), id);
|
||||
await env.DB.prepare(`UPDATE comments SET ${sets.join(', ')} WHERE id = ?`)
|
||||
.bind(...binds)
|
||||
.run();
|
||||
// 只有"计数会变"的字段(待审状态 / 置顶 / 软删)才需要让计数缓存失效;
|
||||
// 改正文、投票之类不该每次都被打掉——否则缓存永远命中不了,COUNT(*) 照跑。
|
||||
if (body.is_pending !== undefined || body.is_pinned !== undefined || body.rid !== undefined) {
|
||||
await bumpCountVersion(env, row.site_name, row.page_key);
|
||||
}
|
||||
|
||||
const updated = await findComment(env, id);
|
||||
const [cooked] = await cookComments(env, updated ? [updated] : [], { includeMarked: admin });
|
||||
return ok(cooked ?? {});
|
||||
}
|
||||
|
||||
export async function deleteComment(ctx: Ctx): Promise<Response> {
|
||||
const { env, req, user } = ctx;
|
||||
const id = parseInt(ctx.params.id, 10);
|
||||
if (!Number.isFinite(id)) return fail(400, 'invalid comment id');
|
||||
|
||||
const row = await findComment(env, id);
|
||||
if (!row) return fail(404, 'Comment not found');
|
||||
|
||||
const admin = await isAdminRequest(env, req, user);
|
||||
const isOwner = !!user && user.id === row.user_id;
|
||||
if (!admin && !isOwner) return fail(403, 'Forbidden');
|
||||
|
||||
await env.DB.prepare('UPDATE comments SET deleted_at = ?, updated_at = ? WHERE id = ?')
|
||||
.bind(now(), now(), id)
|
||||
.run();
|
||||
await bumpCountVersion(env, row.site_name, row.page_key); // 计数缓存失效
|
||||
|
||||
return okMsg();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 人机验证三个端点(挂在 /api/v2/human/*,也兼容根路径 /human/*)
|
||||
//
|
||||
// GET /human/challenge → 领挑战(无状态 HMAC 签名)
|
||||
// POST /human/verify → 交 PoW + 行为信号;通过则发「通行证」(KV,IP 维度)
|
||||
// GET /human/status → 当前 IP 是否已通过(前端决定要不要显示控件)
|
||||
//
|
||||
// 通行证存在 KV 而不是 D1:评论提交时的校验是每个请求都要做的,
|
||||
// 走 KV 既便宜(免费版 10 万读/天)又不占 D1 的 rows_read 额度。
|
||||
|
||||
import type { Ctx } from '../router';
|
||||
import type { Env } from '../types';
|
||||
import { getClientIP, ok, readBody } from '../lib/util';
|
||||
import {
|
||||
assessSignals,
|
||||
grantHumanPass,
|
||||
hasHumanPass,
|
||||
isHumanCheckEnabled,
|
||||
issueChallenge,
|
||||
verifyProof,
|
||||
type HumanSignals,
|
||||
} from '../lib/human';
|
||||
|
||||
export async function humanChallenge(ctx: Ctx): Promise<Response> {
|
||||
const { env } = ctx;
|
||||
if (!(await isHumanCheckEnabled(env))) {
|
||||
return ok({ enabled: false, pass: true });
|
||||
}
|
||||
const ip = getClientIP(ctx.req);
|
||||
// ?fresh=1 时忽略"已有通行证",照样发一张新挑战(预览/排查用)
|
||||
const fresh = new URL(ctx.req.url).searchParams.get('fresh') === '1';
|
||||
const already = !fresh && (await hasHumanPass(env, ip));
|
||||
return ok({ enabled: true, pass: already, ...(already ? {} : await issueChallenge(env)) });
|
||||
}
|
||||
|
||||
export async function humanStatus(ctx: Ctx): Promise<Response> {
|
||||
const { env } = ctx;
|
||||
const enabled = await isHumanCheckEnabled(env);
|
||||
const pass = enabled ? await hasHumanPass(env, getClientIP(ctx.req)) : true;
|
||||
return ok({ enabled, pass });
|
||||
}
|
||||
|
||||
export async function humanVerify(ctx: Ctx): Promise<Response> {
|
||||
const { env, req } = ctx;
|
||||
if (!(await isHumanCheckEnabled(env))) return ok({ pass: true, enabled: false });
|
||||
|
||||
const ip = getClientIP(req);
|
||||
// ?fresh=1:即使该 IP 已有通行证也重新校验一遍 —— 给预览页/排查用。
|
||||
// 不构成后门:仍然需要算出有效 PoW,只是跳过"缓存通行证直接放行"。
|
||||
const fresh = new URL(req.url).searchParams.get('fresh') === '1';
|
||||
if (!fresh && (await hasHumanPass(env, ip))) {
|
||||
return ok({ pass: true, enabled: true, cached: true });
|
||||
}
|
||||
|
||||
const body = await readBody(req);
|
||||
const signals: HumanSignals = {
|
||||
honeypot: typeof body.honeypot === 'string' ? body.honeypot : '',
|
||||
elapsedMs: Number(body.elapsedMs) || 0,
|
||||
events: Number(body.events) || 0,
|
||||
webdriver: body.webdriver === true,
|
||||
clicked: body.clicked === true,
|
||||
};
|
||||
|
||||
const powOk = await verifyProof(env, {
|
||||
challenge: body.challenge,
|
||||
nonce: body.nonce,
|
||||
exp: Number(body.exp) || 0,
|
||||
sig: body.sig,
|
||||
});
|
||||
if (!powOk) {
|
||||
return ok({ pass: false, need_captcha: true, reason: 'pow' });
|
||||
}
|
||||
|
||||
const risk = assessSignals(signals);
|
||||
if (risk.level === 'high') {
|
||||
return ok({ pass: false, need_captcha: true, reason: risk.reasons.join(',') });
|
||||
}
|
||||
if (risk.level === 'medium') {
|
||||
// 让前端显示「点一下」控件:点击会补齐 elapsedMs / events,重试即通过
|
||||
return ok({ pass: false, need_click: true, reason: risk.reasons.join(',') });
|
||||
}
|
||||
|
||||
await grantHumanPass(env, ip);
|
||||
return ok({ pass: true, enabled: true });
|
||||
}
|
||||
|
||||
/** 评论提交时的统一门禁:人机通行证(KV)或图形验证码通行证(D1)任一通过即可 */
|
||||
export async function humanGate(
|
||||
env: Env,
|
||||
ip: string,
|
||||
admin: boolean,
|
||||
): Promise<{ pass: boolean; humanOn: boolean; captchaOn: boolean }> {
|
||||
if (admin) return { pass: true, humanOn: false, captchaOn: false };
|
||||
|
||||
// 先看最便宜的 KV 通行证;命中就不用再读 D1 settings(正常读者走这条)
|
||||
if (await hasHumanPass(env, ip)) return { pass: true, humanOn: true, captchaOn: false };
|
||||
|
||||
const { isCaptchaEnabled, hasCaptchaPassed } = await import('../lib/db');
|
||||
const humanOn = await isHumanCheckEnabled(env);
|
||||
const captchaOn = await isCaptchaEnabled(env);
|
||||
if (!humanOn && !captchaOn) return { pass: true, humanOn, captchaOn };
|
||||
if (await hasCaptchaPassed(env, ip)) return { pass: true, humanOn, captchaOn };
|
||||
return { pass: false, humanOn, captchaOn };
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { Env } from '../types';
|
||||
import {
|
||||
defaultSiteName,
|
||||
findAllSites,
|
||||
findOrCreatePage,
|
||||
findOrCreateSite,
|
||||
findOrCreateUser,
|
||||
getAdminUsers,
|
||||
getFrontendConf,
|
||||
grantCaptchaPass,
|
||||
isCaptchaEnabled,
|
||||
rateLimit,
|
||||
} from '../lib/db';
|
||||
import { cookSite } from '../lib/cook';
|
||||
import { md5 } from '../lib/md5';
|
||||
import { isHumanCheckEnabled } from '../lib/human';
|
||||
import { mailEnabled } from '../lib/mail';
|
||||
import { hashPassword, isAdminRequest } from '../lib/session';
|
||||
import { fail, getClientIP, now, ok, qp, readBody, trimTo } from '../lib/util';
|
||||
import type { Ctx } from '../router';
|
||||
|
||||
/** 服务端实现的 API 版本 —— 必须与博客里打包的 Artalk 客户端版本一致,否则前端会弹版本警告 */
|
||||
export const SERVER_API_VERSION = '2.8.7';
|
||||
|
||||
// ==================================================================== /conf
|
||||
|
||||
export async function getConf(ctx: Ctx): Promise<Response> {
|
||||
const { env, req, user } = ctx;
|
||||
const frontendConf = await getFrontendConf(env);
|
||||
const admin = await isAdminRequest(env, req, user);
|
||||
|
||||
const imgUpload = !!(env.UPLOADS && env.IMG_UPLOAD_ENABLED !== 'false');
|
||||
|
||||
return ok({
|
||||
frontend_conf: { ...frontendConf, imgUpload: imgUpload || admin },
|
||||
version: { app: 'artalk', version: SERVER_API_VERSION, commit_hash: '' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVersion(ctx: Ctx): Promise<Response> {
|
||||
return ok({ app: 'artalk', version: SERVER_API_VERSION, commit_hash: '' });
|
||||
}
|
||||
|
||||
/** 用于判断某个 URL 是否属于已配置的站点、以及取站点 origin */
|
||||
export async function getConfDomain(ctx: Ctx): Promise<Response> {
|
||||
const target = qp(ctx.url, 'url');
|
||||
const sites = await findAllSites(ctx.env);
|
||||
const trusted = sites.some((s) =>
|
||||
(s.urls || '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.some((u) => target.startsWith(u.replace(/\/$/, ''))),
|
||||
);
|
||||
|
||||
let origin = '';
|
||||
try {
|
||||
origin = target ? new URL(target).origin : '';
|
||||
} catch {
|
||||
origin = '';
|
||||
}
|
||||
return ok({ origin, is_trusted: trusted });
|
||||
}
|
||||
|
||||
export async function getAuthProviders(): Promise<Response> {
|
||||
// 未接入第三方登录(GitHub / Google / OIDC),返回空数组让前端隐藏入口
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
// ================================================================== 状态检查
|
||||
|
||||
/** /healthz —— 给你自己用的探活,官方客户端不调 */
|
||||
export async function healthz(ctx: Ctx): Promise<Response> {
|
||||
const t0 = Date.now();
|
||||
// 只用 SELECT 1 探活——COUNT(*) 是全表扫描,监控/刷新频繁打的话
|
||||
// 一天能烧掉几十万 rows_read(免费额度 5M/天)。评论数走 settings 缓存。
|
||||
const res = await ctx.env.DB.prepare('SELECT 1 AS ok')
|
||||
.run()
|
||||
.catch(() => null);
|
||||
const meta = res?.meta as
|
||||
| { served_by_region?: string; served_by_primary?: boolean; served_by?: string }
|
||||
| undefined;
|
||||
let comments = -1;
|
||||
const cached = await ctx.env.DB.prepare(`SELECT value FROM settings WHERE key = 'healthz_comments'`)
|
||||
.first<{ value: string }>()
|
||||
.catch(() => null);
|
||||
if (cached?.value != null) {
|
||||
try {
|
||||
comments = JSON.parse(cached.value) as number;
|
||||
} catch {
|
||||
/* 缓存值损坏时不致命 */
|
||||
}
|
||||
}
|
||||
return ok({
|
||||
ok: true,
|
||||
db: res ? 'up' : 'down',
|
||||
comments,
|
||||
latency_ms: Date.now() - t0,
|
||||
// read replication 生效后,这两个字段能看出请求落在了哪个区域、是否主库
|
||||
region: meta?.served_by_region ?? 'unknown',
|
||||
primary: meta?.served_by_primary ?? null,
|
||||
// Worker 实际在哪个机房执行 —— 判断「要不要副本」的关键:Worker 若已在 D1 同区域,
|
||||
// 副本就带不来收益(本地开发时 cf 不存在,给 unknown)
|
||||
colo: (ctx.req as unknown as { cf?: { colo?: string; country?: string } }).cf?.colo ?? 'unknown',
|
||||
// 邮件通知是否配置好(配了 RESEND_API_KEY 才会真发信)
|
||||
mail: mailEnabled(ctx.env) ? 'on' : 'off',
|
||||
version: SERVER_API_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================================================================== 验证码
|
||||
|
||||
function svgCaptcha(code: string): string {
|
||||
const chars = code.split('');
|
||||
const noise: string[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const x1 = Math.random() * 120;
|
||||
const y1 = Math.random() * 40;
|
||||
const x2 = Math.random() * 120;
|
||||
const y2 = Math.random() * 40;
|
||||
noise.push(
|
||||
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#c8ccd4" stroke-width="1"/>`,
|
||||
);
|
||||
}
|
||||
const glyphs = chars
|
||||
.map((c, i) => {
|
||||
const x = 14 + i * 24;
|
||||
const rot = (Math.random() * 30 - 15).toFixed(1);
|
||||
const y = 30 + (Math.random() * 6 - 3);
|
||||
return `<text x="${x}" y="${y.toFixed(1)}" font-family="monospace" font-size="26" font-weight="700" fill="#2b2f38" transform="rotate(${rot} ${x} ${y.toFixed(1)})">${c}</text>`;
|
||||
})
|
||||
.join('');
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">` +
|
||||
`<rect width="120" height="40" fill="#f2f3f5"/>${noise}${glyphs}</svg>`;
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
}
|
||||
|
||||
function randomCode(): string {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(4));
|
||||
return [...bytes].map((b) => alphabet[b % alphabet.length]).join('');
|
||||
}
|
||||
|
||||
export async function getCaptcha(ctx: Ctx): Promise<Response> {
|
||||
// 人机验证(自研一键验证)开启时也要能取图形验证码 —— 它是高风险访客的回退方案
|
||||
if (!(await isCaptchaEnabled(ctx.env)) && !(await isHumanCheckEnabled(ctx.env))) {
|
||||
return ok({ img_data: '', iframe: false });
|
||||
}
|
||||
const ip = getClientIP(ctx.req);
|
||||
const code = randomCode();
|
||||
const id = crypto.randomUUID();
|
||||
const t = now();
|
||||
|
||||
await ctx.env.DB.prepare(
|
||||
'INSERT INTO captcha_challenges (id, answer_hash, ip, expires_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.bind(id, md5(code.toUpperCase()), ip, t + 10 * 60 * 1000)
|
||||
.run();
|
||||
|
||||
return ok({ img_data: svgCaptcha(code), iframe: false, challenge_id: id });
|
||||
}
|
||||
|
||||
export async function getCaptchaStatus(ctx: Ctx): Promise<Response> {
|
||||
if (!(await isCaptchaEnabled(ctx.env)) && !(await isHumanCheckEnabled(ctx.env))) {
|
||||
return ok({ is_pass: true });
|
||||
}
|
||||
const ip = getClientIP(ctx.req);
|
||||
const row = await ctx.env.DB.prepare(
|
||||
'SELECT expires_at FROM captcha_passes WHERE ip = ? AND expires_at > ?',
|
||||
)
|
||||
.bind(ip, now())
|
||||
.first<{ expires_at: number }>();
|
||||
return ok({ is_pass: !!row });
|
||||
}
|
||||
|
||||
export async function verifyCaptcha(ctx: Ctx): Promise<Response> {
|
||||
if (!(await isCaptchaEnabled(ctx.env)) && !(await isHumanCheckEnabled(ctx.env))) {
|
||||
return ok({ msg: 'Success' });
|
||||
}
|
||||
|
||||
const ip = getClientIP(ctx.req);
|
||||
const body = await readBody(ctx.req);
|
||||
const value = trimTo(body.value || '', 16).trim().toUpperCase();
|
||||
|
||||
if (!(await rateLimit(ctx.env, `captcha:${ip}`, 10, 60))) {
|
||||
return fail(429, 'Too many attempts');
|
||||
}
|
||||
|
||||
const challenge = await ctx.env.DB.prepare(
|
||||
'SELECT id, answer_hash, expires_at FROM captcha_challenges WHERE ip = ? ORDER BY expires_at DESC LIMIT 1',
|
||||
)
|
||||
.bind(ip)
|
||||
.first<{ id: string; answer_hash: string; expires_at: number }>();
|
||||
|
||||
if (!challenge || challenge.expires_at < now()) return fail(403, 'Captcha expired');
|
||||
if (md5(value) !== challenge.answer_hash) return fail(403, 'Captcha incorrect');
|
||||
|
||||
const t = now();
|
||||
await ctx.env.DB.prepare('DELETE FROM captcha_challenges WHERE id = ?').bind(challenge.id).run();
|
||||
await grantCaptchaPass(ctx.env, ip, 1800);
|
||||
|
||||
return ok({ msg: 'Success' });
|
||||
}
|
||||
|
||||
// ==================================================================== 投票
|
||||
|
||||
export async function voteGet(ctx: Ctx): Promise<Response> {
|
||||
const targetName = ctx.params.target_name;
|
||||
const targetId = parseInt(ctx.params.target_id, 10);
|
||||
const type = targetName === 'comment' ? 'comment' : targetName === 'page' ? 'page' : '';
|
||||
if (!type) return fail(404, 'unknown vote target name');
|
||||
if (!Number.isFinite(targetId)) return fail(400, 'invalid vote target id');
|
||||
|
||||
const row = await voteTotals(ctx.env, type, targetId);
|
||||
if (!row) return fail(404, `${targetName} not found`);
|
||||
|
||||
let isUp = false;
|
||||
let isDown = false;
|
||||
if (ctx.user) {
|
||||
const mine = await ctx.env.DB.prepare(
|
||||
'SELECT type FROM votes WHERE target_id = ? AND user_id = ? AND type IN (?, ?)',
|
||||
)
|
||||
.bind(targetId, ctx.user.id, `${type}_up`, `${type}_down`)
|
||||
.all<{ type: string }>();
|
||||
for (const v of mine.results ?? []) {
|
||||
if (v.type === `${type}_up`) isUp = true;
|
||||
if (v.type === `${type}_down`) isDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ up: row.up, down: row.down, is_up: isUp, is_down: isDown });
|
||||
}
|
||||
|
||||
async function voteTotals(
|
||||
env: Env,
|
||||
type: 'comment' | 'page',
|
||||
id: number,
|
||||
): Promise<{ up: number; down: number } | null> {
|
||||
if (type === 'comment') {
|
||||
const r = await env.DB.prepare('SELECT vote_up, vote_down FROM comments WHERE id = ? AND deleted_at = 0')
|
||||
.bind(id)
|
||||
.first<{ vote_up: number; vote_down: number }>();
|
||||
return r ? { up: r.vote_up, down: r.vote_down } : null;
|
||||
}
|
||||
const r = await env.DB.prepare('SELECT vote_up, vote_down FROM pages WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ vote_up: number; vote_down: number }>();
|
||||
return r ? { up: r.vote_up, down: r.vote_down } : null;
|
||||
}
|
||||
|
||||
export async function voteCreate(ctx: Ctx): Promise<Response> {
|
||||
const targetName = ctx.params.target_name;
|
||||
const targetId = parseInt(ctx.params.target_id, 10);
|
||||
const choice = ctx.params.choice;
|
||||
const type = targetName === 'comment' ? 'comment' : targetName === 'page' ? 'page' : '';
|
||||
if (!type) return fail(404, 'unknown vote target name');
|
||||
if (choice !== 'up' && choice !== 'down') return fail(404, 'unknown vote choice');
|
||||
if (!Number.isFinite(targetId)) return fail(400, 'invalid vote target id');
|
||||
|
||||
const ip = getClientIP(ctx.req);
|
||||
if (!(await rateLimit(ctx.env, `vote:${ip}`, 30, 60))) return fail(429, 'Too many requests');
|
||||
|
||||
const totals = await voteTotals(ctx.env, type, targetId);
|
||||
if (!totals) return fail(404, `${targetName} not found`);
|
||||
|
||||
const body = await readBody(ctx.req);
|
||||
let voter = ctx.user;
|
||||
if (!voter) {
|
||||
const name = trimTo(body.name || '', 60).trim();
|
||||
const email = trimTo(body.email || '', 120).trim();
|
||||
if (!name || !email) return fail(400, 'name and email are required when not logged in');
|
||||
voter = await findOrCreateUser(ctx.env, name, email);
|
||||
}
|
||||
|
||||
const voteType = `${type}_${choice}`;
|
||||
const existing = await ctx.env.DB.prepare(
|
||||
'SELECT id, type FROM votes WHERE target_id = ? AND user_id = ? AND type IN (?, ?)',
|
||||
)
|
||||
.bind(targetId, voter.id, `${type}_up`, `${type}_down`)
|
||||
.all<{ id: number; type: string }>();
|
||||
const rows = existing.results ?? [];
|
||||
|
||||
const t = now();
|
||||
const stmts: D1PreparedStatement[] = [];
|
||||
|
||||
if (rows.some((r) => r.type === voteType)) {
|
||||
// 再点一次 = 取消
|
||||
stmts.push(ctx.env.DB.prepare('DELETE FROM votes WHERE id = ?').bind(rows[0].id));
|
||||
stmts.push(deltaVote(ctx.env, type, targetId, choice, -1));
|
||||
} else {
|
||||
// 先清掉反方向的票
|
||||
for (const r of rows) {
|
||||
stmts.push(ctx.env.DB.prepare('DELETE FROM votes WHERE id = ?').bind(r.id));
|
||||
stmts.push(deltaVote(ctx.env, type, targetId, r.type.endsWith('_up') ? 'up' : 'down', -1));
|
||||
}
|
||||
stmts.push(
|
||||
ctx.env.DB.prepare(
|
||||
'INSERT INTO votes (target_id, user_id, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).bind(targetId, voter.id, voteType, t, t),
|
||||
);
|
||||
stmts.push(deltaVote(ctx.env, type, targetId, choice, 1));
|
||||
}
|
||||
|
||||
await ctx.env.DB.batch(stmts);
|
||||
|
||||
const after = await voteTotals(ctx.env, type, targetId);
|
||||
return ok({
|
||||
up: after?.up ?? 0,
|
||||
down: after?.down ?? 0,
|
||||
is_up: choice === 'up' && !rows.some((r) => r.type === voteType),
|
||||
is_down: choice === 'down' && !rows.some((r) => r.type === voteType),
|
||||
});
|
||||
}
|
||||
|
||||
function deltaVote(
|
||||
env: Env,
|
||||
type: 'comment' | 'page',
|
||||
id: number,
|
||||
choice: string,
|
||||
delta: number,
|
||||
): D1PreparedStatement {
|
||||
const col = choice === 'up' ? 'vote_up' : 'vote_down';
|
||||
const table = type === 'comment' ? 'comments' : 'pages';
|
||||
return env.DB.prepare(
|
||||
`UPDATE ${table} SET ${col} = MAX(0, ${col} + ?), updated_at = ? WHERE id = ?`,
|
||||
).bind(delta, now(), id);
|
||||
}
|
||||
|
||||
export async function voteSync(ctx: Ctx): Promise<Response> {
|
||||
// 不做客户端缓存同步,直接回成功(前端只是用来对齐本地缓存)
|
||||
return ok({ msg: 'Success' });
|
||||
}
|
||||
|
||||
// ================================================================== 浏览量
|
||||
|
||||
export async function pagePV(ctx: Ctx): Promise<Response> {
|
||||
const { env } = ctx;
|
||||
const body = await readBody(ctx.req);
|
||||
const pageKey = trimTo(body.page_key || '', 255).trim();
|
||||
const pageTitle = trimTo(body.page_title || '', 255);
|
||||
const siteName = trimTo(body.site_name || '', 120) || (await defaultSiteName(env));
|
||||
|
||||
if (!pageKey) return fail(400, 'page_key is required');
|
||||
if (!(await findOrCreateSite(env, siteName))) return fail(400, `Site "${siteName}" not found`);
|
||||
|
||||
const page = await findOrCreatePage(env, pageKey, pageTitle, siteName);
|
||||
await env.DB.prepare('UPDATE pages SET pv = pv + 1, updated_at = ? WHERE id = ?')
|
||||
.bind(now(), page.id)
|
||||
.run();
|
||||
|
||||
return ok({ pv: (page.pv || 0) + 1 });
|
||||
}
|
||||
|
||||
// ==================================================================== 站点列表
|
||||
|
||||
export async function siteListPublic(ctx: Ctx): Promise<Response> {
|
||||
const sites = await findAllSites(ctx.env);
|
||||
return ok(sites.map(cookSite));
|
||||
}
|
||||
|
||||
/** 首次部署初始化:写入默认站点 + 管理员账号。 */
|
||||
export async function setup(ctx: Ctx): Promise<Response> {
|
||||
const { env, url } = ctx;
|
||||
|
||||
// 放行条件(三选一):
|
||||
// 1) 还没有任何管理员 —— 全新部署,随便谁先来都能初始化(此时也没有可被劫持的东西)
|
||||
// 2) 已经是管理员(带 token)
|
||||
// 3) 请求带了 ?force=<TOKEN_SECRET> —— 证明调用者掌握部署密钥,用于「忘了密码」的恢复
|
||||
// 注意:不能只判断"库里有没有站点"——先导入评论再 init 时站点已存在,会把管理员卡在外面。
|
||||
const adminRow = await env.DB.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1')
|
||||
.first<{ n: number }>()
|
||||
.catch(() => null);
|
||||
const adminCount = adminRow?.n ?? 0;
|
||||
|
||||
const force = url.searchParams.get('force') || '';
|
||||
const canForce = !!force && !!env.TOKEN_SECRET && force === env.TOKEN_SECRET;
|
||||
|
||||
if (adminCount > 0 && !ctx.user?.is_admin && !canForce) {
|
||||
return fail(
|
||||
403,
|
||||
'Already initialized: an admin already exists. ' +
|
||||
'用管理员 token 调用,或带 ?force=<TOKEN_SECRET>(用于重设密码)。',
|
||||
);
|
||||
}
|
||||
|
||||
const body = await readBody(ctx.req).catch(() => ({}) as Record<string, any>);
|
||||
const siteName = trimTo(body.site || env.SITE_DEFAULT, 120);
|
||||
const siteUrl = trimTo(body.site_url || env.SITE_URL, 255);
|
||||
|
||||
await findOrCreateSite(env, siteName, siteUrl);
|
||||
|
||||
const admins = await getAdminUsers(env);
|
||||
for (const a of admins) {
|
||||
const u = await findOrCreateUser(env, a.name, a.email);
|
||||
const pw =
|
||||
a.password && !a.password.startsWith('(') ? await hashPassword(a.password) : a.password;
|
||||
await env.DB.prepare(
|
||||
'UPDATE users SET is_admin = 1, password = ?, token_valid_from = ?, updated_at = ? WHERE id = ?',
|
||||
)
|
||||
.bind(pw, now(), now(), u.id)
|
||||
.run();
|
||||
}
|
||||
|
||||
const t = now();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ('site_default', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.bind(JSON.stringify(siteName), t)
|
||||
.run();
|
||||
|
||||
return ok({
|
||||
msg: 'Success',
|
||||
site: siteName,
|
||||
admins: admins.map((a) => a.email),
|
||||
reseted: adminCount > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// /api/feeds —— 订阅源 CRUD
|
||||
// 迁自 edgeone/functions/api/feeds.js
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, corsOptions, requireAuth, kvGetJson, kvPutJson } from '../../lib/rss/util';
|
||||
|
||||
export async function onRequest(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
switch (request.method) {
|
||||
case 'GET': {
|
||||
const data = await kvGetJson<{ feeds: unknown[] }>(env, 'feeds_config', { feeds: [] });
|
||||
return respond(data);
|
||||
}
|
||||
case 'POST': {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
|
||||
const data = await kvGetJson<{ feeds: any[] }>(env, 'feeds_config', { feeds: [] });
|
||||
const incoming = body.feeds && Array.isArray(body.feeds) ? body.feeds : [body];
|
||||
|
||||
const duplicates: string[] = [];
|
||||
for (const feed of incoming) {
|
||||
if (!feed.url) continue;
|
||||
const idx = data.feeds.findIndex((f) => f.url === feed.url);
|
||||
if (idx >= 0 && feed.url !== feed.oldUrl) duplicates.push(feed.url);
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
return respond({ error: '链接已存在,不允许重复添加', duplicates }, 409);
|
||||
}
|
||||
|
||||
for (const feed of incoming) {
|
||||
if (!feed.url) continue;
|
||||
if (feed.oldUrl) {
|
||||
const idx = data.feeds.findIndex((f) => f.url === feed.oldUrl);
|
||||
if (idx >= 0) {
|
||||
data.feeds[idx] = {
|
||||
...data.feeds[idx],
|
||||
url: feed.url,
|
||||
feedTitle: feed.feedTitle || data.feeds[idx].feedTitle,
|
||||
};
|
||||
} else {
|
||||
data.feeds.push({ url: feed.url, feedTitle: feed.feedTitle || '' });
|
||||
}
|
||||
} else {
|
||||
data.feeds.push({ url: feed.url, feedTitle: feed.feedTitle || '' });
|
||||
}
|
||||
}
|
||||
|
||||
await kvPutJson(env, 'feeds_config', data);
|
||||
return respond({ ok: true, total: data.feeds.length });
|
||||
}
|
||||
case 'DELETE': {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
const targetUrl = url.searchParams.get('url');
|
||||
if (!targetUrl) return respond({ error: 'url parameter required' }, 400);
|
||||
|
||||
const data = await kvGetJson<{ feeds: any[] }>(env, 'feeds_config', { feeds: [] });
|
||||
const before = data.feeds.length;
|
||||
data.feeds = data.feeds.filter((f) => f.url !== targetUrl);
|
||||
const removed = before - data.feeds.length;
|
||||
|
||||
await kvPutJson(env, 'feeds_config', data);
|
||||
return respond({ ok: true, removed, total: data.feeds.length });
|
||||
}
|
||||
default:
|
||||
return respond({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// /api/ai/greeting —— 随机问候语 + DeepSeek 词库扩充
|
||||
// 迁自 edgeone/functions/api/ai/greeting.js
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
|
||||
import { defaultPool, type Greeting } from '../../lib/rss/greetings';
|
||||
|
||||
function getTodayMMDD(): string {
|
||||
const now = new Date();
|
||||
return String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function getTimeSlot(): string {
|
||||
const h = new Date().getHours();
|
||||
const d = new Date().getDay();
|
||||
if (d === 0 || d === 6) {
|
||||
if (h < 22) return 'weekend';
|
||||
return 'night';
|
||||
}
|
||||
if (h >= 6 && h < 9) return 'weekday-morning';
|
||||
if (h >= 9 && h < 12) return 'morning';
|
||||
if (h >= 12 && h < 14) return 'noon';
|
||||
if (h >= 14 && h < 18) return 'afternoon';
|
||||
if (h >= 18 && h < 22) return 'evening';
|
||||
return 'night';
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
async function getPool(env: Env): Promise<Greeting[]> {
|
||||
const raw = await env.RSS_KV.get('greetings_pool');
|
||||
return raw ? (JSON.parse(raw) as Greeting[]) : defaultPool();
|
||||
}
|
||||
|
||||
async function callDeepSeek(apiKey: string, count: number): Promise<Greeting[]> {
|
||||
const today = getTodayMMDD();
|
||||
const slot = getTimeSlot();
|
||||
const slotNames: Record<string, string> = {
|
||||
'weekday-morning': '工作日早晨', morning: '上午', noon: '中午',
|
||||
afternoon: '下午', evening: '傍晚', night: '深夜', weekend: '周末',
|
||||
};
|
||||
|
||||
const prompt = `你是"优世界"博客的AI助手。请生成${count}条新的博客问候语,每条15字以内,语气轻松温暖幽默。
|
||||
当前场景:${slotNames[slot] || slot},日期 ${today}。
|
||||
返回纯JSON数组:[{"text":"问候语","time":"morning","holiday":null}]`;
|
||||
|
||||
const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 2048,
|
||||
temperature: 0.9,
|
||||
}),
|
||||
});
|
||||
const json = (await res.json()) as any;
|
||||
if (json.error) throw new Error(`DeepSeek API: ${json.error.message}`);
|
||||
|
||||
const text = json.choices?.[0]?.message?.content || '';
|
||||
const match = text.match(/\[[\s\S]*\]/);
|
||||
if (!match) throw new Error('AI 返回格式异常');
|
||||
const generated = JSON.parse(match[0]);
|
||||
if (!Array.isArray(generated)) throw new Error('AI 返回的不是数组');
|
||||
return generated.filter((g: Greeting) => g.text && g.text.length <= 20);
|
||||
}
|
||||
|
||||
export async function onRequest(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === 'GET') {
|
||||
const count = Math.min(Math.max(parseInt(url.searchParams.get('count') || '10'), 1), 50);
|
||||
const pool = await getPool(env);
|
||||
const today = getTodayMMDD();
|
||||
const slot = getTimeSlot();
|
||||
|
||||
const holidayMatches = pool.filter((g) => g.holiday === today);
|
||||
const slotMatches = pool.filter((g) => !g.holiday && g.time === slot);
|
||||
const genericMatches = pool.filter((g) => !g.holiday && !g.time);
|
||||
|
||||
let result = shuffle(holidayMatches).slice(0, Math.ceil(count * 0.3));
|
||||
result = result.concat(shuffle(slotMatches).slice(0, count - result.length));
|
||||
result = result.concat(shuffle(genericMatches).slice(0, count - result.length));
|
||||
return respond({ greetings: shuffle(result).slice(0, count) });
|
||||
}
|
||||
|
||||
if (request.method === 'POST') {
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
|
||||
if (body.action === 'config') {
|
||||
if (!body.deepseek_key) return respond({ error: '请提供 deepseek_key' }, 400);
|
||||
await env.RSS_KV.put('ai_config', JSON.stringify({ deepseek_key: body.deepseek_key }));
|
||||
return respond({ ok: true, message: 'DeepSeek API Key 已保存' });
|
||||
}
|
||||
|
||||
if (body.action === 'generate') {
|
||||
const count = Math.min(body.count || 10, 30);
|
||||
let apiKey = body.deepseek_key;
|
||||
if (!apiKey) {
|
||||
const configRaw = await env.RSS_KV.get('ai_config');
|
||||
if (configRaw) {
|
||||
try {
|
||||
apiKey = JSON.parse(configRaw).deepseek_key;
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!apiKey) return respond({ error: '请先配置 DeepSeek API Key' }, 400);
|
||||
|
||||
try {
|
||||
const generated = await callDeepSeek(apiKey, count);
|
||||
const pool = await getPool(env);
|
||||
const existing = new Set(pool.map((g) => g.text));
|
||||
let added = 0;
|
||||
for (const g of generated) {
|
||||
if (!existing.has(g.text)) {
|
||||
pool.push({ text: g.text, time: g.time || null, holiday: g.holiday || null });
|
||||
existing.add(g.text);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
await env.RSS_KV.put('greetings_pool', JSON.stringify(pool));
|
||||
return respond({ ok: true, generated: generated.length, added, total: pool.length });
|
||||
} catch (err) {
|
||||
return respond({ error: (err as Error).message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const pool = body.pool && Array.isArray(body.pool) ? body.pool : defaultPool();
|
||||
await env.RSS_KV.put('greetings_pool', JSON.stringify(pool));
|
||||
return respond({ ok: true, total: pool.length, message: '词库已更新' });
|
||||
}
|
||||
|
||||
return respond({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// /api/links —— 友链 CRUD
|
||||
// 迁自 edgeone/functions/api/links.js
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, corsOptions, requireAuth, kvGetJson, kvPutJson } from '../../lib/rss/util';
|
||||
|
||||
interface Link {
|
||||
name: string;
|
||||
url: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
rss?: string;
|
||||
hidden?: boolean;
|
||||
addedAt?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export async function onRequest(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
if (method === 'GET') {
|
||||
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
|
||||
const showAll = url.searchParams.get('all') === '1';
|
||||
// 历史数据里 /api/favicon 存的是相对地址 → 读取时补成绝对(博客域名下相对路径会 404)
|
||||
const apiBase = env.PUBLIC_API_BASE || 'https://api.200181.xyz';
|
||||
let links = data.links.map((l) => {
|
||||
const img = l.image || l.avatar || '';
|
||||
return { ...l, image: img.startsWith('/') ? apiBase + img : img };
|
||||
});
|
||||
if (!showAll) links = links.filter((l) => !l.hidden);
|
||||
return respond({ links });
|
||||
}
|
||||
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
|
||||
if (method === 'DELETE') {
|
||||
const targetUrl = url.searchParams.get('url');
|
||||
if (!targetUrl) return respond({ error: 'missing url param' }, 400);
|
||||
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
|
||||
data.links = data.links.filter((l) => l.url !== targetUrl);
|
||||
await kvPutJson(env, 'friend_links', data);
|
||||
return respond({ ok: true, links: data.links });
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
|
||||
|
||||
const incoming = body.links || (body.url ? [body] : []);
|
||||
if (incoming.length === 0) return respond({ error: 'empty body' }, 400);
|
||||
|
||||
const duplicates: string[] = [];
|
||||
for (const link of incoming) {
|
||||
if (!link.url) continue;
|
||||
const idx = data.links.findIndex((l) => l.url === link.url);
|
||||
if (idx >= 0 && link.url !== link.oldUrl) duplicates.push(link.url);
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
return respond({ error: '链接已存在,不允许重复添加', duplicates }, 409);
|
||||
}
|
||||
|
||||
for (const link of incoming) {
|
||||
if (!link.url) continue;
|
||||
const entry: Link = {
|
||||
name: link.name || link.title || '',
|
||||
url: link.url,
|
||||
image:
|
||||
link.image || link.avatar || `/api/favicon?url=${encodeURIComponent(link.url)}`,
|
||||
description: link.description || '',
|
||||
rss: link.rss || '',
|
||||
hidden: link.hidden || false,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
if (link.oldUrl) {
|
||||
const idx = data.links.findIndex((l) => l.url === link.oldUrl);
|
||||
if (idx >= 0) {
|
||||
entry.addedAt = data.links[idx].addedAt;
|
||||
data.links[idx] = entry;
|
||||
} else {
|
||||
data.links.push(entry);
|
||||
}
|
||||
} else {
|
||||
data.links.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
await kvPutJson(env, 'friend_links', data);
|
||||
return respond({ ok: true, links: data.links });
|
||||
}
|
||||
|
||||
return respond({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 友链自助申请
|
||||
// 公开提交 → KV link_applications(pending)→ 邮件提醒管理员;
|
||||
// 管理面板审核:approve → 写入 friend_links + 邮件告知申请人;
|
||||
// reject → 记录反馈意见 + 邮件告知申请人。
|
||||
// ============================================================================
|
||||
|
||||
interface LinkApplication {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
feed: string;
|
||||
email: string;
|
||||
/** 申请人自填的图标地址;为空时审核通过会自动用 /api/favicon 抓站点图标 */
|
||||
image?: string;
|
||||
description?: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
feedback?: string;
|
||||
appliedAt: string;
|
||||
reviewedAt?: string;
|
||||
}
|
||||
|
||||
function validHttpUrl(s: string): boolean {
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normUrl(s: string): string {
|
||||
return s.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export async function linkApply(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
if (request.method !== 'POST') return respond({ error: 'method not allowed' }, 405);
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
|
||||
const name = String(body.name || '').trim().slice(0, 60);
|
||||
const url = String(body.url || '').trim().slice(0, 300);
|
||||
const feed = String(body.feed || '').trim().slice(0, 300);
|
||||
const email = String(body.email || '').trim().slice(0, 120);
|
||||
const image = String(body.image || '').trim().slice(0, 300);
|
||||
const description = String(body.description || '').trim().slice(0, 200);
|
||||
|
||||
if (!name || !url || !feed || !email) return respond({ error: '名称、链接、订阅地址、邮箱均为必填' }, 400);
|
||||
if (!validHttpUrl(url)) return respond({ error: '站点链接格式不正确(需 http/https 开头)' }, 400);
|
||||
if (!validHttpUrl(feed)) return respond({ error: '订阅地址格式不正确(需 http/https 开头)' }, 400);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return respond({ error: '邮箱格式不正确' }, 400);
|
||||
if (image && !validHttpUrl(image)) return respond({ error: '图标链接格式不正确(需 http/https 开头,或留空自动获取)' }, 400);
|
||||
|
||||
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
|
||||
const links = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
|
||||
|
||||
if (links.links.some((l) => normUrl(l.url) === normUrl(url)))
|
||||
return respond({ error: '该站点已经是友链啦,无需重复申请' }, 409);
|
||||
if (st.apps.some((a) => normUrl(a.url) === normUrl(url) && a.status === 'pending'))
|
||||
return respond({ error: '该站点已有待审申请,请耐心等待审核结果' }, 409);
|
||||
|
||||
const app: LinkApplication = {
|
||||
id: 'la' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
name, url, feed, email, description, image,
|
||||
status: 'pending',
|
||||
appliedAt: new Date().toISOString(),
|
||||
};
|
||||
st.apps.unshift(app);
|
||||
st.apps = st.apps.slice(0, 200);
|
||||
await kvPutJson(env, 'link_applications', st);
|
||||
|
||||
// 邮件提醒管理员
|
||||
const { sendMail } = await import('../../lib/mail');
|
||||
await sendMail(env, {
|
||||
to: 'imql@qq.com',
|
||||
subject: `🔗 新的友链申请:${name}`,
|
||||
html:
|
||||
`<p><b>${name}</b> 提交了友链申请:</p>` +
|
||||
`<ul><li>链接:<a href="${url}">${url}</a></li>` +
|
||||
(image ? `<li>图标:<a href="${image}">${image}</a></li>` : `<li>图标:未填写(通过后自动抓取站点图标)</li>`) +
|
||||
`<li>订阅:${feed}</li><li>邮箱:${email}</li>` +
|
||||
(description ? `<li>描述:${description}</li>` : '') + `</ul>` +
|
||||
`<p><a href="https://api.200181.xyz/admin/">前往管理面板审核</a></p>`,
|
||||
});
|
||||
|
||||
return respond({ ok: true, message: '申请已提交,审核结果将邮件通知您' });
|
||||
}
|
||||
|
||||
export async function linkApplyList(request: Request, env: Env): Promise<Response> {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
|
||||
return respond({ apps: st.apps || [] });
|
||||
}
|
||||
|
||||
export async function linkApplyReview(request: Request, env: Env): Promise<Response> {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
|
||||
let body: { id?: string; action?: string; feedback?: string };
|
||||
try {
|
||||
body = (await request.json()) as { id?: string; action?: string; feedback?: string };
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const id = String(body.id || '');
|
||||
const action = body.action === 'approve' ? 'approve' : body.action === 'reject' ? 'reject' : '';
|
||||
if (!id || !action) return respond({ error: 'id and action required' }, 400);
|
||||
|
||||
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
|
||||
const app = st.apps.find((a) => a.id === id);
|
||||
if (!app) return respond({ error: '申请不存在' }, 404);
|
||||
if (app.status !== 'pending') return respond({ error: '该申请已处理过' }, 409);
|
||||
|
||||
app.status = action === 'approve' ? 'approved' : 'rejected';
|
||||
app.feedback = String(body.feedback || '').slice(0, 300);
|
||||
app.reviewedAt = new Date().toISOString();
|
||||
await kvPutJson(env, 'link_applications', st);
|
||||
|
||||
let mailOk = false;
|
||||
if (action === 'approve') {
|
||||
// 写入友链
|
||||
const links = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
|
||||
links.links.push({
|
||||
name: app.name,
|
||||
url: app.url,
|
||||
// 申请人没填图标 → 用站内 favicon 服务自动抓(页面/友圈直接用这个地址)
|
||||
image: app.image || `/api/favicon?url=${encodeURIComponent(app.url)}`,
|
||||
description: app.description || '',
|
||||
rss: app.feed,
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
await kvPutJson(env, 'friend_links', links);
|
||||
mailOk = await sendMailSafe(env, app.email,
|
||||
`✅ 你的友链申请已通过:${app.name}`,
|
||||
`<p>你的友链申请(<a href="${app.url}">${app.url}</a>)已审核通过,现已加进友链列表,感谢支持!</p>` +
|
||||
(app.feedback ? `<p>站长留言:${app.feedback}</p>` : ''));
|
||||
} else {
|
||||
mailOk = await sendMailSafe(env, app.email,
|
||||
`关于你的友链申请:${app.name}`,
|
||||
`<p>很抱歉,你的友链申请(<a href="${app.url}">${app.url}</a>)本次未能通过。</p>` +
|
||||
`<p>站长反馈:${app.feedback || '(未填写)'}</p>` +
|
||||
`<p>欢迎完善站点内容后再次申请。</p>`);
|
||||
}
|
||||
|
||||
return respond({ ok: true, mailOk });
|
||||
}
|
||||
|
||||
async function sendMailSafe(env: Env, to: string, subject: string, html: string): Promise<boolean> {
|
||||
try {
|
||||
const { sendMail } = await import('../../lib/mail');
|
||||
return await sendMail(env, { to, subject, html });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// /api/random-image —— 随机图
|
||||
// /api/artalk/commenter —— 代理 Artalk 查用户最新评论(已修 login 路径 bug)
|
||||
// 迁自 edgeone/functions/api/{random-image.js, artalk/commenter.js}
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
|
||||
|
||||
const FOLDER_PREFIX = 'img_folder:';
|
||||
const DATA_PREFIX = 'img_data:';
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = (filename || '').split('.').pop()?.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
||||
webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', ico: 'image/x-icon',
|
||||
};
|
||||
return map[ext || ''] || 'image/jpeg';
|
||||
}
|
||||
|
||||
export async function randomImage(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === 'GET') {
|
||||
const folder = url.searchParams.get('folder') || 'jiege';
|
||||
const meta = url.searchParams.get('meta');
|
||||
const list = url.searchParams.get('list');
|
||||
|
||||
if (list === '1') {
|
||||
const raw = await env.RSS_KV.get('img_folders');
|
||||
const folders = raw ? JSON.parse(raw) : [];
|
||||
return respond({ folders });
|
||||
}
|
||||
|
||||
const folderRaw = await env.RSS_KV.get(FOLDER_PREFIX + folder);
|
||||
const images: { name?: string; type?: string; url?: string }[] = folderRaw ? JSON.parse(folderRaw) : [];
|
||||
if (images.length === 0) return respond({ error: `文件夹 "${folder}" 为空或不存在` }, 404);
|
||||
|
||||
const idx = Math.floor(Math.random() * images.length);
|
||||
const img = images[idx];
|
||||
|
||||
if (meta === '1') {
|
||||
return respond({ name: img.name, type: img.type, url: img.url || null, folder, total: images.length, index: idx });
|
||||
}
|
||||
if (img.url && !img.name) {
|
||||
return new Response(null, { status: 302, headers: { Location: img.url } });
|
||||
}
|
||||
|
||||
const dataKey = DATA_PREFIX + folder + '/' + img.name;
|
||||
const base64 = await env.RSS_KV.get(dataKey);
|
||||
if (!base64) return respond({ error: `图片数据不存在: ${img.name}` }, 404);
|
||||
|
||||
const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
return new Response(binary, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': img.type || getContentType(img.name || ''),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'Content-Disposition': `inline; filename="${img.name}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'POST') {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
const folder = url.searchParams.get('folder') || 'jiege';
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
|
||||
const folderKey = FOLDER_PREFIX + folder;
|
||||
const folderRaw = await env.RSS_KV.get(folderKey);
|
||||
const images: any[] = folderRaw ? JSON.parse(folderRaw) : [];
|
||||
let added = 0;
|
||||
|
||||
if (body.files && Array.isArray(body.files)) {
|
||||
for (const f of body.files) {
|
||||
if (!f.name || !f.data) continue;
|
||||
if (images.find((i) => i.name === f.name)) continue;
|
||||
const type = f.type || getContentType(f.name);
|
||||
await env.RSS_KV.put(DATA_PREFIX + folder + '/' + f.name, f.data);
|
||||
images.push({ name: f.name, type });
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (body.urls && Array.isArray(body.urls)) {
|
||||
for (const item of body.urls) {
|
||||
const u = typeof item === 'string' ? item : item.url;
|
||||
if (!u) continue;
|
||||
if (!images.find((i) => (i.url || i.name) === u)) {
|
||||
images.push(typeof item === 'string' ? { url: u } : item);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added === 0) return respond({ error: '没有可添加的图片' }, 400);
|
||||
|
||||
await env.RSS_KV.put(folderKey, JSON.stringify(images));
|
||||
const foldersRaw = await env.RSS_KV.get('img_folders');
|
||||
const folders: string[] = foldersRaw ? JSON.parse(foldersRaw) : [];
|
||||
if (!folders.includes(folder)) {
|
||||
folders.push(folder);
|
||||
await env.RSS_KV.put('img_folders', JSON.stringify(folders));
|
||||
}
|
||||
return respond({ ok: true, folder, added, total: images.length });
|
||||
}
|
||||
|
||||
return respond({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
export async function artalkCommenter(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
|
||||
if (request.method === 'POST') {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const { name, email, password, site_name, api_url } = body;
|
||||
if (!name || !email || !password) return respond({ error: 'name, email, password 必填' }, 400);
|
||||
await env.RSS_KV.put(
|
||||
'artalk_config',
|
||||
JSON.stringify({ name, email, password, site_name: site_name || '优世界', api_url: api_url || 'https://artalk.usj.cc' }),
|
||||
);
|
||||
return respond({ ok: true, message: 'Artalk 配置已保存' });
|
||||
}
|
||||
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
const url = new URL(request.url);
|
||||
const targetEmail = url.searchParams.get('email');
|
||||
if (!targetEmail) return respond({ error: 'email 参数必填' }, 400);
|
||||
|
||||
const configRaw = await env.RSS_KV.get('artalk_config');
|
||||
if (!configRaw) return respond({ error: 'Artalk 未配置,请先 POST 保存 admin 账号' }, 400);
|
||||
const config = JSON.parse(configRaw);
|
||||
const { name, email, password, site_name, api_url } = config;
|
||||
|
||||
try {
|
||||
// 登录(★ 修正:Artalk 登录接口是 /user/access_token,不是 /login)
|
||||
const loginRes = await fetch(`${api_url}/api/v2/user/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
const loginJson = (await loginRes.json()) as { token?: string; msg?: string };
|
||||
if (!loginJson.token) {
|
||||
return respond({ error: 'Artalk 登录失败: ' + (loginJson.msg || 'unknown') }, 502);
|
||||
}
|
||||
const adminToken = loginJson.token;
|
||||
|
||||
const searchUrl = `${api_url}/api/v2/comments?scope=site&site_name=${encodeURIComponent(site_name)}&search=${encodeURIComponent(targetEmail)}&limit=1`;
|
||||
const commentRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${adminToken}` } });
|
||||
const commentJson = (await commentRes.json()) as { comments?: any[]; data?: any };
|
||||
|
||||
const comments = commentJson.comments || commentJson.data?.comments || commentJson.data || [];
|
||||
if (!Array.isArray(comments) || comments.length === 0) return respond({ nick: '', link: '' });
|
||||
|
||||
const last = comments[0];
|
||||
return respond({ nick: last.nick || '', link: last.link || '' });
|
||||
} catch (err) {
|
||||
return respond({ error: (err as Error).message }, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// /api/auth —— 验证 / 修改口令
|
||||
// /api/health —— 站点存活检测
|
||||
// 迁自 edgeone/functions/api/auth.js + health.js
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, corsOptions, requireAuth, kvGetJson } from '../../lib/rss/util';
|
||||
|
||||
export async function auth(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
|
||||
if (request.method === 'POST') {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const { oldPassword, newPassword } = body;
|
||||
if (oldPassword) {
|
||||
const current = (await env.RSS_KV.get('site_password')) || '';
|
||||
if (current && oldPassword !== current) return respond({ error: '当前口令错误' }, 403);
|
||||
}
|
||||
if (newPassword) {
|
||||
await env.RSS_KV.put('site_password', newPassword);
|
||||
} else if (newPassword === '') {
|
||||
await env.RSS_KV.delete('site_password');
|
||||
}
|
||||
return respond({ ok: true });
|
||||
}
|
||||
|
||||
const authed = await requireAuth(request, env);
|
||||
return respond({ ok: authed }, authed ? 200 : 401);
|
||||
}
|
||||
|
||||
async function checkSite(url: string): Promise<Record<string, unknown>> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 10000);
|
||||
const res = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: ctrl.signal,
|
||||
redirect: 'follow',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)' },
|
||||
});
|
||||
clearTimeout(t);
|
||||
return { url, status: res.status, ok: res.ok, latency: Date.now() - start };
|
||||
} catch (err) {
|
||||
return {
|
||||
url,
|
||||
status: 0,
|
||||
ok: false,
|
||||
latency: Date.now() - start,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function health(request: Request, env: Env): Promise<Response> {
|
||||
const reqUrl = new URL(request.url);
|
||||
const targetUrl = reqUrl.searchParams.get('url');
|
||||
|
||||
if (targetUrl) {
|
||||
return respond(await checkSite(targetUrl));
|
||||
}
|
||||
|
||||
const data = await kvGetJson<{ feeds: { url: string }[] }>(env, 'feeds_config', { feeds: [] });
|
||||
const feeds = data.feeds || [];
|
||||
if (feeds.length === 0) return respond({ results: [], total: 0 });
|
||||
|
||||
const results: Record<string, unknown>[] = [];
|
||||
const concurrency = 5;
|
||||
for (let i = 0; i < feeds.length; i += concurrency) {
|
||||
const batch = feeds.slice(i, i + concurrency);
|
||||
const batchResults = await Promise.all(batch.map((f) => checkSite(f.url)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const alive = results.filter((r) => r.ok).length;
|
||||
const dead = results.filter((r) => !r.ok).length;
|
||||
|
||||
return respond({
|
||||
timestamp: new Date().toISOString(),
|
||||
total: results.length,
|
||||
alive,
|
||||
dead,
|
||||
results,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// /api/results —— 最新 RSS 聚合 JSON(按博客分组)
|
||||
// /api/articles —— 按时间排序的文章列表
|
||||
// 迁自 edgeone/functions/api/results.js + articles.js
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, kvGetJson } from '../../lib/rss/util';
|
||||
|
||||
interface FeedData {
|
||||
name: string;
|
||||
siteUrl: string;
|
||||
favicon: string;
|
||||
articles: unknown[];
|
||||
}
|
||||
|
||||
interface Latest {
|
||||
timestamp: string | null;
|
||||
total: number;
|
||||
feeds: FeedData[];
|
||||
}
|
||||
|
||||
export async function results(request: Request, env: Env): Promise<Response> {
|
||||
const data = await kvGetJson<Latest>(env, 'latest', { timestamp: null, total: 0, feeds: [] });
|
||||
const url = new URL(request.url);
|
||||
const feedFilter = url.searchParams.get('feed');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '0');
|
||||
|
||||
if (feedFilter) data.feeds = data.feeds.filter((f) => f.name === feedFilter);
|
||||
if (limit > 0) data.feeds = data.feeds.map((f) => ({ ...f, articles: f.articles.slice(0, limit) }));
|
||||
|
||||
return respond(data, 200);
|
||||
}
|
||||
|
||||
export async function articles(request: Request, env: Env): Promise<Response> {
|
||||
const data = await kvGetJson<Latest>(env, 'latest', { timestamp: null, total: 0, feeds: [] });
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50');
|
||||
|
||||
const flat: any[] = [];
|
||||
for (const feed of data.feeds || []) {
|
||||
for (const article of feed.articles || []) {
|
||||
const a = article as any;
|
||||
flat.push({
|
||||
title: a.title,
|
||||
link: a.link,
|
||||
pubDate: a.pubDate,
|
||||
author: a.author || feed.name,
|
||||
feedName: feed.name,
|
||||
siteUrl: feed.siteUrl,
|
||||
// 源里没抓到图标时,直接用站内 favicon 服务(会返回真实图标或字母 SVG,绝不是灰头像)。
|
||||
// 用 request 的 origin 拼绝对地址 —— 友圈页在 usj.cc 上,相对路径 /api/* 会 404。
|
||||
favicon: (() => {
|
||||
const raw = feed.favicon || '';
|
||||
// 历史数据里存的是相对路径 → 补成绝对(否则在博客域名下会 404)
|
||||
if (raw) return raw.startsWith('/') ? url.origin + raw : raw;
|
||||
return feed.siteUrl ? `${url.origin}/api/favicon?url=${encodeURIComponent(feed.siteUrl)}` : '';
|
||||
})(),
|
||||
});
|
||||
}
|
||||
}
|
||||
flat.sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime());
|
||||
|
||||
return respond({ timestamp: data.timestamp, total: flat.length, articles: flat.slice(0, limit) }, 200);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// /api/favicon —— 自动发现并返回站点 favicon
|
||||
// /api/update —— 接收抓取结果写入 KV(scheduled 已内置,此端点保留兼容外部上报)
|
||||
// /api/proxy —— RSS 抓取代理(EdgeOne 版迁过来;注意本 Worker 在境外,等价于原 EdgeOne)
|
||||
// 迁自 edgeone/functions/api/{favicon,update,proxy}.js
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, requireAuth } from '../../lib/rss/util';
|
||||
import { notifyAdmin } from '../../lib/admin-notify';
|
||||
import { checkProxyHealth, notifyProxyDown } from '../../lib/rss/proxy-health';
|
||||
import { kvGetJson, kvPutJson } from '../../lib/rss/util';
|
||||
|
||||
const CACHE_TTL = 604800;
|
||||
|
||||
function resolveUrl(href: string, baseUrl: string): string | null {
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
||||
try {
|
||||
return new URL(href, baseUrl).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverFaviconUrl(siteUrl: string): Promise<string | null> {
|
||||
if (!/^https?:\/\//i.test(siteUrl)) siteUrl = 'https://' + siteUrl;
|
||||
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 12000);
|
||||
const res = await fetch(siteUrl, {
|
||||
signal: ctrl.signal,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
|
||||
Accept: 'text/html,application/xhtml+xml,*/*',
|
||||
},
|
||||
});
|
||||
clearTimeout(t);
|
||||
const html = await res.text();
|
||||
|
||||
const iconPatterns = [
|
||||
/<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i,
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i,
|
||||
/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/i,
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']apple-touch-icon["']/i,
|
||||
];
|
||||
for (const pattern of iconPatterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match) return resolveUrl(match[1].trim(), siteUrl);
|
||||
}
|
||||
|
||||
const urlObj = new URL(siteUrl);
|
||||
const defaultFavicon = `${urlObj.protocol}//${urlObj.host}/favicon.ico`;
|
||||
try {
|
||||
const ctrl2 = new AbortController();
|
||||
const t2 = setTimeout(() => ctrl2.abort(), 8000);
|
||||
const check = await fetch(defaultFavicon, { method: 'HEAD', signal: ctrl2.signal, redirect: 'follow' });
|
||||
clearTimeout(t2);
|
||||
const ct = (check.headers.get('content-type') || '').toLowerCase();
|
||||
// 必须确认返回的真是图片:Typecho/WordPress 等会把 404 页面以 200 + text/html 返回
|
||||
if (check.ok && (ct.startsWith('image/') || ct.includes('octet-stream'))) return defaultFavicon;
|
||||
} catch {
|
||||
/* 继续走兜底 */
|
||||
}
|
||||
// favicon.ico 不通或返回的不是图片 → 兜底第三方 favicon 服务(国内可达),
|
||||
// 避免友圈卡片大面积落回灰色默认头像
|
||||
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
|
||||
} catch {
|
||||
try {
|
||||
const urlObj = new URL(siteUrl);
|
||||
// 同上:抓取失败兜底第三方服务
|
||||
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function favicon(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const siteUrl = url.searchParams.get('url');
|
||||
if (!siteUrl) return respond({ error: 'url parameter required' }, 400);
|
||||
|
||||
const bodyKey = 'favicon:body:v2:' + siteUrl;
|
||||
|
||||
// 一级缓存:图片本体直接存 KV(base64)——命中后零外站回源,响应最快
|
||||
const cached = await env.RSS_KV.get(bodyKey);
|
||||
if (cached) {
|
||||
const [meta, b64] = cached.split('|');
|
||||
const body = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': meta,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 失败短缓存:抓取失败的站点 1 小时内不再反复打外站
|
||||
const missKey = 'favicon:miss:' + siteUrl;
|
||||
if (await env.RSS_KV.get(missKey)) {
|
||||
return letterSvg(siteUrl);
|
||||
}
|
||||
|
||||
if (url.searchParams.get('meta')) {
|
||||
const u = await discoverFaviconUrl(siteUrl);
|
||||
return respond({ url: u });
|
||||
}
|
||||
|
||||
// 发现 + 下载图片本体(兼容读旧版 URL 字符串缓存:存量友链的发现结果都在旧 key 里)
|
||||
let faviconUrl: string | null = await env.RSS_KV.get('favicon:' + siteUrl).catch(() => null);
|
||||
if (!faviconUrl) {
|
||||
try {
|
||||
faviconUrl = await discoverFaviconUrl(siteUrl);
|
||||
if (faviconUrl) await env.RSS_KV.put('favicon:' + siteUrl, faviconUrl, { expirationTtl: CACHE_TTL });
|
||||
} catch {
|
||||
faviconUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (faviconUrl) {
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 10000);
|
||||
const imgRes = await fetch(faviconUrl, {
|
||||
signal: ctrl.signal,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (imgRes.ok) {
|
||||
const rawCt = (imgRes.headers.get('content-type') || '').toLowerCase();
|
||||
// 只接受真正的图片(含 octet-stream 的 .ico);HTML/文本一律视为抓取失败
|
||||
if (!rawCt.startsWith('image/') && !rawCt.includes('octet-stream')) {
|
||||
throw new Error('favicon not an image: ' + rawCt);
|
||||
}
|
||||
const contentType = rawCt || 'image/x-icon';
|
||||
const buf = await imgRes.arrayBuffer();
|
||||
// 只缓存小于 300KB 的图(KV 单值上限 25MB,异常大图不占空间)
|
||||
if (buf.byteLength <= 307200) {
|
||||
let bin = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
|
||||
}
|
||||
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
|
||||
}
|
||||
return new Response(buf, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 落到第三方兜底 */
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底链:第三方 favicon 服务代理返回(仍尝试写缓存,下次直接命中)
|
||||
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0];
|
||||
const fallback = `https://api.iowen.cn/favicon/${host}.png`;
|
||||
try {
|
||||
const ctrl3 = new AbortController();
|
||||
const t3 = setTimeout(() => ctrl3.abort(), 10000);
|
||||
const fRes = await fetch(fallback, { signal: ctrl3.signal, redirect: 'follow' });
|
||||
clearTimeout(t3);
|
||||
if (fRes.ok) {
|
||||
const contentType = fRes.headers.get('content-type') || 'image/png';
|
||||
const buf = await fRes.arrayBuffer();
|
||||
let bin = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
|
||||
}
|
||||
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
|
||||
return new Response(buf, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 彻底失败 */
|
||||
}
|
||||
|
||||
// 记一次失败,1 小时内不再打外站;返回自绘字母 SVG 兜底
|
||||
// (零依赖永远可用,比第三方服务和灰色默认头像都体面)
|
||||
await env.RSS_KV.put(missKey, '1', { expirationTtl: 3600 });
|
||||
return letterSvg(siteUrl);
|
||||
}
|
||||
|
||||
/** 站点首字母占位图(永远 200 的最后兜底) */
|
||||
function letterSvg(siteUrl: string): Response {
|
||||
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0].replace(/^www\./, '');
|
||||
const letter = (host[0] || '?').toUpperCase();
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">` +
|
||||
`<rect width="64" height="64" rx="12" fill="#2f9e63"/>` +
|
||||
`<text x="32" y="43" font-family="Arial,sans-serif" font-size="32" font-weight="600" fill="#ffffff" text-anchor="middle">${letter}</text></svg>`;
|
||||
return new Response(svg, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function update(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
try {
|
||||
const data = await request.json();
|
||||
await env.RSS_KV.put('latest', JSON.stringify(data), { expirationTtl: 604800 });
|
||||
return respond({ ok: true });
|
||||
} catch (err) {
|
||||
return respond({ error: (err as Error).message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/notify-mail —— 通用提醒邮件(给自己发一封)
|
||||
* body: { subject, text, dedupeKey?, minGapHours? }
|
||||
* 鉴权:requireAuth;收件人固定 MAIL_ADMIN,不能指定别人。
|
||||
*/
|
||||
export async function notifyMail(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
|
||||
const result = await notifyAdmin(env, {
|
||||
subject: String(body?.subject || '提醒'),
|
||||
text: String(body?.text || ''),
|
||||
dedupeKey: body?.dedupeKey,
|
||||
minGapHours: Number(body?.minGapHours) || 0,
|
||||
});
|
||||
if (result.error) return respond({ error: result.error }, 400);
|
||||
return respond(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/proxy-health —— 国内代理(scfapi.usj.cc)体检
|
||||
* ?notify=1 时若不健康还会给站长发一封提醒邮件(48h 节流)。
|
||||
*/
|
||||
export async function proxyHealth(request: Request, env: Env): Promise<Response> {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
const health = await checkProxyHealth(env);
|
||||
let mailed: unknown = null;
|
||||
if (!health.ok && new URL(request.url).searchParams.get('notify') === '1') {
|
||||
mailed = await notifyProxyDown(env, health);
|
||||
}
|
||||
return respond({ ...health, mailed });
|
||||
}
|
||||
|
||||
export async function proxy(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' } });
|
||||
}
|
||||
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const { url, timeout = 15000 } = body;
|
||||
if (!url) return respond({ error: 'url required' }, 400);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const targetRes = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml,application/json;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const text = await targetRes.text();
|
||||
const ct = targetRes.headers.get('content-type') || '';
|
||||
return respond({ ok: true, status: targetRes.status, contentType: ct, body: text });
|
||||
} catch (err) {
|
||||
return respond({ ok: false, error: (err as Error).message }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/cron/run?offset=0&limit=35 —— 手动触发一批抓取(token 鉴权)。
|
||||
* 免费版 subrequest 限制下用于补跑/首灌数据。
|
||||
*/
|
||||
export async function cronRun(request: Request, env: Env): Promise<Response> {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
const url = new URL(request.url);
|
||||
const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10) || 0, 0);
|
||||
const limit = Math.max(parseInt(url.searchParams.get('limit') || '35', 10) || 0, 0);
|
||||
// dry=1:只跑抓取、不写 KV、不发通知 —— 用于安全地测耗时/成功率(不会污染线上数据)
|
||||
const dry = url.searchParams.get('dry') === '1';
|
||||
// rotate=1:从 KV 游标继续抓(模拟定时任务的行为)
|
||||
const rotate = url.searchParams.get('rotate') === '1';
|
||||
// 抓取主要是 IO 等待(不计 CPU),HTTP 触发也没有时长上限,35 个源串行能跑完
|
||||
const { runCron } = await import('../../lib/rss/cron');
|
||||
const stats = await runCron(env, { offset, limit, dry, rotate });
|
||||
return respond({ ok: true, offset, limit, stats });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 部署状态(国内中转机同步监控)
|
||||
// CI 的 build job 在 COS 上传后上报 phase=built;中转机 sync.sh 同步完成后
|
||||
// 上报 phase=synced。GET 公开可查,对比两者就知道国内线路部署完了没。
|
||||
// ============================================================================
|
||||
|
||||
interface DeployPhase {
|
||||
hash: string;
|
||||
phase: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export async function deployStatusGet(_request: Request, env: Env): Promise<Response> {
|
||||
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
|
||||
const builds = (st.builds || []).slice(0, 5);
|
||||
const built = builds.find((b) => b.phase === 'built');
|
||||
const synced = builds.find((b) => b.phase === 'synced');
|
||||
let state = 'unknown';
|
||||
if (built && synced && synced.hash === built.hash) state = 'synced';
|
||||
else if (built && !synced) state = 'syncing';
|
||||
else if (synced && !built) state = 'synced'; // 未配置 CI 上报时,以中转机记录为准
|
||||
else if (built && synced && synced.hash !== built.hash) state = 'syncing';
|
||||
return respond({
|
||||
state, // synced = 国内已生效;syncing = 构建完成等待中转机同步
|
||||
latest_build: built || null,
|
||||
latest_sync: synced || null,
|
||||
builds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deployStatusPost(request: Request, env: Env): Promise<Response> {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
let body: { hash?: string; phase?: string } = {};
|
||||
try {
|
||||
body = (await request.json()) as { hash?: string; phase?: string };
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const hash = String(body.hash || '').slice(0, 64);
|
||||
const phase = String(body.phase || '').slice(0, 20);
|
||||
if (!hash || !phase) return respond({ error: 'hash and phase required' }, 400);
|
||||
|
||||
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
|
||||
st.builds = (st.builds || []).filter((b) => !(b.phase === phase && b.hash === hash));
|
||||
st.builds.unshift({ hash, phase, at: new Date().toISOString() });
|
||||
st.builds = st.builds.slice(0, 20);
|
||||
await kvPutJson(env, 'deploy_status', st);
|
||||
return respond({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* POST /api/deploy-notify —— 中转机同步完成后发部署完成邮件(token 鉴权)。
|
||||
* 与 Gitea Actions 的构建通知配套:构建通知告诉你"构建好了",
|
||||
* 这封邮件告诉你"国内线路真正生效了"。
|
||||
*/
|
||||
export async function deployNotify(request: Request, env: Env): Promise<Response> {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
let body: { hash?: string } = {};
|
||||
try {
|
||||
body = (await request.json()) as { hash?: string };
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
const hash = String(body.hash || '').slice(0, 12) || '未知';
|
||||
const { sendMail } = await import('../../lib/mail');
|
||||
const okMail = await sendMail(env, {
|
||||
to: 'imql@qq.com',
|
||||
subject: `✅ 博客国内线路部署完成 ${hash}`,
|
||||
text: `构建产物 ${hash} 已同步到又拍云并刷新多吉云 CDN,国内线路已生效。\n时间:${new Date().toISOString()}`,
|
||||
html:
|
||||
`<p>构建产物 <code>${hash}</code> 已同步到又拍云并刷新多吉云 CDN,<b>国内线路已生效</b>。</p>` +
|
||||
`<p style="color:#8a8e98">时间:${new Date().toISOString()} · artalk-cf deploy monitor</p>`,
|
||||
});
|
||||
return respond({ ok: okMail });
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// /api/wechat-material —— 同步文章到微信公众号草稿箱
|
||||
// 迁自 edgeone/functions/api/wechat-material.js(319 行,最复杂的端点)
|
||||
// 调用链:token → material/add_material(封面) → media/uploadimg(文内图) → draft/add
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
|
||||
|
||||
const WECHAT_CONFIG_KEY = 'wechat_config';
|
||||
const WECHAT_TOKEN_KEY = 'wechat_token';
|
||||
|
||||
async function getAccessToken(env: Env, appId: string, appSecret: string): Promise<string> {
|
||||
const cached = await env.RSS_KV.get(WECHAT_TOKEN_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const data = JSON.parse(cached);
|
||||
if (data.expiresAt > Date.now()) return data.token;
|
||||
} catch {
|
||||
// 忽略,重新获取
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`,
|
||||
);
|
||||
const json = (await res.json()) as { errcode?: number; errmsg?: string; access_token?: string; expires_in?: number };
|
||||
if (json.errcode) throw new Error(`获取 access_token 失败: ${json.errmsg} (code=${json.errcode})`);
|
||||
|
||||
const ttl = Math.max((json.expires_in || 7200) - 300, 60);
|
||||
await env.RSS_KV.put(
|
||||
WECHAT_TOKEN_KEY,
|
||||
JSON.stringify({ token: json.access_token, expiresAt: Date.now() + ttl * 1000 }),
|
||||
);
|
||||
return json.access_token!;
|
||||
}
|
||||
|
||||
function buildMultipartBody(fieldName: string, filename: string, data: ArrayBuffer, contentType: string): { body: Uint8Array; boundary: string } {
|
||||
const boundary = '----WechatMaterial' + Date.now();
|
||||
const encoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [];
|
||||
parts.push(encoder.encode(`--${boundary}\r\n`));
|
||||
parts.push(encoder.encode(`Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\n`));
|
||||
parts.push(encoder.encode(`Content-Type: ${contentType}\r\n\r\n`));
|
||||
parts.push(new Uint8Array(data));
|
||||
parts.push(encoder.encode('\r\n'));
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
const totalLen = parts.reduce((acc, p) => acc + p.length, 0);
|
||||
const body = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
body.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return { body, boundary };
|
||||
}
|
||||
|
||||
async function uploadThumbImage(accessToken: string, imageUrl: string): Promise<string> {
|
||||
const downloadRes = await fetch(imageUrl);
|
||||
if (!downloadRes.ok) throw new Error(`下载封面图失败: HTTP ${downloadRes.status} — ${imageUrl}`);
|
||||
const buffer = await downloadRes.arrayBuffer();
|
||||
const contentType = downloadRes.headers.get('content-type') || 'image/jpeg';
|
||||
const ext = contentType.split('/')[1] || 'jpg';
|
||||
|
||||
const { body, boundary } = buildMultipartBody('media', `cover.${ext}`, buffer, contentType);
|
||||
const res = await fetch(
|
||||
`https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${accessToken}&type=image`,
|
||||
{ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body },
|
||||
);
|
||||
const json = (await res.json()) as { errcode?: number; errmsg?: string; media_id?: string };
|
||||
if (json.errcode) throw new Error(`上传封面图失败: ${json.errmsg} (code=${json.errcode})`);
|
||||
return json.media_id!;
|
||||
}
|
||||
|
||||
async function uploadContentImage(accessToken: string, imageUrl: string): Promise<string> {
|
||||
const downloadRes = await fetch(imageUrl);
|
||||
if (!downloadRes.ok) throw new Error(`下载文内图片失败: HTTP ${downloadRes.status} — ${imageUrl}`);
|
||||
const buffer = await downloadRes.arrayBuffer();
|
||||
const contentType = downloadRes.headers.get('content-type') || 'image/jpeg';
|
||||
const ext = contentType.split('/')[1] || 'jpg';
|
||||
|
||||
const { body, boundary } = buildMultipartBody('media', `content.${ext}`, buffer, contentType);
|
||||
const res = await fetch(
|
||||
`https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${accessToken}`,
|
||||
{ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body },
|
||||
);
|
||||
const json = (await res.json()) as { errcode?: number; errmsg?: string; url?: string };
|
||||
if (json.errcode) throw new Error(`上传文内图片失败: ${json.errmsg} (code=${json.errcode})`);
|
||||
return json.url!;
|
||||
}
|
||||
|
||||
async function addDraft(accessToken: string, articles: unknown[]): Promise<string> {
|
||||
const res = await fetch(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${accessToken}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ articles }),
|
||||
});
|
||||
const json = (await res.json()) as { errcode?: number; errmsg?: string; media_id?: string };
|
||||
if (json.errcode) throw new Error(`新增草稿失败: ${json.errmsg} (code=${json.errcode})`);
|
||||
return json.media_id!;
|
||||
}
|
||||
|
||||
export async function onRequest(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') return corsOptions();
|
||||
|
||||
if (request.method === 'GET') {
|
||||
const configRaw = await env.RSS_KV.get(WECHAT_CONFIG_KEY);
|
||||
if (!configRaw) return respond({ configured: false, message: '尚未配置微信公众号凭证' });
|
||||
try {
|
||||
const config = JSON.parse(configRaw);
|
||||
return respond({ configured: true, appId: config.appId, hasSecret: !!config.appSecret });
|
||||
} catch {
|
||||
return respond({ configured: false, message: '配置解析失败' });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'POST') {
|
||||
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return respond({ error: 'invalid json' }, 400);
|
||||
}
|
||||
|
||||
const { appId, appSecret, articles } = body;
|
||||
if (!articles || !Array.isArray(articles) || articles.length === 0) {
|
||||
return respond({ error: 'articles 数组不能为空' }, 400);
|
||||
}
|
||||
|
||||
let wechatAppId = appId;
|
||||
let wechatAppSecret = appSecret;
|
||||
if ((!wechatAppId || !wechatAppSecret)) {
|
||||
const configRaw = await env.RSS_KV.get(WECHAT_CONFIG_KEY);
|
||||
if (configRaw) {
|
||||
try {
|
||||
const config = JSON.parse(configRaw);
|
||||
if (!wechatAppId) wechatAppId = config.appId;
|
||||
if (!wechatAppSecret) wechatAppSecret = config.appSecret;
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!wechatAppId || !wechatAppSecret) {
|
||||
return respond({ error: '请提供微信 appId 和 appSecret' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
if (appId && appSecret) {
|
||||
await env.RSS_KV.put(WECHAT_CONFIG_KEY, JSON.stringify({ appId, appSecret }));
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken(env, wechatAppId, wechatAppSecret);
|
||||
const processed: Record<string, unknown>[] = [];
|
||||
|
||||
for (const article of articles) {
|
||||
const item = { ...article };
|
||||
if (!item.title) throw new Error('文章 title 不能为空');
|
||||
|
||||
if (item.thumb_media_url && !item.thumb_media_id) {
|
||||
item.thumb_media_id = await uploadThumbImage(accessToken, item.thumb_media_url);
|
||||
}
|
||||
delete item.thumb_media_url;
|
||||
if (!item.thumb_media_id) throw new Error(`文章「${item.title}」缺少 thumb_media_id 或 thumb_media_url`);
|
||||
|
||||
if (item.content_image_urls && Array.isArray(item.content_image_urls)) {
|
||||
for (const img of item.content_image_urls) {
|
||||
const wxUrl = await uploadContentImage(accessToken, img.original_url);
|
||||
item.content = item.content.split(img.original_url).join(wxUrl);
|
||||
}
|
||||
}
|
||||
delete item.content_image_urls;
|
||||
|
||||
const allowed = [
|
||||
'title', 'author', 'digest', 'content', 'content_source_url',
|
||||
'thumb_media_id', 'show_cover_pic', 'need_open_comment',
|
||||
'only_fans_can_comment', 'article_type',
|
||||
];
|
||||
const draft: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (item[key] !== undefined) draft[key] = item[key];
|
||||
}
|
||||
if (draft.show_cover_pic == null) draft.show_cover_pic = 1;
|
||||
if (!draft.content) draft.content = '';
|
||||
processed.push(draft);
|
||||
}
|
||||
|
||||
const mediaId = await addDraft(accessToken, processed);
|
||||
return respond({ ok: true, media_id: mediaId, message: `已同步 ${processed.length} 篇文章到公众号草稿箱` });
|
||||
} catch (err) {
|
||||
return respond({ error: (err as Error).message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return respond({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Env, UserRow } from '../types';
|
||||
import { findUserByEmail, findUserByName, findUserByNameEmail, rateLimit } from '../lib/db';
|
||||
import { cookNotify, cookUser } from '../lib/cook';
|
||||
import { isAdminByNameEmail, signToken, verifyPassword } from '../lib/session';
|
||||
import { fail, getClientIP, isEmail, now, ok, okMsg, qp, readBody, trimTo } from '../lib/util';
|
||||
import type { Ctx } from '../router';
|
||||
|
||||
// ==================================================================== GET /user
|
||||
|
||||
export async function userInfo(ctx: Ctx): Promise<Response> {
|
||||
const { env, url, user } = ctx;
|
||||
const name = qp(url, 'name');
|
||||
const email = qp(url, 'email');
|
||||
|
||||
// 官方语义:name+email 即凭证(sidebar 用这种),email 是调用者已知的
|
||||
// 信息,不构成泄露;token 优先。
|
||||
let target: UserRow | null = user;
|
||||
if (!target && name && email) {
|
||||
target = await findUserByNameEmail(env, name, email);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return ok({ user: null, is_login: false, notifies: [], notifies_count: 0 });
|
||||
}
|
||||
|
||||
const notifies = await env.DB.prepare(
|
||||
`SELECT n.*, c.page_key FROM notifies n
|
||||
LEFT JOIN comments c ON c.id = n.comment_id
|
||||
WHERE n.user_id = ? AND n.is_read = 0 ORDER BY n.created_at DESC LIMIT 20`,
|
||||
)
|
||||
.bind(target.id)
|
||||
.all<{
|
||||
id: number;
|
||||
user_id: number;
|
||||
comment_id: number;
|
||||
is_read: number;
|
||||
is_emailed: number;
|
||||
page_key: string | null;
|
||||
}>();
|
||||
|
||||
const list = (notifies.results ?? []).map((n) =>
|
||||
cookNotify(n, notifyAnchor(n.page_key, n.comment_id)),
|
||||
);
|
||||
|
||||
return ok({
|
||||
user: cookUser(target),
|
||||
is_login: !!user && user.id === target.id,
|
||||
notifies: list,
|
||||
notifies_count: list.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** 官方 read_link 语义:跳回文章页定位到那条评论 */
|
||||
function notifyAnchor(pageKey: string | null, commentId: number): string {
|
||||
const key = (pageKey || '').trim();
|
||||
return key ? `${key}?atk_comment=${commentId}` : `/admin/comments?comment_id=${commentId}`;
|
||||
}
|
||||
|
||||
// =================================================================== POST /user
|
||||
|
||||
export async function userUpdate(ctx: Ctx): Promise<Response> {
|
||||
const { env, req, user } = ctx;
|
||||
const body = await readBody(req);
|
||||
|
||||
const name = trimTo(body.name || '', 60).trim();
|
||||
const email = trimTo(body.email || '', 120).trim();
|
||||
const link = trimTo(body.link || '', 255).trim();
|
||||
|
||||
if (!name || !email) return fail(400, 'name and email are required');
|
||||
if (!isEmail(email)) return fail(400, 'Invalid Email');
|
||||
|
||||
// 必须登录且只能改自己;未登录时代官方的 name+email 匹配也允许
|
||||
let target: UserRow | null = user;
|
||||
if (!target) {
|
||||
target = await findUserByNameEmail(env, name, email);
|
||||
if (!target) return fail(401, 'Login required');
|
||||
} else if (target.name !== name || target.email !== email) {
|
||||
return fail(403, 'Forbidden');
|
||||
}
|
||||
|
||||
await env.DB.prepare('UPDATE users SET name = ?, email = ?, link = ?, updated_at = ? WHERE id = ?')
|
||||
.bind(name, email, link, now(), target.id)
|
||||
.run();
|
||||
|
||||
const updated = await env.DB.prepare('SELECT * FROM users WHERE id = ?')
|
||||
.bind(target.id)
|
||||
.first<UserRow>();
|
||||
|
||||
return ok({ user: cookUser(updated as UserRow) });
|
||||
}
|
||||
|
||||
// ========================================================== GET /user/status
|
||||
|
||||
export async function userStatus(ctx: Ctx): Promise<Response> {
|
||||
const { env, url, user } = ctx;
|
||||
const name = qp(url, 'name');
|
||||
const email = qp(url, 'email');
|
||||
|
||||
if (user) {
|
||||
return ok({ is_admin: !!user.is_admin, is_login: true });
|
||||
}
|
||||
if (name && email) {
|
||||
return ok({ is_admin: await isAdminByNameEmail(env, name, email), is_login: false });
|
||||
}
|
||||
return ok({ is_admin: false, is_login: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录标识:可能是邮箱(user@x.com),也可能是用户名(admin)。
|
||||
* 后台登录框只有一个「邮箱」字段,用户很可能把账号名直接填进去,
|
||||
* 所以这里对两种写法都做匹配,避免"账号明明是对的却登不上"。
|
||||
*/
|
||||
async function findLoginCandidates(
|
||||
env: Env,
|
||||
identifier: string,
|
||||
name?: string,
|
||||
): Promise<UserRow[]> {
|
||||
const id = (identifier || '').trim();
|
||||
const nm = (name || '').trim();
|
||||
|
||||
if (id && id.includes('@')) {
|
||||
const byEmail = await findUserByEmail(env, id);
|
||||
return nm ? byEmail.filter((u) => u.name === nm) : byEmail;
|
||||
}
|
||||
|
||||
// 不是邮箱形态 → 当成用户名;也顺带用 name 参数兜一下
|
||||
const names = [...new Set([id, nm].filter(Boolean))];
|
||||
const out: UserRow[] = [];
|
||||
for (const n of names) out.push(...(await findUserByName(env, n)));
|
||||
// 万一有人把用户名写成了邮箱格式的账号,再补一次邮箱匹配
|
||||
if (!out.length && id) out.push(...(await findUserByEmail(env, id)));
|
||||
return out;
|
||||
}
|
||||
|
||||
// =================================================== POST /user/access_token
|
||||
|
||||
export async function userAccessToken(ctx: Ctx): Promise<Response> {
|
||||
const { env, req } = ctx;
|
||||
const ip = getClientIP(req);
|
||||
|
||||
if (!(await rateLimit(env, `login:${ip}`, 10, 300))) {
|
||||
return fail(429, 'Too many login attempts, try again later');
|
||||
}
|
||||
|
||||
const body = await readBody(req);
|
||||
const identifier = trimTo(body.email || body.username || body.account || '', 120).trim();
|
||||
const name = trimTo(body.name || '', 60).trim();
|
||||
const password = String(body.password ?? '');
|
||||
|
||||
if (!identifier && !name) return fail(400, '账号不能为空');
|
||||
if (!password) return fail(400, '密码不能为空');
|
||||
|
||||
const candidates = await findLoginCandidates(env, identifier, name);
|
||||
if (!candidates.length) return fail(401, 'Unauthorized');
|
||||
|
||||
for (const u of candidates) {
|
||||
if (await verifyPassword(u.password, password)) {
|
||||
return ok({ token: await signToken(env, u.id), user: cookUser(u) });
|
||||
}
|
||||
}
|
||||
return fail(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
// ==================================================== POST /auth/email/login
|
||||
|
||||
export async function authEmailLogin(ctx: Ctx): Promise<Response> {
|
||||
const { env, req } = ctx;
|
||||
const ip = getClientIP(req);
|
||||
|
||||
if (!(await rateLimit(env, `login:${ip}`, 10, 300))) {
|
||||
return fail(429, 'Too many login attempts, try again later');
|
||||
}
|
||||
|
||||
const body = await readBody(req);
|
||||
const identifier = trimTo(body.email || body.username || body.account || '', 120).trim();
|
||||
const password = String(body.password ?? '');
|
||||
const code = String(body.code ?? '').trim();
|
||||
|
||||
if (code) {
|
||||
return fail(
|
||||
501,
|
||||
'邮箱验证码登录未启用:Cloudflare Workers 没有 SMTP,发不出验证邮件。请改用密码登录。',
|
||||
);
|
||||
}
|
||||
if (!identifier || !password) return fail(400, '账号和密码不能为空');
|
||||
|
||||
for (const u of await findLoginCandidates(env, identifier)) {
|
||||
if (await verifyPassword(u.password, password)) {
|
||||
return ok({ token: await signToken(env, u.id), user: cookUser(u) });
|
||||
}
|
||||
}
|
||||
return fail(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
export async function authEmailSend(ctx: Ctx): Promise<Response> {
|
||||
// Workers 无法直连 SMTP。要恢复「邮箱验证码」,接一个 HTTP 邮件 API
|
||||
// (Resend / MailChannels / 你自己的转发接口)后在 sendMail() 里实现。
|
||||
return fail(
|
||||
501,
|
||||
'邮件发送未启用:Workers 环境没有 SMTP,需要接 Resend / MailChannels 之类的 HTTP 邮件 API。',
|
||||
);
|
||||
}
|
||||
|
||||
export async function authEmailRegister(ctx: Ctx): Promise<Response> {
|
||||
return fail(403, '注册已关闭:本服务端只保留「昵称 + 邮箱直接评论」和「管理员密码登录」。');
|
||||
}
|
||||
|
||||
// ============================================================== auth/merge
|
||||
|
||||
export async function authMergeCheck(ctx: Ctx): Promise<Response> {
|
||||
const { env, user } = ctx;
|
||||
if (!user) return fail(401, 'Login required');
|
||||
const same = await findUserByEmail(env, user.email);
|
||||
const names = same.filter((u) => u.id !== user.id).map((u) => u.name);
|
||||
return ok({ need_merge: names.length > 0, user_names: names });
|
||||
}
|
||||
|
||||
export async function authMergeApply(ctx: Ctx): Promise<Response> {
|
||||
const { env, req, user } = ctx;
|
||||
if (!user) return fail(401, 'Login required');
|
||||
|
||||
const body = await readBody(req);
|
||||
const fromName = trimTo(body.user_name || '', 60).trim();
|
||||
if (!fromName) return fail(400, 'user_name is required');
|
||||
|
||||
const from = await findUserByNameEmail(env, fromName, user.email);
|
||||
if (!from || from.id === user.id) return okMsg('Nothing to merge');
|
||||
|
||||
const updated = await env.DB.prepare(
|
||||
'UPDATE comments SET user_id = ? WHERE user_id = ?',
|
||||
)
|
||||
.bind(user.id, from.id)
|
||||
.run();
|
||||
await env.DB.prepare('UPDATE notifies SET user_id = ? WHERE user_id = ?')
|
||||
.bind(user.id, from.id)
|
||||
.run();
|
||||
await env.DB.prepare('UPDATE votes SET user_id = ? WHERE user_id = ?')
|
||||
.bind(user.id, from.id)
|
||||
.run();
|
||||
await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(from.id).run();
|
||||
|
||||
return ok({
|
||||
deleted_user_count: 1,
|
||||
update_comments_count: updated.meta?.changes ?? 0,
|
||||
update_notifies_count: 0,
|
||||
update_votes_count: 0,
|
||||
user_token: await signToken(env, user.id),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================ sso/exchange
|
||||
|
||||
export async function ssoExchange(): Promise<Response> {
|
||||
return fail(501, 'SSO 未配置');
|
||||
}
|
||||
Reference in new issue
Block a user