归档 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
@@ -0,0 +1,85 @@
|
||||
// 给管理员发提醒邮件(带节流)。
|
||||
//
|
||||
// 用途:定时任务(证书/代理体检、用量告警等)需要给站长发提醒,
|
||||
// 但又不该每天重复刷屏。收件人固定为 MAIL_ADMIN,发件人默认「小赵」。
|
||||
//
|
||||
// 节流:同一 dedupeKey 在 minGapHours 内只发一次,时间戳存 KV `notify:last:<key>`。
|
||||
|
||||
import type { Env } from '../types';
|
||||
import { sendMail } from './mail';
|
||||
|
||||
export interface AdminNotifyInput {
|
||||
subject: string;
|
||||
text: string;
|
||||
/** 同一 key 在 minGapHours 内只发一次;不传则不节流 */
|
||||
dedupeKey?: string;
|
||||
minGapHours?: number;
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
export interface AdminNotifyResult {
|
||||
ok: boolean;
|
||||
sent: boolean;
|
||||
throttled?: boolean;
|
||||
lastAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function escapeHtml(x: string): string {
|
||||
return x.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function textToHtml(subject: string, text: string): string {
|
||||
const paras = text
|
||||
.split('\n')
|
||||
.map((l) =>
|
||||
l.trim()
|
||||
? `<p style="margin:0 0 8px">${escapeHtml(l)}</p>`
|
||||
: '<p style="margin:0 0 8px"> </p>',
|
||||
)
|
||||
.join('');
|
||||
return (
|
||||
`<div style="max-width:560px;margin:0 auto;padding:20px;font-family:'PingFang SC','Microsoft YaHei',sans-serif;color:#253830">` +
|
||||
`<div style="font-size:15px;font-weight:600;color:#2f9e63;margin-bottom:12px">${escapeHtml(subject)}</div>` +
|
||||
`<div style="background:#fff;border:1px solid #e7e9ee;border-radius:10px;padding:16px;font-size:14px;line-height:1.7">${paras}</div>` +
|
||||
`<div style="font-size:12px;color:#8a94a6;margin-top:12px">来自 artalk-cf 定时提醒</div></div>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function notifyAdmin(env: Env, input: AdminNotifyInput): Promise<AdminNotifyResult> {
|
||||
const to = (env.MAIL_ADMIN || '').trim();
|
||||
if (!to) return { ok: false, sent: false, error: 'MAIL_ADMIN not configured' };
|
||||
|
||||
const subject = String(input.subject || '').trim().slice(0, 160) || '提醒';
|
||||
const text = String(input.text || '').trim().slice(0, 8000);
|
||||
if (!text) return { ok: false, sent: false, error: 'text required' };
|
||||
|
||||
const dedupeKey = String(input.dedupeKey || '').trim().slice(0, 60);
|
||||
const minGapHours = Math.min(Math.max(input.minGapHours || 0, 0), 24 * 30);
|
||||
const throttleKey = dedupeKey ? `notify:last:${dedupeKey}` : '';
|
||||
|
||||
if (throttleKey && minGapHours > 0) {
|
||||
const last = await env.RSS_KV.get(throttleKey);
|
||||
const lastAt = last ? parseInt(last, 10) : 0;
|
||||
if (lastAt && Date.now() - lastAt < minGapHours * 3600 * 1000) {
|
||||
return { ok: true, sent: false, throttled: true, lastAt: new Date(lastAt).toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
const sent = await sendMail(env, {
|
||||
to,
|
||||
subject,
|
||||
html: textToHtml(subject, text),
|
||||
text,
|
||||
fromName: input.fromName || '小赵',
|
||||
});
|
||||
|
||||
if (sent && throttleKey) {
|
||||
try {
|
||||
await env.RSS_KV.put(throttleKey, String(Date.now()), { expirationTtl: 60 * 60 * 24 * 90 });
|
||||
} catch {
|
||||
/* 节流时间戳写失败不影响已发出的邮件 */
|
||||
}
|
||||
}
|
||||
return { ok: sent, sent };
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type {
|
||||
CommentRow,
|
||||
CookedComment,
|
||||
CookedNotify,
|
||||
CookedPage,
|
||||
CookedSite,
|
||||
CookedUser,
|
||||
Env,
|
||||
PageRow,
|
||||
SiteRow,
|
||||
UserRow,
|
||||
} from '../types';
|
||||
import { findPagesByKeys, findUsersByIds, siteFirstUrl } from './db';
|
||||
import { renderMarkdown } from './md';
|
||||
import { md5Lower } from './md5';
|
||||
import { formatDateCN } from './util';
|
||||
|
||||
// ============================================================================
|
||||
// 评论等级(九品十八阶)——与博客主题 Ying 的 RANK_TABLE 保持一致。
|
||||
// 主题原来用 localStorage 自己累计(换设备/清缓存就归零、别人看到的也不准),
|
||||
// 现在改成后端按全站真实评论数算好,直接塞进官方 badge_name/badge_color
|
||||
// 字段——官方前端原生渲染白字彩底徽章,跨设备全局一致。
|
||||
// 只对「没有自定义徽章」的用户生效;博主等保留原徽章不覆盖。
|
||||
// ============================================================================
|
||||
|
||||
interface Rank {
|
||||
min: number;
|
||||
title: string;
|
||||
short: string;
|
||||
/** 徽章文字色(与主题原版配色一致) */
|
||||
color: string;
|
||||
/** 徽章半透明底色(与主题原版配色一致) */
|
||||
bg: string;
|
||||
}
|
||||
|
||||
const RANK_TABLE: Rank[] = [
|
||||
{ min: 2000, title: '正一品·太师', short: '太师', color: '#c5881b', bg: 'rgba(197,136,27,0.16)' },
|
||||
{ min: 1400, title: '从一品·太尉', short: '太尉', color: '#cc942a', bg: 'rgba(204,148,42,0.13)' },
|
||||
{ min: 1050, title: '正二品·参知政事', short: '参知政事', color: '#d46d33', bg: 'rgba(212,109,51,0.16)' },
|
||||
{ min: 800, title: '从二品·节度使', short: '节度使', color: '#d87940', bg: 'rgba(216,121,64,0.13)' },
|
||||
{ min: 600, title: '正三品·御史中丞', short: '御史中丞', color: '#cf4a58', bg: 'rgba(207,74,88,0.16)' },
|
||||
{ min: 480, title: '从三品·秘书监', short: '秘书监', color: '#d45d68', bg: 'rgba(212,93,104,0.13)' },
|
||||
{ min: 380, title: '正四品·谏议大夫', short: '谏议大夫', color: '#b05385', bg: 'rgba(176,83,133,0.16)' },
|
||||
{ min: 300, title: '从四品·侍读学士', short: '侍读学士', color: '#b8638f', bg: 'rgba(184,99,143,0.13)' },
|
||||
{ min: 240, title: '正五品·给事中', short: '给事中', color: '#9570d6', bg: 'rgba(149,112,214,0.16)' },
|
||||
{ min: 190, title: '从五品·知州', short: '知州', color: '#a07bda', bg: 'rgba(160,123,218,0.13)' },
|
||||
{ min: 150, title: '正六品·侍御史', short: '侍御史', color: '#5f7ace', bg: 'rgba(95,122,206,0.16)' },
|
||||
{ min: 115, title: '从六品·通判', short: '通判', color: '#6b85d0', bg: 'rgba(107,133,208,0.13)' },
|
||||
{ min: 85, title: '正七品·知县', short: '知县', color: '#2a9a84', bg: 'rgba(42,154,132,0.16)' },
|
||||
{ min: 60, title: '从七品·殿中侍御史', short: '殿中侍御史', color: '#34a390', bg: 'rgba(52,163,144,0.13)' },
|
||||
{ min: 40, title: '正八品·大理评事', short: '大理评事', color: '#628530', bg: 'rgba(98,133,48,0.16)' },
|
||||
{ min: 25, title: '从八品·录事参军', short: '录事参军', color: '#6e913d', bg: 'rgba(110,145,61,0.13)' },
|
||||
{ min: 15, title: '正九品·主簿', short: '主簿', color: '#8a7050', bg: 'rgba(138,112,80,0.16)' },
|
||||
{ min: 5, title: '从九品·司户参军', short: '司户参军', color: '#917a5c', bg: 'rgba(145,122,92,0.13)' },
|
||||
{ min: 0, title: '庶民', short: '庶民', color: '#8a8e98', bg: 'rgba(138,142,152,0.10)' },
|
||||
];
|
||||
|
||||
function getRank(count: number): Rank {
|
||||
for (const r of RANK_TABLE) {
|
||||
if (count >= r.min) return r;
|
||||
}
|
||||
return RANK_TABLE[RANK_TABLE.length - 1];
|
||||
}
|
||||
|
||||
// Worker isolate 级缓存:评论数变化慢,同一实例的后续请求零 D1 消耗。
|
||||
// (D1 免费额度按 rows_read 计费,GROUP BY 全表扫很贵,必须挡在缓存后面)
|
||||
const RANK_CACHE_TTL = 10 * 60 * 1000;
|
||||
const rankCache = ((globalThis as Record<string, unknown>).__atkRankCache ??= new Map<
|
||||
number,
|
||||
{ n: number; t: number }
|
||||
>()) as Map<number, { n: number; t: number }>;
|
||||
|
||||
async function getCommentCounts(env: Env, userIds: number[]): Promise<Map<number, number>> {
|
||||
const out = new Map<number, number>();
|
||||
const missing: number[] = [];
|
||||
const now = Date.now();
|
||||
for (const id of userIds) {
|
||||
const hit = rankCache.get(id);
|
||||
if (hit && now - hit.t < RANK_CACHE_TTL) out.set(id, hit.n);
|
||||
else missing.push(id);
|
||||
}
|
||||
if (missing.length) {
|
||||
const rows = (
|
||||
await env.DB.prepare(
|
||||
`SELECT user_id, COUNT(*) AS n FROM comments
|
||||
WHERE deleted_at = 0 AND user_id IN (${missing.map(() => '?').join(',')})
|
||||
GROUP BY user_id`,
|
||||
)
|
||||
.bind(...missing)
|
||||
.all<{ user_id: number; n: number }>()
|
||||
.catch(() => ({ results: [] as { user_id: number; n: number }[] }))
|
||||
).results;
|
||||
for (const r of rows ?? []) {
|
||||
out.set(r.user_id, r.n);
|
||||
rankCache.set(r.user_id, { n: r.n, t: now });
|
||||
}
|
||||
// 查不到的(0 条评论)也缓存,避免反复查
|
||||
for (const id of missing) {
|
||||
if (!out.has(id)) {
|
||||
out.set(id, 0);
|
||||
rankCache.set(id, { n: 0, t: now });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** page_key 是相对路径时,拼到站点地址后面(与 Artalk GetPageAccessibleURL 一致) */
|
||||
export function pageAccessibleUrl(pageKey: string, siteUrl: string): string {
|
||||
if (/^https?:\/\//i.test(pageKey)) return pageKey;
|
||||
if (!siteUrl) return pageKey;
|
||||
try {
|
||||
return new URL(pageKey, siteUrl.endsWith('/') ? siteUrl : `${siteUrl}/`).toString();
|
||||
} catch {
|
||||
return pageKey;
|
||||
}
|
||||
}
|
||||
|
||||
export function cookUser(u: UserRow): CookedUser {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
link: u.link || '',
|
||||
badge_name: u.badge_name || '',
|
||||
badge_color: u.badge_color || '',
|
||||
title_name: u.title_name || '',
|
||||
is_admin: !!u.is_admin,
|
||||
receive_email: !!u.receive_email,
|
||||
};
|
||||
}
|
||||
|
||||
export function cookSite(s: SiteRow): CookedSite {
|
||||
const urls = (s.urls || '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
return { id: s.id, name: s.name, urls, urls_raw: s.urls || '', first_url: urls[0] || '' };
|
||||
}
|
||||
|
||||
export function cookPage(p: PageRow, siteUrl = ''): CookedPage {
|
||||
return {
|
||||
id: p.id,
|
||||
admin_only: !!p.admin_only,
|
||||
key: p.key,
|
||||
url: p.accessible_url || pageAccessibleUrl(p.key, siteUrl),
|
||||
title: p.title || '',
|
||||
site_name: p.site_name,
|
||||
vote_up: p.vote_up || 0,
|
||||
vote_down: p.vote_down || 0,
|
||||
pv: p.pv || 0,
|
||||
date: formatDateCN(p.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function cookNotify(n: {
|
||||
id: number;
|
||||
user_id: number;
|
||||
comment_id: number;
|
||||
is_read: number;
|
||||
is_emailed: number;
|
||||
}, readLink = ''): CookedNotify {
|
||||
return {
|
||||
id: n.id,
|
||||
user_id: n.user_id,
|
||||
comment_id: n.comment_id,
|
||||
is_read: !!n.is_read,
|
||||
is_emailed: !!n.is_emailed,
|
||||
read_link: readLink,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量把评论行转成 API 结构。
|
||||
* 一次性把用到的 user / page 捞出来,避免 N+1(Worker 里每次 D1 查询都是成本)。
|
||||
*
|
||||
* ★ includeMarked 默认 false:content_marked 是「把 content 再渲染一遍成 HTML」,
|
||||
* 体积几乎翻倍,而且 **Artalk 2.8.x 客户端根本不消费它**(它自带 marked,在浏览器里
|
||||
* 渲染 content;只有官方后台 sidebar 会用到)。实测 20 条评论 36KB,
|
||||
* 去掉后能砍掉一大半,跨境加载明显更快。
|
||||
* → 只有管理员请求(后台)才带上这个字段。
|
||||
*/
|
||||
export async function cookComments(
|
||||
env: Env,
|
||||
rows: CommentRow[],
|
||||
opts: { ipRegion?: boolean; visibility?: Map<number, boolean>; includeMarked?: boolean } = {},
|
||||
): Promise<CookedComment[]> {
|
||||
if (!rows.length) return [];
|
||||
|
||||
const users = await findUsersByIds(
|
||||
env,
|
||||
rows.map((r) => r.user_id),
|
||||
);
|
||||
|
||||
// 评论等级:批量取本批用户的真实评论数(带 isolate 缓存)
|
||||
const distinctUserIds = [...new Set(rows.map((r) => r.user_id))];
|
||||
const commentCounts = await getCommentCounts(env, distinctUserIds);
|
||||
|
||||
const bySite = new Map<string, string[]>();
|
||||
for (const r of rows) {
|
||||
const arr = bySite.get(r.site_name) ?? [];
|
||||
arr.push(r.page_key);
|
||||
bySite.set(r.site_name, arr);
|
||||
}
|
||||
const pageMap = new Map<string, PageRow>();
|
||||
const siteUrlMap = new Map<string, string>();
|
||||
for (const [siteName, keys] of bySite) {
|
||||
for (const [k, v] of await findPagesByKeys(env, keys, siteName)) {
|
||||
pageMap.set(`${siteName}\u0000${k}`, v);
|
||||
}
|
||||
siteUrlMap.set(siteName, await siteFirstUrl(env, siteName));
|
||||
}
|
||||
|
||||
return rows.map((c) => {
|
||||
const user = users.get(c.user_id);
|
||||
const siteUrl = siteUrlMap.get(c.site_name) || '';
|
||||
const page = pageMap.get(`${c.site_name}\u0000${c.page_key}`);
|
||||
const cook: CookedComment = {
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
content_marked: opts.includeMarked ? renderMarkdown(c.content) : '',
|
||||
user_id: c.user_id,
|
||||
nick: user?.name ?? '',
|
||||
email_encrypted: md5Lower(user?.email ?? ''),
|
||||
link: user?.link ?? '',
|
||||
ua: c.ua || '',
|
||||
date: formatDateCN(c.created_at),
|
||||
is_collapsed: !!c.is_collapsed,
|
||||
is_pending: !!c.is_pending,
|
||||
is_pinned: !!c.is_pinned,
|
||||
is_allow_reply: !c.is_collapsed && !c.is_pending,
|
||||
is_verified: user?.is_admin ? true : !!c.is_verified,
|
||||
rid: c.rid,
|
||||
badge_name: user?.badge_name ?? '',
|
||||
badge_color: user?.badge_color ?? '',
|
||||
title_name: user?.title_name || '',
|
||||
visible: opts.visibility?.get(c.id) ?? true,
|
||||
vote_up: c.vote_up || 0,
|
||||
vote_down: c.vote_down || 0,
|
||||
page_key: c.page_key,
|
||||
page_url: page ? cookPage(page, siteUrl).url : pageAccessibleUrl(c.page_key, siteUrl),
|
||||
site_name: c.site_name,
|
||||
};
|
||||
if (opts.ipRegion && c.ip_region) cook.ip_region = c.ip_region;
|
||||
// 官职徽标独立字段:不占用官方 badge 槽(博主的「博主」徽章独一无二,
|
||||
// 官职接在它后面由主题渲染),配色沿用主题原版(彩字 + 半透明底)
|
||||
const rank = getRank(commentCounts.get(c.user_id) ?? 0);
|
||||
const myCount = commentCounts.get(c.user_id) ?? 0;
|
||||
cook.rank_name = rank.short;
|
||||
cook.rank_title = rank.title;
|
||||
cook.rank_count = myCount;
|
||||
cook.rank_color = rank.color;
|
||||
cook.rank_bg = rank.bg;
|
||||
return cook;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import type { CommentRow, Env, PageRow, SiteRow, UserRow } from '../types';
|
||||
import { now } from './util';
|
||||
|
||||
// ===================================================================== 配置
|
||||
|
||||
export interface AdminUserConf {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function getSetting<T>(env: Env, key: string, dft: T): Promise<T> {
|
||||
const row = await env.DB.prepare('SELECT value FROM settings WHERE key = ?').bind(key).first<{
|
||||
value: string;
|
||||
}>();
|
||||
if (!row) return dft;
|
||||
try {
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch {
|
||||
return dft;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSetting(env: Env, key: string, value: unknown): Promise<void> {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.bind(key, JSON.stringify(value), now())
|
||||
.run();
|
||||
}
|
||||
|
||||
/** 前端配置:/conf 里原样下发的 frontend_conf(默认值对齐旧 artalk.yml 的 frontend 段) */
|
||||
export const DEFAULT_FRONTEND_CONF: Record<string, unknown> = {
|
||||
imgUpload: false,
|
||||
versionCheck: false, // 自研服务端版本号与官方客户端对不齐时会弹警告,默认关掉
|
||||
pvAdd: true,
|
||||
countOfCommentsOnPageInit: 20,
|
||||
notify: true,
|
||||
vote: true,
|
||||
voteDown: false,
|
||||
captchaMode: 'off',
|
||||
placeholder: '',
|
||||
noComment: '',
|
||||
sendBtn: '评论一下',
|
||||
editorTravel: true,
|
||||
uaBadge: false,
|
||||
listSort: true,
|
||||
preview: true,
|
||||
flatMode: false,
|
||||
nestMax: 2,
|
||||
nestSort: 'DATE_ASC',
|
||||
// OwO 格式官方前端原生支持(isOwOFormat/convertOwO),直接指向博客自己的表情包
|
||||
emoticons: 'https://usj.cc/emotion/OwO.json',
|
||||
gravatar: { mirror: 'https://weavatar.com/avatar/', params: 'd=mp&s=240' },
|
||||
useBackendConf: true,
|
||||
locale: 'zh-CN',
|
||||
pluginURLs: [] as string[],
|
||||
darkMode: 'inherit',
|
||||
pagination: { pageSize: 20, readMore: true, autoLoad: true },
|
||||
heightLimit: { content: 10000, children: 10000, scrollable: true },
|
||||
imgLazyLoad: 'data-src',
|
||||
reqTimeout: 15000,
|
||||
};
|
||||
|
||||
export async function getFrontendConf(env: Env): Promise<Record<string, unknown>> {
|
||||
const saved = await getSetting<Record<string, unknown>>(env, 'frontend_conf', {});
|
||||
return { ...DEFAULT_FRONTEND_CONF, ...saved };
|
||||
}
|
||||
|
||||
export async function getAdminUsers(env: Env): Promise<AdminUserConf[]> {
|
||||
const list = await getSetting<AdminUserConf[]>(env, 'admin_users', []);
|
||||
if (Array.isArray(list) && list.length) return list;
|
||||
return [{ name: env.ADMIN_NAME, email: env.ADMIN_EMAIL, password: env.ADMIN_PASSWORD }];
|
||||
}
|
||||
|
||||
export async function isPendingDefault(env: Env): Promise<boolean> {
|
||||
const saved = await getSetting<{ pendingDefault?: boolean } | null>(env, 'moderator', null);
|
||||
if (saved && typeof saved.pendingDefault === 'boolean') return saved.pendingDefault;
|
||||
return (env.PENDING_DEFAULT || 'false').toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
export async function isCaptchaEnabled(env: Env): Promise<boolean> {
|
||||
const saved = await getSetting<{ enabled?: boolean } | null>(env, 'captcha', null);
|
||||
return !!(saved && saved.enabled);
|
||||
}
|
||||
|
||||
export async function isIPRegionEnabled(env: Env): Promise<boolean> {
|
||||
const saved = await getSetting<{ enabled?: boolean } | null>(env, 'ip_region', null);
|
||||
return !!(saved && saved.enabled);
|
||||
}
|
||||
|
||||
export async function defaultSiteName(env: Env): Promise<string> {
|
||||
return (await getSetting<string>(env, 'site_default', env.SITE_DEFAULT)) || env.SITE_DEFAULT;
|
||||
}
|
||||
|
||||
// ===================================================================== 站点
|
||||
|
||||
export async function findSite(env: Env, name: string): Promise<SiteRow | null> {
|
||||
return env.DB.prepare('SELECT * FROM sites WHERE name = ?').bind(name).first<SiteRow>();
|
||||
}
|
||||
|
||||
export async function findOrCreateSite(env: Env, name: string, urls = ''): Promise<SiteRow> {
|
||||
const found = await findSite(env, name);
|
||||
if (found) return found;
|
||||
const t = now();
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO sites (name, urls, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.bind(name, urls, t, t)
|
||||
.run();
|
||||
const created = await findSite(env, name);
|
||||
return created as SiteRow;
|
||||
}
|
||||
|
||||
export async function findAllSites(env: Env): Promise<SiteRow[]> {
|
||||
const res = await env.DB.prepare('SELECT * FROM sites ORDER BY id ASC').all<SiteRow>();
|
||||
return res.results ?? [];
|
||||
}
|
||||
|
||||
export async function siteFirstUrl(env: Env, siteName: string): Promise<string> {
|
||||
const site = await findSite(env, siteName);
|
||||
const raw = (site?.urls || '').split(',')[0]?.trim();
|
||||
return raw || env.SITE_URL;
|
||||
}
|
||||
|
||||
// ===================================================================== 页面
|
||||
|
||||
export async function findPage(env: Env, key: string, siteName: string): Promise<PageRow | null> {
|
||||
return env.DB.prepare('SELECT * FROM pages WHERE key = ? AND site_name = ?')
|
||||
.bind(key, siteName)
|
||||
.first<PageRow>();
|
||||
}
|
||||
|
||||
export async function findPageById(env: Env, id: number): Promise<PageRow | null> {
|
||||
return env.DB.prepare('SELECT * FROM pages WHERE id = ?').bind(id).first<PageRow>();
|
||||
}
|
||||
|
||||
export async function findOrCreatePage(
|
||||
env: Env,
|
||||
key: string,
|
||||
title: string,
|
||||
siteName: string,
|
||||
): Promise<PageRow> {
|
||||
const found = await findPage(env, key, siteName);
|
||||
if (found) {
|
||||
if (title && found.title !== title) {
|
||||
await env.DB.prepare('UPDATE pages SET title = ?, updated_at = ? WHERE id = ?')
|
||||
.bind(title, now(), found.id)
|
||||
.run();
|
||||
found.title = title;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
const t = now();
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO pages (key, site_name, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||
)
|
||||
.bind(key, siteName, title || '', t, t)
|
||||
.run();
|
||||
const created = await findPage(env, key, siteName);
|
||||
return created as PageRow;
|
||||
}
|
||||
|
||||
// ===================================================================== 用户
|
||||
|
||||
export async function findUserByNameEmail(
|
||||
env: Env,
|
||||
name: string,
|
||||
email: string,
|
||||
): Promise<UserRow | null> {
|
||||
return env.DB.prepare('SELECT * FROM users WHERE name = ? AND email = ?')
|
||||
.bind(name, email)
|
||||
.first<UserRow>();
|
||||
}
|
||||
|
||||
export async function findUserByEmail(env: Env, email: string): Promise<UserRow[]> {
|
||||
const res = await env.DB.prepare('SELECT * FROM users WHERE email = ?')
|
||||
.bind(email)
|
||||
.all<UserRow>();
|
||||
return res.results ?? [];
|
||||
}
|
||||
|
||||
export async function findUserByName(env: Env, name: string): Promise<UserRow[]> {
|
||||
const res = await env.DB.prepare('SELECT * FROM users WHERE name = ?').bind(name).all<UserRow>();
|
||||
return res.results ?? [];
|
||||
}
|
||||
|
||||
export async function findUserById(env: Env, id: number): Promise<UserRow | null> {
|
||||
return env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(id).first<UserRow>();
|
||||
}
|
||||
|
||||
/** 与 Artalk 的 FindCreateUser 一致:按 name+email 找,找不到就建 */
|
||||
export async function findOrCreateUser(
|
||||
env: Env,
|
||||
name: string,
|
||||
email: string,
|
||||
link = '',
|
||||
): Promise<UserRow> {
|
||||
const found = await findUserByNameEmail(env, name, email);
|
||||
if (found) return found;
|
||||
|
||||
const t = now();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO users (name, email, link, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(name, email, link, t, t)
|
||||
.run();
|
||||
|
||||
const created = await findUserByNameEmail(env, name, email);
|
||||
return created as UserRow;
|
||||
}
|
||||
|
||||
export async function touchUser(env: Env, id: number, ip: string, ua: string): Promise<void> {
|
||||
await env.DB.prepare('UPDATE users SET last_ip = ?, last_ua = ?, updated_at = ? WHERE id = ?')
|
||||
.bind(ip, ua, now(), id)
|
||||
.run();
|
||||
}
|
||||
|
||||
// ===================================================================== 评论
|
||||
|
||||
export async function findComment(env: Env, id: number): Promise<CommentRow | null> {
|
||||
return env.DB.prepare('SELECT * FROM comments WHERE id = ? AND deleted_at = 0')
|
||||
.bind(id)
|
||||
.first<CommentRow>();
|
||||
}
|
||||
|
||||
/** 与 Artalk FindCommentRootID 一致:沿 rid 链上溯到顶层 */
|
||||
export async function findCommentRootId(env: Env, rid: number): Promise<number> {
|
||||
const visited = new Set<number>();
|
||||
let cur = rid;
|
||||
while (cur !== 0 && !visited.has(cur)) {
|
||||
visited.add(cur);
|
||||
const row = await env.DB.prepare('SELECT id, rid FROM comments WHERE id = ?')
|
||||
.bind(cur)
|
||||
.first<{ id: number; rid: number }>();
|
||||
if (!row) return 0;
|
||||
if (row.rid === 0) return row.id;
|
||||
cur = row.rid;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export async function findUsersByIds(env: Env, ids: number[]): Promise<Map<number, UserRow>> {
|
||||
const map = new Map<number, UserRow>();
|
||||
const uniq = [...new Set(ids.filter((n) => n > 0))];
|
||||
for (let i = 0; i < uniq.length; i += 80) {
|
||||
const chunk = uniq.slice(i, i + 80);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await env.DB.prepare(`SELECT * FROM users WHERE id IN (${placeholders})`)
|
||||
.bind(...chunk)
|
||||
.all<UserRow>();
|
||||
for (const u of res.results ?? []) map.set(u.id, u);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function findPagesByKeys(
|
||||
env: Env,
|
||||
keys: string[],
|
||||
siteName: string,
|
||||
): Promise<Map<string, PageRow>> {
|
||||
const map = new Map<string, PageRow>();
|
||||
const uniq = [...new Set(keys.filter(Boolean))];
|
||||
for (let i = 0; i < uniq.length; i += 80) {
|
||||
const chunk = uniq.slice(i, i + 80);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await env.DB.prepare(
|
||||
`SELECT * FROM pages WHERE site_name = ? AND key IN (${placeholders})`,
|
||||
)
|
||||
.bind(siteName, ...chunk)
|
||||
.all<PageRow>();
|
||||
for (const p of res.results ?? []) map.set(p.key, p);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ===================================================================== 投票
|
||||
|
||||
export async function findVotesFor(
|
||||
env: Env,
|
||||
type: string,
|
||||
ids: number[],
|
||||
): Promise<Map<number, { up: number; down: number }>> {
|
||||
const map = new Map<number, { up: number; down: number }>();
|
||||
if (!ids.length) return map;
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const res = await env.DB.prepare(
|
||||
`SELECT target_id,
|
||||
SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS up,
|
||||
SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS down
|
||||
FROM votes WHERE target_id IN (${placeholders})
|
||||
GROUP BY target_id`,
|
||||
)
|
||||
.bind(`${type}_up`, `${type}_down`, ...ids)
|
||||
.all<{ target_id: number; up: number; down: number }>();
|
||||
for (const r of res.results ?? []) map.set(r.target_id, { up: r.up ?? 0, down: r.down ?? 0 });
|
||||
return map;
|
||||
}
|
||||
|
||||
// ===================================================================== 限流
|
||||
|
||||
/**
|
||||
* 简单滑动窗口限流。同一 bucket 在 windowSec 内超过 limit 次就拒绝。
|
||||
* 用于发评论 / 登录,防止被刷。
|
||||
*/
|
||||
export async function rateLimit(
|
||||
env: Env,
|
||||
bucket: string,
|
||||
limit: number,
|
||||
windowSec: number,
|
||||
): Promise<boolean> {
|
||||
const t = now();
|
||||
const row = await env.DB.prepare('SELECT count, expires_at FROM rate_limits WHERE bucket = ?')
|
||||
.bind(bucket)
|
||||
.first<{ count: number; expires_at: number }>();
|
||||
|
||||
if (!row || row.expires_at < t) {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO rate_limits (bucket, count, expires_at) VALUES (?, 1, ?)
|
||||
ON CONFLICT(bucket) DO UPDATE SET count = 1, expires_at = excluded.expires_at`,
|
||||
)
|
||||
.bind(bucket, t + windowSec * 1000)
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (row.count >= limit) return false;
|
||||
|
||||
await env.DB.prepare('UPDATE rate_limits SET count = count + 1 WHERE bucket = ?')
|
||||
.bind(bucket)
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 顺手清理过期的限流记录(低频调用即可) */
|
||||
export async function gcRateLimits(env: Env): Promise<void> {
|
||||
await env.DB.prepare('DELETE FROM rate_limits WHERE expires_at < ?').bind(now()).run();
|
||||
}
|
||||
|
||||
// ===================================================================== 验证码
|
||||
|
||||
/** /captcha/verify 通过后写入;发评论时检查 */
|
||||
export async function hasCaptchaPassed(env: Env, ip: string): Promise<boolean> {
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT expires_at FROM captcha_passes WHERE ip = ? AND expires_at > ?',
|
||||
)
|
||||
.bind(ip, now())
|
||||
.first<{ expires_at: number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function grantCaptchaPass(env: Env, ip: string, ttlSec: number): Promise<void> {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO captcha_passes (ip, expires_at) VALUES (?, ?)
|
||||
ON CONFLICT(ip) DO UPDATE SET expires_at = excluded.expires_at`,
|
||||
)
|
||||
.bind(ip, now() + ttlSec * 1000)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function gcCaptcha(env: Env): Promise<void> {
|
||||
const t = now();
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM captcha_passes WHERE expires_at < ?').bind(t),
|
||||
env.DB.prepare('DELETE FROM captcha_challenges WHERE expires_at < ?').bind(t),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// ============================================================================
|
||||
// 统一错误出口
|
||||
//
|
||||
// 起因:D1 免费额度用尽时,Artalk 客户端弹出的是
|
||||
// 「Failed to load comments / TypeError: Failed to fetch」这种模棱两可的报错。
|
||||
// 两个原因:
|
||||
// 1) 异常路径不带 CORS 头(例如 userFromToken 抛错时在 try 之外)
|
||||
// → 浏览器把它当网络故障,连状态码都拿不到;
|
||||
// 2) 原始错误串(D1_ERROR: ... see https://developers.cloudflare.com/...)
|
||||
// 直接甩给读者,既是英文黑话又带外链。
|
||||
//
|
||||
// 这里做两件事:把技术错误翻译成人话 + 附上机器可读的 code,
|
||||
// 让前端能按 code 给出对应提示(而不是去猜字符串)。
|
||||
// ============================================================================
|
||||
|
||||
export type ErrCode = 'd1_quota' | 'd1_unavailable' | 'timeout' | 'unavailable' | 'internal';
|
||||
|
||||
export interface FriendlyError {
|
||||
/** HTTP 状态码 */
|
||||
status: number;
|
||||
/** 机器可读的错误类别(前端按它选文案) */
|
||||
code: ErrCode;
|
||||
/** 给读者看的中文文案 */
|
||||
msg: string;
|
||||
/** 原始技术信息,折叠展示,方便站长排查 */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** 判断是不是 D1 免费额度用尽 */
|
||||
export function isD1QuotaError(msg: string): boolean {
|
||||
return (
|
||||
/exceeded D1'?s free tier/i.test(msg) ||
|
||||
/daily row (read|write) limit/i.test(msg) ||
|
||||
/D1_ERROR.*(limit|exceeded|quota)/i.test(msg)
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断是不是 D1 本身不可用(不是配额,是服务/连接问题) */
|
||||
export function isD1Unavailable(msg: string): boolean {
|
||||
return /D1_ERROR|D1 database|no such DB|storage operation|internal error in D1/i.test(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意异常翻译成「给人看的 + 给程序看的」错误结构。
|
||||
* 不认识的异常一律 500 + internal,但 detail 里保留原文。
|
||||
*/
|
||||
export function friendlyError(e: unknown): FriendlyError {
|
||||
const raw = e instanceof Error ? e.message : String(e ?? '');
|
||||
const detail = raw.slice(0, 500);
|
||||
|
||||
if (isD1QuotaError(raw)) {
|
||||
return {
|
||||
status: 503,
|
||||
code: 'd1_quota',
|
||||
msg: '评论服务暂时不可用:数据库今日读取额度已用尽,北京时间明早 8:00 自动恢复。给你带来不便,可以先看看文章,稍后再来。',
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
if (isD1Unavailable(raw)) {
|
||||
return {
|
||||
status: 503,
|
||||
code: 'd1_unavailable',
|
||||
msg: '评论服务暂时不可用(数据库连接异常),我们正在处理,请稍后再试。',
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
if (/abort|timeout|timed out/i.test(raw)) {
|
||||
return {
|
||||
status: 504,
|
||||
code: 'timeout',
|
||||
msg: '评论服务响应超时,请稍后重试。',
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
if (/fetch failed|Network|ECONNREFUSED|ENOTFOUND|socket/i.test(raw)) {
|
||||
return {
|
||||
status: 503,
|
||||
code: 'unavailable',
|
||||
msg: '评论服务暂时不可用,请稍后重试。',
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
code: 'internal',
|
||||
msg: '评论服务出了点小问题,请稍后重试。',
|
||||
detail,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// 人机验证(自研「一键验证」)
|
||||
//
|
||||
// 设计目标:正常读者**无感**(浏览器静默算一个 20~80ms 的 PoW,什么也不用手动做),
|
||||
// 命中可疑信号时才让访客「点一下」,再可疑才回退到图形验证码。
|
||||
//
|
||||
// 为什么不用 Turnstile/reCAPTCHA:
|
||||
// 1) 国内加载不稳定(Google/Cloudflare 的脚本经常拉不下来),一旦失败评论就发不出去;
|
||||
// 2) 会把访客 IP/浏览器指纹送给第三方。
|
||||
// 自研版靠 PoW + 行为信号,挡得住脚本刷评论和低成本机器人,对个人博客足够。
|
||||
//
|
||||
// 关键点:**挑战是无状态的**(HMAC 签名,不写库);通行证放 KV(不占 D1 额度)。
|
||||
|
||||
import type { Env } from '../types';
|
||||
|
||||
/** PoW 难度:sha256(challenge + nonce) 的十六进制前缀要有这么多个 0 */
|
||||
export const POW_DIFFICULTY = 4;
|
||||
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
|
||||
/** 通行证有效期(秒):过了一次就不用再验 */
|
||||
const PASS_TTL_SEC = 1800;
|
||||
/** 开关:存 KV,读一次比读 D1 settings 便宜 */
|
||||
const ENABLED_KEY = 'human_check';
|
||||
|
||||
export interface HumanChallenge {
|
||||
challenge: string;
|
||||
difficulty: number;
|
||||
exp: number;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
function bytesToHex(buf: ArrayBuffer): string {
|
||||
return [...new Uint8Array(buf)]
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
return bytesToHex(await crypto.subtle.digest('SHA-256', data));
|
||||
}
|
||||
|
||||
async function hmacHex(secret: string, msg: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(msg));
|
||||
return bytesToHex(sig);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 开关
|
||||
|
||||
export async function isHumanCheckEnabled(env: Env): Promise<boolean> {
|
||||
try {
|
||||
return (await env.RSS_KV.get(ENABLED_KEY)) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setHumanCheck(env: Env, on: boolean): Promise<void> {
|
||||
try {
|
||||
await env.RSS_KV.put(ENABLED_KEY, on ? '1' : '0');
|
||||
} catch {
|
||||
/* 开关写失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 挑战签发
|
||||
|
||||
export async function issueChallenge(env: Env): Promise<HumanChallenge> {
|
||||
const buf = crypto.getRandomValues(new Uint8Array(12));
|
||||
const challenge = bytesToHex(buf.buffer);
|
||||
const exp = Date.now() + CHALLENGE_TTL_MS;
|
||||
const sig = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
|
||||
return { challenge, difficulty: POW_DIFFICULTY, exp, sig };
|
||||
}
|
||||
|
||||
/** 校验 PoW 证明:签名有效 + 未过期 + 哈希前缀达标 */
|
||||
export async function verifyProof(
|
||||
env: Env,
|
||||
p: { challenge?: string; nonce?: number | string; exp?: number; sig?: string },
|
||||
): Promise<boolean> {
|
||||
const challenge = String(p.challenge || '');
|
||||
const exp = Number(p.exp || 0);
|
||||
const sig = String(p.sig || '');
|
||||
if (!challenge || !exp || !sig || p.nonce === undefined || p.nonce === null) return false;
|
||||
if (Date.now() > exp) return false;
|
||||
const expect = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
|
||||
if (expect !== sig) return false;
|
||||
const hash = await sha256Hex(`${challenge}${p.nonce}`);
|
||||
return hash.startsWith('0'.repeat(POW_DIFFICULTY));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 通行证
|
||||
|
||||
export function humanPassKey(ip: string): string {
|
||||
return `human:pass:${ip}`;
|
||||
}
|
||||
|
||||
export async function hasHumanPass(env: Env, ip: string): Promise<boolean> {
|
||||
try {
|
||||
return (await env.RSS_KV.get(humanPassKey(ip))) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function grantHumanPass(env: Env, ip: string, ttl = PASS_TTL_SEC): Promise<void> {
|
||||
try {
|
||||
await env.RSS_KV.put(humanPassKey(ip), '1', { expirationTtl: ttl });
|
||||
} catch {
|
||||
/* 通行证写失败 → 下次再验,不影响本次放行 */
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 行为信号评分
|
||||
|
||||
export interface HumanSignals {
|
||||
/** 蜜罐字段:人类看不见也不会填,填了就一定是脚本 */
|
||||
honeypot?: string;
|
||||
/** 从拿到挑战到提交验证的耗时(毫秒) */
|
||||
elapsedMs?: number;
|
||||
/** 页面上的鼠标/键盘/滚动/触摸事件次数 */
|
||||
events?: number;
|
||||
/** navigator.webdriver(自动化浏览器通常为 true) */
|
||||
webdriver?: boolean;
|
||||
/** 是否是「点一下」触发的验证(点击本身就是人类信号) */
|
||||
clicked?: boolean;
|
||||
}
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface RiskResult {
|
||||
level: RiskLevel;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 信号评分。
|
||||
* low → 直接发通行证(读者无感)
|
||||
* medium → 要求「点一下」(点击会补齐 elapsedMs / events 信号,重试即通过)
|
||||
* high → 回退图形验证码
|
||||
*/
|
||||
export function assessSignals(s: HumanSignals): RiskResult {
|
||||
const reasons: string[] = [];
|
||||
if (s.honeypot) {
|
||||
return { level: 'high', reasons: ['honeypot filled'] };
|
||||
}
|
||||
if (s.webdriver === true) {
|
||||
reasons.push('webdriver');
|
||||
}
|
||||
const elapsed = Number(s.elapsedMs || 0);
|
||||
const events = Number(s.events || 0);
|
||||
if (elapsed > 0 && elapsed < 1200) reasons.push('too fast');
|
||||
if (elapsed > 2 * 3600 * 1000) reasons.push('too slow');
|
||||
if (events <= 0 && !s.clicked) reasons.push('no interaction');
|
||||
|
||||
if (reasons.includes('webdriver')) return { level: 'high', reasons };
|
||||
if (s.clicked) {
|
||||
// 点击过就直接放行(点击 + PoW 已经足够;elapsed 太短仍视为可疑)
|
||||
return reasons.includes('too fast') && elapsed < 400
|
||||
? { level: 'medium', reasons }
|
||||
: { level: 'low', reasons };
|
||||
}
|
||||
if (reasons.length) return { level: 'medium', reasons };
|
||||
return { level: 'low', reasons: [] };
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import type { Env, CommentRow, UserRow } from '../types';
|
||||
import { formatDateCN } from './util';
|
||||
import { md5Lower } from './md5';
|
||||
import { renderMarkdown } from './md';
|
||||
|
||||
/**
|
||||
* 邮件通知 —— 走 Resend 的 HTTP API。
|
||||
*
|
||||
* 为什么不用 SMTP:**Cloudflare Workers 没有 TCP socket**,连不上 SMTP 服务器。
|
||||
* (Cloudflare 2026-04 推出的原生 Email Service 需要 Workers Paid,
|
||||
* 本项目跑在免费版上,所以走第三方 HTTP API。)
|
||||
*
|
||||
* 没配 RESEND_API_KEY 时全部静默跳过,不影响评论提交。
|
||||
*/
|
||||
|
||||
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
|
||||
const DEFAULT_FROM = '小赵 <noreply@mail.200181.xyz>';
|
||||
|
||||
export function mailEnabled(env: Env): boolean {
|
||||
return !!env.RESEND_API_KEY;
|
||||
}
|
||||
|
||||
export interface MailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
/** 覆盖发件人显示名(对应旧配置里的 email.send_name) */
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
/** 从 `名字 <地址>` 或裸地址里取出纯地址 */
|
||||
function addrOf(from: string): string {
|
||||
const m = /<([^>]+)>/.exec(from || '');
|
||||
return (m ? m[1] : from || '').trim();
|
||||
}
|
||||
|
||||
/** 发一封。失败只记日志,不抛异常 —— 邮件不该影响评论主流程。 */
|
||||
export async function sendMail(env: Env, mail: MailInput): Promise<boolean> {
|
||||
if (!env.RESEND_API_KEY) return false;
|
||||
try {
|
||||
const baseFrom = env.MAIL_FROM || DEFAULT_FROM;
|
||||
const from = mail.fromName
|
||||
? `${mail.fromName} <${addrOf(baseFrom)}>`
|
||||
: baseFrom;
|
||||
|
||||
const res = await fetch(RESEND_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: [mail.to],
|
||||
subject: mail.subject,
|
||||
html: mail.html,
|
||||
...(mail.text ? { text: mail.text } : {}),
|
||||
...(mail.replyTo ? { reply_to: mail.replyTo } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
console.error('[mail] resend 返回', res.status, body.slice(0, 300));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[mail] 请求失败', e instanceof Error ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日发信配额保护。
|
||||
* Resend 免费版是 **100 封/天**,评论突然变多不能把额度打爆。
|
||||
* 复用已有的 rate_limits 表当计数器(bucket = "mail:2026-10-02")。
|
||||
*/
|
||||
export async function mailQuotaOk(env: Env, limit = 90): Promise<boolean> {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const bucket = `mail:${day}`;
|
||||
const t = Date.now();
|
||||
try {
|
||||
const row = await env.DB.prepare('SELECT count, expires_at FROM rate_limits WHERE bucket = ?')
|
||||
.bind(bucket)
|
||||
.first<{ count: number; expires_at: number }>();
|
||||
|
||||
if (row && row.expires_at > t && row.count >= limit) return false;
|
||||
|
||||
if (!row || row.expires_at <= t) {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO rate_limits (bucket, count, expires_at) VALUES (?, 1, ?)
|
||||
ON CONFLICT(bucket) DO UPDATE SET count = 1, expires_at = excluded.expires_at`,
|
||||
)
|
||||
.bind(bucket, t + 86_400_000)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare('UPDATE rate_limits SET count = count + 1 WHERE bucket = ?')
|
||||
.bind(bucket)
|
||||
.run();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return true; // 计数本身失败就别拦着发信
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 模板 —— 与 artalk.yml 里 mail_tpl / notify_tpl 指向的两个文件一致
|
||||
// 改模板只需要动下面两段字符串
|
||||
//
|
||||
// 风格:聊天对话式(自己的评论靠右绿色气泡,对方靠左白色气泡),
|
||||
// 配色清新(浅薄荷底 + 白卡 + 鲜绿主色 #2fa872)。
|
||||
// ============================================================================
|
||||
|
||||
/** 取首字(码点安全,emoji/生僻字不会被截断),用作头像字 */
|
||||
const firstChar = (v: unknown): string => {
|
||||
const s = String(v ?? '').trim();
|
||||
return [...s][0] || '?';
|
||||
};
|
||||
|
||||
/** 回复提醒(原 reply.html) */
|
||||
const REPLY_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title></title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f3f9f5">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
|
||||
</div>
|
||||
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
|
||||
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
|
||||
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 对话</div>
|
||||
|
||||
<div style="display:flex;flex-direction:row-reverse;gap:8px;margin-bottom:14px">
|
||||
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#2fa872;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{parent_comment.name_first}}</div>
|
||||
<div style="max-width:82%">
|
||||
<div style="font-size:11px;color:#8fa096;margin-bottom:4px;text-align:right">{{parent_comment.nick}} · {{parent_comment.datetime}}</div>
|
||||
<div style="background-color:#d7f0e2;border-radius:12px 2px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{parent_comment.content}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px">
|
||||
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
|
||||
<div style="max-width:82%">
|
||||
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||||
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
|
||||
查看并回复
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
|
||||
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
|
||||
如果你不想再收到这类通知,可以在评论时关闭"接收邮件通知"
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/** 管理员通知(原 notice.html) */
|
||||
const NOTICE_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title></title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f3f9f5">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
|
||||
</div>
|
||||
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
|
||||
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
|
||||
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 新留言</div>
|
||||
|
||||
<div style="display:flex;gap:8px">
|
||||
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
|
||||
<div style="max-width:82%">
|
||||
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||||
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
|
||||
前往查看
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
|
||||
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/** 主题沿用 artalk.yml 里的 mail_subject */
|
||||
export const REPLY_SUBJECT = '{{site_name}} · 新的评论回复';
|
||||
export const ADMIN_SUBJECT = '{{site_name}} · 新的评论待审';
|
||||
|
||||
// ------------------------------------------------------------------ 渲染
|
||||
|
||||
const esc = (v: unknown): string =>
|
||||
String(v ?? '').replace(/[&<>"']/g, (m) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m] as string,
|
||||
);
|
||||
|
||||
/**
|
||||
* 评论正文 → 邮件里可用的 HTML。
|
||||
* 先走 Markdown(保留正文里的原生 HTML),再把表情图加上内联宽度 ——
|
||||
* 原模板靠 <style> 里的 `.comment_img img{width:100px}`,而多数邮件客户端会
|
||||
* 丢掉 head 里的样式表,所以这里补一份内联的。
|
||||
*/
|
||||
function contentHtml(raw: string): string {
|
||||
return renderMarkdown(String(raw || '')).replace(
|
||||
/<img\s+([^>]*?)>/gi,
|
||||
(m, attrs: string) =>
|
||||
/atk-emoticon=/i.test(attrs) && !/style=/i.test(attrs)
|
||||
? `<img style="width:100px;height:auto;vertical-align:middle" ${attrs}>`
|
||||
: m,
|
||||
);
|
||||
}
|
||||
|
||||
type Vars = Record<string, string>;
|
||||
|
||||
function commentVars(prefix: string, c: CommentRow, nick: string, email: string): Vars {
|
||||
const d = formatDateCN(c.created_at);
|
||||
return {
|
||||
[`${prefix}.id`]: esc(c.id),
|
||||
[`${prefix}.nick`]: esc(nick),
|
||||
[`${prefix}.name_first`]: esc(firstChar(nick)),
|
||||
[`${prefix}.email_encrypted`]: md5Lower(email || ''),
|
||||
[`${prefix}.email`]: esc(email),
|
||||
[`${prefix}.datetime`]: esc(d),
|
||||
[`${prefix}.date`]: esc(d.slice(0, 10)),
|
||||
[`${prefix}.time`]: esc(d.slice(11)),
|
||||
// 正文与链接是白名单不转义的字段(与原实现 getPurifiedValue 一致)
|
||||
[`${prefix}.content`]: contentHtml(c.content),
|
||||
[`${prefix}.content_marked`]: contentHtml(c.content),
|
||||
[`${prefix}.content_raw`]: esc(c.content),
|
||||
[`${prefix}.page_key`]: esc(c.page_key),
|
||||
};
|
||||
}
|
||||
|
||||
/** 页面可访问链接 + 定位到某条评论(对应官方 GetLinkToReplyByComment) */
|
||||
function linkToReply(pageUrl: string, commentId: number): string {
|
||||
const base = String(pageUrl || '').trim();
|
||||
if (!base) return '';
|
||||
const sep = base.includes('?') ? '&' : '?';
|
||||
return `${base}${sep}atk_comment=${commentId}`;
|
||||
}
|
||||
|
||||
function renderTpl(tpl: string, vars: Vars): string {
|
||||
return tpl.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (m, k: string) => (k in vars ? vars[k] : m));
|
||||
}
|
||||
|
||||
export interface ReplyMailInput {
|
||||
siteName: string;
|
||||
siteUrl: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
/** 被回复者(收信人)的评论 */
|
||||
parentComment: CommentRow;
|
||||
parentNick: string;
|
||||
parentEmail: string;
|
||||
/** 新评论(回复方) */
|
||||
comment: CommentRow;
|
||||
commenterNick: string;
|
||||
commenterEmail: string;
|
||||
/** 收信人昵称,用于 {{nick}} */
|
||||
toNick: string;
|
||||
}
|
||||
|
||||
export function replyMailHtml(o: ReplyMailInput): string {
|
||||
const ltr = linkToReply(o.pageUrl, o.comment.id);
|
||||
const vars: Vars = {
|
||||
site_name: esc(o.siteName),
|
||||
site_name_first: esc(firstChar(o.siteName)),
|
||||
site_url: esc(o.siteUrl),
|
||||
page_title: esc(o.pageTitle),
|
||||
page_url: esc(o.pageUrl),
|
||||
link_to_reply: ltr, // 白名单:不转义
|
||||
nick: esc(o.toNick),
|
||||
reply_nick: esc(o.commenterNick),
|
||||
reply_content: o.comment.content,
|
||||
...commentVars('comment', o.comment, o.commenterNick, o.commenterEmail),
|
||||
...commentVars('from', o.comment, o.commenterNick, o.commenterEmail),
|
||||
...commentVars('parent_comment', o.parentComment, o.parentNick, o.parentEmail),
|
||||
...commentVars('to', o.parentComment, o.parentNick, o.parentEmail),
|
||||
};
|
||||
return renderTpl(REPLY_TPL, vars);
|
||||
}
|
||||
|
||||
export function replyMailSubject(o: ReplyMailInput): string {
|
||||
return renderTpl(REPLY_SUBJECT, {
|
||||
site_name: o.siteName,
|
||||
reply_nick: o.commenterNick,
|
||||
page_title: o.pageTitle,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AdminMailInput {
|
||||
siteName: string;
|
||||
siteUrl: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
comment: CommentRow;
|
||||
commenterNick: string;
|
||||
commenterEmail: string;
|
||||
adminNick: string;
|
||||
}
|
||||
|
||||
export function adminMailHtml(o: AdminMailInput): string {
|
||||
const vars: Vars = {
|
||||
site_name: esc(o.siteName),
|
||||
site_name_first: esc(firstChar(o.siteName)),
|
||||
site_url: esc(o.siteUrl),
|
||||
page_title: esc(o.pageTitle),
|
||||
page_url: esc(o.pageUrl),
|
||||
link_to_reply: linkToReply(o.pageUrl, o.comment.id),
|
||||
nick: esc(o.adminNick),
|
||||
reply_nick: esc(o.commenterNick),
|
||||
reply_content: o.comment.content,
|
||||
...commentVars('comment', o.comment, o.commenterNick, o.commenterEmail),
|
||||
...commentVars('from', o.comment, o.commenterNick, o.commenterEmail),
|
||||
};
|
||||
return renderTpl(NOTICE_TPL, vars);
|
||||
}
|
||||
|
||||
export function adminMailSubject(o: AdminMailInput): string {
|
||||
return renderTpl(ADMIN_SUBJECT, { site_name: o.siteName, page_title: o.pageTitle });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 触发
|
||||
|
||||
export interface NotifyCtx {
|
||||
newComment: CommentRow;
|
||||
author: UserRow;
|
||||
/** 被回复的评论(顶层评论则没有) */
|
||||
parent: CommentRow | null;
|
||||
parentAuthor: UserRow | null;
|
||||
siteName: string;
|
||||
siteUrl: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发评论之后的邮件通知。整体容错:任何一步失败都不影响评论已提交的事实。
|
||||
*
|
||||
* 两个场景:
|
||||
* 1. 回复别人的评论 → 通知被回复者(尊重其 receive_email 设置)
|
||||
* 2. 新评论(顶层)→ 通知管理员(MAIL_ADMIN)
|
||||
*/
|
||||
export async function notifyByEmail(env: Env, c: NotifyCtx): Promise<void> {
|
||||
if (!mailEnabled(env)) return;
|
||||
|
||||
const targets: { to: string; subject: string; html: string; fromName?: string }[] = [];
|
||||
const fromName = (env.MAIL_FROM_NAME || '').trim() || undefined;
|
||||
|
||||
// --- 被回复者
|
||||
if (c.parent && c.parentAuthor) {
|
||||
const pa = c.parentAuthor;
|
||||
const selfReply = pa.id === c.author.id;
|
||||
if (!selfReply && pa.receive_email && pa.email && pa.email.includes('@')) {
|
||||
const input: ReplyMailInput = {
|
||||
siteName: c.siteName,
|
||||
siteUrl: c.siteUrl,
|
||||
pageTitle: c.pageTitle,
|
||||
pageUrl: c.pageUrl,
|
||||
parentComment: c.parent,
|
||||
parentNick: pa.name,
|
||||
parentEmail: pa.email,
|
||||
comment: c.newComment,
|
||||
commenterNick: c.author.name,
|
||||
commenterEmail: c.author.email,
|
||||
toNick: pa.name,
|
||||
};
|
||||
targets.push({
|
||||
to: pa.email,
|
||||
subject: replyMailSubject(input),
|
||||
html: replyMailHtml(input),
|
||||
fromName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 管理员(用 MAIL_ADMIN,不复用 ADMIN_EMAIL)
|
||||
const adminTo = (env.MAIL_ADMIN || '').trim();
|
||||
if (
|
||||
adminTo.includes('@') &&
|
||||
adminTo.toLowerCase() !== c.author.email.toLowerCase() &&
|
||||
(!c.parentAuthor || c.parentAuthor.email.toLowerCase() !== adminTo.toLowerCase())
|
||||
) {
|
||||
const input: AdminMailInput = {
|
||||
siteName: c.siteName,
|
||||
siteUrl: c.siteUrl,
|
||||
pageTitle: c.pageTitle,
|
||||
pageUrl: c.pageUrl,
|
||||
comment: c.newComment,
|
||||
commenterNick: c.author.name,
|
||||
commenterEmail: c.author.email,
|
||||
adminNick: '博主',
|
||||
};
|
||||
targets.push({
|
||||
to: adminTo,
|
||||
subject: adminMailSubject(input),
|
||||
html: adminMailHtml(input),
|
||||
fromName,
|
||||
});
|
||||
}
|
||||
|
||||
if (!targets.length) return;
|
||||
|
||||
for (const t of targets) {
|
||||
if (!(await mailQuotaOk(env))) {
|
||||
console.warn('[mail] 已达当日发信上限,跳过');
|
||||
return;
|
||||
}
|
||||
console.log('[mail] 发送中 →', t.to, '|', t.subject);
|
||||
const okSent = await sendMail(env, t);
|
||||
if (!okSent) console.warn('[mail] 发送失败 →', t.to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// content_marked:Markdown → HTML。
|
||||
//
|
||||
// 官方 Artalk 用 goldmark(GFM + HardWraps + Unsafe) 渲染后再过 bluemonday 的 UGC 白名单清洗。
|
||||
// 这里采取「先抽白名单标签 → 其余全部转义 → 再解析 Markdown」的策略:
|
||||
// 语义上等价于 UGC 白名单之外全部剔除,而且不可能出现漏网标签。
|
||||
//
|
||||
// 唯一保留的裸 HTML 是 <img>:小赵站上 302 条(8%)评论用了表情包,内容是
|
||||
// <img src="https://usj.cc/emotion/douyin/19.png" atk-emoticon="19">
|
||||
// 全部转义的话,后台预览里会变成一堆可见的标签文本。
|
||||
//
|
||||
// 保留范围严格对齐 Artalk 2.8.7 客户端自己的白名单(Artalk.js 里的 allowedTags /
|
||||
// allowedAttributes):img 只留 src / alt / title / atk-emoticon,script、iframe、on* 一律丢。
|
||||
//
|
||||
// 另:客户端其实**不消费** content_marked(它自己用打包的 marked 渲染 content),
|
||||
// 所以这里主要影响后台与导出预览。
|
||||
|
||||
const IMG_TAG_RE = /<img\b[^>]*>/gi;
|
||||
|
||||
const ALLOWED_IMG_ATTRS = ['alt', 'title', 'atk-emoticon'] as const;
|
||||
|
||||
const SAFE_SRC_RE = /^(https?:\/\/|\/|\.\/|data:image\/)/i;
|
||||
|
||||
function readAttr(tag: string, name: string): string | null {
|
||||
// 双引号 / 单引号 / 无引号三种写法都兼容
|
||||
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i');
|
||||
const m = tag.match(re);
|
||||
if (!m) return null;
|
||||
return (m[1] ?? m[2] ?? m[3] ?? '').trim();
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 把一条 <img> 收敛成只带白名单属性的安全标签;不安全就返回 null(当普通文本处理) */
|
||||
function sanitizeImgTag(tag: string): string | null {
|
||||
const src = readAttr(tag, 'src');
|
||||
if (!src) return null;
|
||||
if (!SAFE_SRC_RE.test(src) || /^\s*javascript:/i.test(src)) return null;
|
||||
|
||||
const parts = [`src="${escapeAttr(src)}"`];
|
||||
for (const name of ALLOWED_IMG_ATTRS) {
|
||||
const v = readAttr(tag, name);
|
||||
if (v) parts.push(`${name}="${escapeAttr(v)}"`);
|
||||
}
|
||||
parts.push('referrerpolicy="no-referrer"');
|
||||
return `<img ${parts.join(' ')} />`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function inline(text: string): string {
|
||||
let s = text;
|
||||
|
||||
// 行内代码优先,避免里面的符号被当成语法
|
||||
const codes: string[] = [];
|
||||
s = s.replace(/`([^`\n]+)`/g, (_m, code: string) => {
|
||||
codes.push(`<code>${code}</code>`);
|
||||
return `\u0000${codes.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// 图片
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_m, alt: string, url: string) => {
|
||||
if (!isSafeUrl(url)) return alt;
|
||||
return `<img src="${url}" alt="${alt}" referrerpolicy="no-referrer" />`;
|
||||
});
|
||||
|
||||
// 链接
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_m, label: string, url: string) => {
|
||||
if (!isSafeUrl(url)) return label;
|
||||
return `<a href="${url}" target="_blank" rel="nofollow noopener noreferrer">${label}</a>`;
|
||||
});
|
||||
|
||||
// 裸链接
|
||||
s = s.replace(/(^|[\s(])((?:https?:\/\/|mailto:)[^\s<)]+)/g, (_m, pre: string, url: string) => {
|
||||
const clean = url.replace(/[.,;:!?]+$/, '');
|
||||
const trail = url.slice(clean.length);
|
||||
return `${pre}<a href="${clean}" target="_blank" rel="nofollow noopener noreferrer">${clean}</a>${trail}`;
|
||||
});
|
||||
|
||||
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
|
||||
|
||||
// 还原行内代码
|
||||
s = s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => codes[Number(i)]);
|
||||
return s;
|
||||
}
|
||||
|
||||
function isSafeUrl(url: string): boolean {
|
||||
const u = url.trim().toLowerCase();
|
||||
if (u.startsWith('javascript:') || u.startsWith('data:') || u.startsWith('vbscript:')) return false;
|
||||
return /^(https?:\/\/|mailto:|\/|#|\.)/.test(u) || !u.includes(':');
|
||||
}
|
||||
|
||||
export function renderMarkdown(input: string): string {
|
||||
const raw = String(input ?? '').replace(/\r\n?/g, '\n');
|
||||
|
||||
const blocks: string[] = [];
|
||||
const imgs: string[] = [];
|
||||
|
||||
// 1) 先抽掉围栏代码块(里面的内容一律当字面量,不做二次解析)
|
||||
let text = raw.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang: string, body: string) => {
|
||||
const cls = lang.trim() ? ` class="language-${lang.trim()}"` : '';
|
||||
blocks.push(`<pre><code${cls}>${escapeHtml(body.replace(/\n$/, ''))}</code></pre>`);
|
||||
return `\u0001${blocks.length - 1}\u0001`;
|
||||
});
|
||||
|
||||
// 2) 再抽掉白名单内的 <img>(表情包);其余标签留给第 3 步整体转义
|
||||
text = text.replace(IMG_TAG_RE, (tag) => {
|
||||
const safe = sanitizeImgTag(tag);
|
||||
if (!safe) return tag; // 不安全 → 原样留下,稍后被转义成可见文本
|
||||
imgs.push(safe);
|
||||
return `\u0002${imgs.length - 1}\u0002`;
|
||||
});
|
||||
|
||||
// 3) 整体转义,保证不会有漏网的 HTML
|
||||
text = escapeHtml(text);
|
||||
|
||||
const lines = text.split('\n');
|
||||
const out: string[] = [];
|
||||
let list: 'ul' | 'ol' | null = null;
|
||||
let quote: string[] = [];
|
||||
let para: string[] = [];
|
||||
|
||||
const flushPara = () => {
|
||||
if (para.length) {
|
||||
out.push(`<p>${para.join('<br>')}</p>`);
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
const flushList = () => {
|
||||
if (list) {
|
||||
out.push(`</${list}>`);
|
||||
list = null;
|
||||
}
|
||||
};
|
||||
const flushQuote = () => {
|
||||
if (quote.length) {
|
||||
out.push(`<blockquote><p>${quote.join('<br>')}</p></blockquote>`);
|
||||
quote = [];
|
||||
}
|
||||
};
|
||||
const flushAll = () => {
|
||||
flushPara();
|
||||
flushList();
|
||||
flushQuote();
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// 代码块 / 图片占位符可能独占一行
|
||||
if (/^\s*[\u0001\u0002]\d+[\u0001\u0002]\s*$/.test(line)) {
|
||||
flushAll();
|
||||
out.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushAll();
|
||||
continue;
|
||||
}
|
||||
|
||||
const q = line.match(/^>\s?(.*)$/);
|
||||
if (q) {
|
||||
flushPara();
|
||||
flushList();
|
||||
quote.push(inline(q[1]));
|
||||
continue;
|
||||
}
|
||||
flushQuote();
|
||||
|
||||
const ul = line.match(/^\s*[-*+]\s+(.*)$/);
|
||||
const ol = line.match(/^\s*\d+\.\s+(.*)$/);
|
||||
if (ul || ol) {
|
||||
flushPara();
|
||||
const want: 'ul' | 'ol' = ul ? 'ul' : 'ol';
|
||||
if (list !== want) {
|
||||
flushList();
|
||||
out.push(`<${want}>`);
|
||||
list = want;
|
||||
}
|
||||
out.push(`<li>${inline((ul ? ul[1] : ol![1]) as string)}</li>`);
|
||||
continue;
|
||||
}
|
||||
flushList();
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
flushPara();
|
||||
const level = heading[1].length;
|
||||
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) {
|
||||
flushPara();
|
||||
out.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
|
||||
para.push(inline(line));
|
||||
}
|
||||
flushAll();
|
||||
|
||||
let html = out.join('\n');
|
||||
html = html.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => blocks[Number(i)]);
|
||||
html = html.replace(/\u0002(\d+)\u0002/g, (_m, i: string) => imgs[Number(i)]);
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Artalk 的 email_encrypted 字段 = MD5(email 小写),前端拿它当 Gravatar / Cravatar 的 hash。
|
||||
// Workers 的 crypto.subtle 不提供 MD5,所以这里自带一份。
|
||||
|
||||
const K_HEX = [
|
||||
'd76aa478', 'e8c7b756', '242070db', 'c1bdceee', 'f57c0faf', '4787c62a', 'a8304613', 'fd469501',
|
||||
'698098d8', '8b44f7af', 'ffff5bb1', '895cd7be', '6b901122', 'fd987193', 'a679438e', '49b40821',
|
||||
'f61e2562', 'c040b340', '265e5a51', 'e9b6c7aa', 'd62f105d', '02441453', 'd8a1e681', 'e7d3fbc8',
|
||||
'21e1cde6', 'c33707d6', 'f4d50d87', '455a14ed', 'a9e3e905', 'fcefa3f8', '676f02d9', '8d2a4c8a',
|
||||
'fffa3942', '8771f681', '6d9d6122', 'fde5380c', 'a4beea44', '4bdecfa9', 'f6bb4b60', 'bebfbc70',
|
||||
'289b7ec6', 'eaa127fa', 'd4ef3085', '04881d05', 'd9d4d039', 'e6db99e5', '1fa27cf8', 'c4ac5665',
|
||||
'f4292244', '432aff97', 'ab9423a7', 'fc93a039', '655b59c3', '8f0ccc92', 'ffeff47d', '85845dd1',
|
||||
'6fa87e4f', 'fe2ce6e0', 'a3014314', '4e0811a1', 'f7537e82', 'bd3af235', '2ad7d2bb', 'eb86d391',
|
||||
].map((h) => parseInt(h, 16) >>> 0);
|
||||
|
||||
const S = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
|
||||
function wordToHexLE(n: number): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < 4; i++) out += ((n >>> (i * 8)) & 0xff).toString(16).padStart(2, '0');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function md5(input: string): string {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
const len = bytes.length;
|
||||
const padded = new Uint8Array((((len + 8) >> 6) + 1) * 64);
|
||||
padded.set(bytes);
|
||||
padded[len] = 0x80;
|
||||
|
||||
const dv = new DataView(padded.buffer);
|
||||
const bitLen = len * 8;
|
||||
dv.setUint32(padded.length - 8, bitLen >>> 0, true);
|
||||
dv.setUint32(padded.length - 4, Math.floor(bitLen / 0x100000000), true);
|
||||
|
||||
let a0 = 0x67452301;
|
||||
let b0 = 0xefcdab89;
|
||||
let c0 = 0x98badcfe;
|
||||
let d0 = 0x10325476;
|
||||
|
||||
for (let chunk = 0; chunk < padded.length; chunk += 64) {
|
||||
const M = new Uint32Array(16);
|
||||
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(chunk + i * 4, true);
|
||||
|
||||
let A = a0;
|
||||
let B = b0;
|
||||
let C = c0;
|
||||
let D = d0;
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let F: number;
|
||||
let g: number;
|
||||
if (i < 16) {
|
||||
F = (B & C) | (~B & D);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
F = (D & B) | (~D & C);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
F = B ^ C ^ D;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
F = C ^ (B | ~D);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
|
||||
const sum = (F + A + K_HEX[i] + M[g]) >>> 0;
|
||||
A = D;
|
||||
D = C;
|
||||
C = B;
|
||||
B = (B + (((sum << S[i]) | (sum >>> (32 - S[i]))) >>> 0)) >>> 0;
|
||||
}
|
||||
|
||||
a0 = (a0 + A) >>> 0;
|
||||
b0 = (b0 + B) >>> 0;
|
||||
c0 = (c0 + C) >>> 0;
|
||||
d0 = (d0 + D) >>> 0;
|
||||
}
|
||||
|
||||
return wordToHexLE(a0) + wordToHexLE(b0) + wordToHexLE(c0) + wordToHexLE(d0);
|
||||
}
|
||||
|
||||
export function md5Lower(input: string): string {
|
||||
return md5(input.trim().toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// 抓取主流程:遍历订阅源 → 抓取 → 解析 → 去重 → 通知 → 写 KV
|
||||
// 迁自 check-feeds.js 的 resolveFeed + main,作为 CF Cron 的 scheduled handler。
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { fetchUrl } from './fetch';
|
||||
import { discoverFeedUrl, tryCommonFeedPaths } from './discover';
|
||||
import {
|
||||
parseFeedXml,
|
||||
parseJsonFeed,
|
||||
parseCustomJson,
|
||||
isJsonResponse,
|
||||
type Article,
|
||||
type FeedConfig,
|
||||
type ParsedFeed,
|
||||
} from './parse';
|
||||
import { sendFeishuNotification } from './notify';
|
||||
import { kvGetJson, kvPutJson } from './util';
|
||||
|
||||
const MAX_SEEN = 500;
|
||||
|
||||
/** 单源抓取超时(原来 30s:一个挂掉的源能把整批拖到 4 分钟以上) */
|
||||
const FEED_TIMEOUT = 8000;
|
||||
|
||||
/** 连续失败这么多次就进入退避期,不再每次抓(否则每小时白等 8 秒) */
|
||||
const FAIL_THRESHOLD = 3;
|
||||
/** 退避时长:6 小时(期间只在其它源都抓完后才可能被跳过) */
|
||||
const FAIL_BACKOFF_MS = 6 * 3600 * 1000;
|
||||
|
||||
interface FailRecord { n: number; at: number }
|
||||
|
||||
/** 抓取 + 解析单个订阅源(含 feed 自动发现)。失败返回 null。 */
|
||||
async function resolveFeed(env: Env, feedConfig: FeedConfig): Promise<ParsedFeed | null> {
|
||||
const url = feedConfig.url;
|
||||
const format = feedConfig.format || 'auto';
|
||||
const forceProxy = feedConfig.proxy === true;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchUrl(env, url, FEED_TIMEOUT, forceProxy);
|
||||
} catch {
|
||||
// 直连 + 代理都失败:不再重复抓同一个地址(原来会白等一个超时),
|
||||
// 直接把常见的 feed 路径猜一遍就放弃。
|
||||
response = undefined;
|
||||
}
|
||||
if (!response) {
|
||||
const guessedUrl = await tryCommonFeedPaths(env, url);
|
||||
if (!guessedUrl) return null;
|
||||
try {
|
||||
response = await fetchUrl(env, guessedUrl, FEED_TIMEOUT, forceProxy);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const { text, contentType } = response;
|
||||
const isJson = isJsonResponse(text, contentType);
|
||||
|
||||
if (format === 'json' || (format === 'auto' && isJson)) {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (
|
||||
json.version?.includes('jsonfeed.org') ||
|
||||
(json.items && Array.isArray(json.items) && !feedConfig.path)
|
||||
) {
|
||||
return parseJsonFeed(json, url);
|
||||
}
|
||||
return parseCustomJson(json, feedConfig);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('<rss') ||
|
||||
text.includes('<feed') ||
|
||||
text.includes('<channel') ||
|
||||
text.includes('<entry')
|
||||
) {
|
||||
return parseFeedXml(text, url);
|
||||
}
|
||||
|
||||
// HTML 发现
|
||||
const feedUrl = discoverFeedUrl(text, url);
|
||||
if (feedUrl) {
|
||||
const feedResp = await fetchUrl(env, feedUrl, FEED_TIMEOUT, forceProxy);
|
||||
if (feedResp) {
|
||||
if (isJsonResponse(feedResp.text, feedResp.contentType)) {
|
||||
try {
|
||||
const json = JSON.parse(feedResp.text);
|
||||
if (json.version?.includes('jsonfeed.org') || json.items) return parseJsonFeed(json, feedUrl);
|
||||
return parseCustomJson(json, { url: feedUrl });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parseFeedXml(feedResp.text, feedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const guessedUrl = await tryCommonFeedPaths(env, url);
|
||||
if (guessedUrl) {
|
||||
const guessResp = await fetchUrl(env, guessedUrl, FEED_TIMEOUT, forceProxy);
|
||||
if (guessResp) {
|
||||
if (isJsonResponse(guessResp.text, guessResp.contentType)) {
|
||||
try {
|
||||
const json = JSON.parse(guessResp.text);
|
||||
if (json.version?.includes('jsonfeed.org') || json.items) return parseJsonFeed(json, guessedUrl);
|
||||
return parseCustomJson(json, { url: guessedUrl });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parseFeedXml(guessResp.text, guessedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从远程 JSON / KV 加载订阅源列表 */
|
||||
async function loadFeeds(env: Env): Promise<FeedConfig[]> {
|
||||
// 优先从 KV 读(管理页维护的最新订阅源)
|
||||
const kvFeeds = await kvGetJson<{ feeds: FeedConfig[] }>(env, 'feeds_config', { feeds: [] });
|
||||
if (kvFeeds.feeds && kvFeeds.feeds.length > 0) {
|
||||
return kvFeeds.feeds.map((f) => (typeof f === 'string' ? { url: f } : f));
|
||||
}
|
||||
|
||||
// 回退:FEEDS_URL 远程 JSON
|
||||
if (env.FEEDS_URL) {
|
||||
try {
|
||||
const res = await fetch(env.FEEDS_URL, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)' },
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as any;
|
||||
if (Array.isArray(json)) {
|
||||
return json.map((item: string | FeedConfig) =>
|
||||
typeof item === 'string' ? { url: item } : item,
|
||||
);
|
||||
}
|
||||
if (json.feeds && Array.isArray(json.feeds)) {
|
||||
return json.feeds.map((item: string | FeedConfig) =>
|
||||
typeof item === 'string' ? { url: item } : item,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略,走空
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
interface SeenData {
|
||||
lastCheck: string | null;
|
||||
articles: Record<string, string>;
|
||||
}
|
||||
|
||||
/** scheduled 入口:每天抓取一次 */
|
||||
/**
|
||||
* 抓取一批订阅源。
|
||||
* 免费版 Worker 每次调用限 50 个 subrequest,66 个源全量一把抓会炸,
|
||||
* 所以按 offset/limit 分批轮转(一天两批跑完全部);latest 按 feed 维度合并写入。
|
||||
*/
|
||||
export interface CronStats {
|
||||
/** 本批计划抓取的源数 */
|
||||
batch: number;
|
||||
/** 订阅源总数 */
|
||||
total: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
/** 本批抓到的文章条数(去重前) */
|
||||
articles: number;
|
||||
/** 判定为新文章的条数 */
|
||||
newArticles: number;
|
||||
/** 是否 dry-run(不通知、不写 KV) */
|
||||
dry: boolean;
|
||||
durationMs: number;
|
||||
/** 因连续失败处于退避期、本次跳过的源数 */
|
||||
skipped: number;
|
||||
/** 最慢的几个源,用于排查拖后腿的 feed */
|
||||
slowest: { url: string; ms: number }[];
|
||||
/** 本次失败的源(最多列 10 个,便于排查) */
|
||||
failedUrls: string[];
|
||||
}
|
||||
|
||||
export async function runCron(
|
||||
env: Env,
|
||||
opts?: { offset?: number; limit?: number; dry?: boolean; rotate?: boolean },
|
||||
): Promise<CronStats> {
|
||||
const startedAt = Date.now();
|
||||
const dry = opts?.dry === true;
|
||||
const rotate = opts?.rotate === true;
|
||||
const offset = Math.max(opts?.offset ?? 0, 0);
|
||||
const batchLimit = Math.max(opts?.limit ?? 0, 0);
|
||||
const timings: { url: string; ms: number }[] = [];
|
||||
let okCount = 0;
|
||||
let failedCount = 0;
|
||||
let articleCount = 0;
|
||||
let skippedCount = 0;
|
||||
const failedUrls: string[] = [];
|
||||
const emptyStats: CronStats = {
|
||||
batch: 0, total: 0, ok: 0, failed: 0, articles: 0, newArticles: 0,
|
||||
dry, durationMs: 0, skipped: 0, slowest: [], failedUrls: [],
|
||||
};
|
||||
|
||||
console.log('🔍 开始检查 RSS Feed...');
|
||||
|
||||
const allFeeds = await loadFeeds(env);
|
||||
if (allFeeds.length === 0) {
|
||||
console.log('⚠️ 没有可用的订阅源');
|
||||
return emptyStats;
|
||||
}
|
||||
// 失败退避表:连续失败 >= 阈值的源,在退避期内不再每次白等超时
|
||||
const failMap = await kvGetJson<Record<string, FailRecord>>(env, 'rss_fail', {});
|
||||
const nowTs = Date.now();
|
||||
const inBackoff = (u: string): boolean => {
|
||||
const r = failMap[u];
|
||||
return !!r && r.n >= FAIL_THRESHOLD && nowTs - r.at < FAIL_BACKOFF_MS;
|
||||
};
|
||||
const markFail = (u: string): void => {
|
||||
const r = failMap[u];
|
||||
failMap[u] = { n: (r?.n ?? 0) + 1, at: nowTs };
|
||||
};
|
||||
const clearFail = (u: string): void => {
|
||||
if (failMap[u]) delete failMap[u];
|
||||
};
|
||||
|
||||
console.log(`📋 共 ${allFeeds.length} 个订阅源`);
|
||||
for (const u of Object.keys(failMap)) if (!inBackoff(u)) delete failMap[u]; // 退避期满自动重试
|
||||
|
||||
// 环形取本批:offset 开始取 limit 个(limit=0 表示全量)。
|
||||
// rotate=true 时从 KV 里的游标继续(定时任务用,保证每轮都能覆盖到所有源)。
|
||||
const feeds: FeedConfig[] = [];
|
||||
let cursorAfter = offset;
|
||||
if (batchLimit > 0 && allFeeds.length > batchLimit) {
|
||||
let start = offset % allFeeds.length;
|
||||
if (rotate && !dry) {
|
||||
const saved = await env.RSS_KV.get('rss_cursor');
|
||||
const n = parseInt(saved || '0', 10);
|
||||
if (Number.isFinite(n)) start = ((n % allFeeds.length) + allFeeds.length) % allFeeds.length;
|
||||
}
|
||||
let examined = 0;
|
||||
let i = start;
|
||||
while (feeds.length < batchLimit && examined < allFeeds.length) {
|
||||
const f = allFeeds[i % allFeeds.length];
|
||||
i += 1;
|
||||
examined += 1;
|
||||
if (inBackoff(f.url)) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
feeds.push(f);
|
||||
}
|
||||
cursorAfter = i % allFeeds.length;
|
||||
if (rotate && !dry) await env.RSS_KV.put('rss_cursor', String(cursorAfter));
|
||||
console.log(
|
||||
`📋 本批 ${feeds.length} 个(cursor=${start}${skippedCount ? `,跳过退避中 ${skippedCount} 个` : ''})`,
|
||||
);
|
||||
} else {
|
||||
feeds.push(...allFeeds);
|
||||
}
|
||||
|
||||
const seenData = await kvGetJson<SeenData>(env, 'seen_articles', {
|
||||
lastCheck: null,
|
||||
articles: {},
|
||||
});
|
||||
|
||||
const allNewArticles: Article[] = [];
|
||||
const allFetchedArticles: (Article & { siteUrl: string })[] = [];
|
||||
|
||||
for (const feedConfig of feeds) {
|
||||
const url = feedConfig.url;
|
||||
console.log(`🔍 检查: ${url}`);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const result = await resolveFeed(env, feedConfig);
|
||||
timings.push({ url, ms: Date.now() - t0 });
|
||||
if (!result) {
|
||||
failedCount++;
|
||||
markFail(url);
|
||||
if (failedUrls.length < 10) failedUrls.push(url);
|
||||
console.log(' ❌ 未找到 Feed');
|
||||
continue;
|
||||
}
|
||||
okCount++;
|
||||
articleCount += result.articles.length;
|
||||
clearFail(url);
|
||||
|
||||
const { feedTitle, articles } = result;
|
||||
console.log(` 📰 ${feedTitle || '未知'} - 共 ${articles.length} 篇`);
|
||||
|
||||
const recentArticles = articles.slice(0, 10);
|
||||
allFetchedArticles.push(
|
||||
...recentArticles.map((a) => ({ ...a, feedTitle: feedTitle || '未知', siteUrl: url })),
|
||||
);
|
||||
const newOnes = recentArticles.filter((a) => !seenData.articles[a.link]);
|
||||
|
||||
if (newOnes.length > 0) {
|
||||
console.log(` 🆕 发现 ${newOnes.length} 篇新文章`);
|
||||
allNewArticles.push(...newOnes);
|
||||
} else {
|
||||
console.log(' ✅ 无新文章');
|
||||
}
|
||||
|
||||
for (const a of recentArticles) {
|
||||
seenData.articles[a.link] = a.pubDate;
|
||||
}
|
||||
} catch (err) {
|
||||
timings.push({ url, ms: Date.now() - t0 });
|
||||
failedCount++;
|
||||
markFail(url);
|
||||
if (failedUrls.length < 10) failedUrls.push(url);
|
||||
console.error(` ❌ 抓取失败: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 通知(仅最近 24h 内的)
|
||||
const oneDayAgo = Date.now() - 86400000;
|
||||
const recentNew = allNewArticles.filter(
|
||||
(a) => new Date(a.pubDate).getTime() > oneDayAgo || !seenData.lastCheck,
|
||||
);
|
||||
|
||||
if (dry) {
|
||||
console.log(`🧪 dry-run:跳过通知(本应通知 ${recentNew.length} 篇)与 KV 写入`);
|
||||
} else if (recentNew.length > 0) {
|
||||
await sendFeishuNotification(env, recentNew);
|
||||
} else if (allNewArticles.length > 0) {
|
||||
console.log('ℹ️ 发现新文章但超过 24 小时,不推送');
|
||||
} else {
|
||||
console.log('✅ 所有博客均无新文章');
|
||||
}
|
||||
|
||||
// 写 latest 缓存(供 /api/results、/api/articles 读)
|
||||
// 分批模式:本批源的条目替换旧缓存里的同源条目,其他源的保留
|
||||
const byFeed: Record<string, { articles: Article[]; siteUrl: string }> = {};
|
||||
for (const a of allFetchedArticles) {
|
||||
const key = a.feedTitle || '未知';
|
||||
if (!byFeed[key]) byFeed[key] = { articles: [], siteUrl: a.siteUrl || '' };
|
||||
byFeed[key].articles.push({ title: a.title, link: a.link, pubDate: a.pubDate, author: a.author, feedTitle: key });
|
||||
}
|
||||
|
||||
if (!dry) {
|
||||
try {
|
||||
await kvPutJson(env, 'rss_fail', failMap);
|
||||
} catch {
|
||||
/* 失败表写失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const stats: CronStats = {
|
||||
batch: feeds.length,
|
||||
total: allFeeds.length,
|
||||
ok: okCount,
|
||||
failed: failedCount,
|
||||
articles: articleCount,
|
||||
newArticles: allNewArticles.length,
|
||||
dry,
|
||||
durationMs,
|
||||
skipped: skippedCount,
|
||||
slowest: timings.sort((a, b) => b.ms - a.ms).slice(0, 5),
|
||||
failedUrls,
|
||||
};
|
||||
if (dry) return stats;
|
||||
|
||||
const prev = await kvGetJson<{ timestamp: string | null; total: number; feeds: { name: string; siteUrl: string; favicon: string; articles: Article[] }[] }>(
|
||||
env,
|
||||
'latest',
|
||||
{ timestamp: null, total: 0, feeds: [] },
|
||||
);
|
||||
const batchUrls = new Set(feeds.map((f) => f.url));
|
||||
const kept = prev.feeds.filter((f) => !batchUrls.has(f.siteUrl || f.name));
|
||||
const mergedFeeds = [
|
||||
...kept,
|
||||
...Object.entries(byFeed).map(([name, { articles, siteUrl }]) => ({
|
||||
name,
|
||||
siteUrl,
|
||||
// 必须写绝对地址:友圈页在 usj.cc 上,相对路径 /api/* 会 404 → 退化成第三方默认头像
|
||||
favicon: siteUrl
|
||||
? `${env.PUBLIC_API_BASE || 'https://api.200181.xyz'}/api/favicon?url=${encodeURIComponent(siteUrl)}`
|
||||
: '',
|
||||
articles,
|
||||
})),
|
||||
];
|
||||
const mergedTotal = mergedFeeds.reduce((n, f) => n + f.articles.length, 0);
|
||||
|
||||
await kvPutJson(env, 'latest', {
|
||||
timestamp: new Date().toISOString(),
|
||||
total: mergedTotal,
|
||||
feeds: mergedFeeds,
|
||||
});
|
||||
|
||||
// 清理 + 保存去重记录
|
||||
const entries = Object.entries(seenData.articles);
|
||||
if (entries.length > MAX_SEEN) {
|
||||
entries.sort((a, b) => new Date(b[1]).getTime() - new Date(a[1]).getTime());
|
||||
seenData.articles = Object.fromEntries(entries.slice(0, MAX_SEEN));
|
||||
}
|
||||
seenData.lastCheck = new Date().toISOString();
|
||||
await kvPutJson(env, 'seen_articles', seenData);
|
||||
|
||||
console.log(`📝 已保存去重记录 (${Object.keys(seenData.articles).length} 条)`);
|
||||
return stats;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Feed 自动发现:从 HTML 里找 RSS/Atom/JSON Feed 链接,或试常见路径
|
||||
// 迁自 check-feeds.js 的 discoverFeedUrl / tryCommonFeedPaths
|
||||
|
||||
import { probeDirect } from './fetch';
|
||||
import type { Env } from '../../types';
|
||||
|
||||
export function discoverFeedUrl(html: string, baseUrl: string): string | null {
|
||||
const patterns = [
|
||||
/<link[^>]+type=["']application\/rss\+xml["'][^>]+href=["']([^"']+)["']/i,
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+type=["']application\/rss\+xml["']/i,
|
||||
/<link[^>]+type=["']application\/atom\+xml["'][^>]+href=["']([^"']+)["']/i,
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+type=["']application\/atom\+xml["']/i,
|
||||
/<link[^>]+type=["']application\/feed\+json["'][^>]+href=["']([^"']+)["']/i,
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+type=["']application\/feed\+json["']/i,
|
||||
/<link[^>]+type=["']application\/json["'][^>]+title=["'][^"']*feed[^"']*["'][^>]+href=["']([^"']+)["']/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match) {
|
||||
let href = match[1];
|
||||
const urlObj = new URL(baseUrl);
|
||||
if (href.startsWith('/')) {
|
||||
href = `${urlObj.protocol}//${urlObj.host}${href}`;
|
||||
} else if (!href.startsWith('http')) {
|
||||
href = `${urlObj.protocol}//${urlObj.host}/${href}`;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function tryCommonFeedPaths(env: Env, baseUrl: string): Promise<string | null> {
|
||||
const urlObj = new URL(baseUrl);
|
||||
// 只留最常见的三个:候选多一个,最坏情况就多等一个超时(原来的 9 个候选能把
|
||||
// 单个源拖到 180 秒以上,整批跑几分钟)
|
||||
const paths = ['/feed', '/feed.xml', '/atom.xml'];
|
||||
for (const path of paths) {
|
||||
const candidate = `${urlObj.protocol}//${urlObj.host}${path}`;
|
||||
try {
|
||||
const { contentType } = await probeDirect(candidate, 4000);
|
||||
const ct = contentType.toLowerCase();
|
||||
if (ct.includes('xml') || ct.includes('rss') || ct.includes('atom') || ct.includes('json')) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// 忽略,继续试
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 抓取逻辑:直连 + 腾讯云 SCF 国内代理回退
|
||||
// 迁自 check-feeds.js 的 fetchUrl / fetchViaProxy。
|
||||
//
|
||||
// 关键:CF Worker 跑在境外节点,抓国内博客可能被拦。
|
||||
// 复用已搭好的腾讯云 SCF(scfapi.usj.cc,国内 IP)作为回退代理。
|
||||
// 原 EdgeOne 的 /api/proxy 也是境外节点,迁移后不再需要——Worker 直连即等价。
|
||||
|
||||
import type { Env } from '../../types';
|
||||
|
||||
// 单源超时:原来 30s 太长(一个挂掉的源能把整批拖到 4 分钟以上)。
|
||||
// 正常 RSS 1-3 秒就能回来,8 秒足够;代理(国内 SCF)多给 4 秒余量。
|
||||
const REQUEST_TIMEOUT = 8000;
|
||||
const PROXY_TIMEOUT_EXTRA = 4000;
|
||||
|
||||
// ── 代理熔断 ─────────────────────────────────────────────────────────────
|
||||
// 国内代理(scfapi.usj.cc)证书过期/挂掉时,每个源都要先直连超时、再代理超时,
|
||||
// 一批 20 多个源能白等好几分钟。连续失败若干次后就暂时跳过代理,省掉这一半时间。
|
||||
let proxyFailStreak = 0;
|
||||
let proxySkipUntil = 0;
|
||||
const PROXY_FAIL_THRESHOLD = 5;
|
||||
const PROXY_SKIP_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface FetchResult {
|
||||
text: string;
|
||||
contentType: string;
|
||||
via: 'direct' | 'proxy';
|
||||
}
|
||||
|
||||
/** 直连抓取 */
|
||||
async function fetchDirect(url: string, timeout: number): Promise<FetchResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const res = 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',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
||||
return { text, contentType, via: 'direct' };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过腾讯云 SCF 国内代理抓取 */
|
||||
async function fetchViaSCF(
|
||||
env: Env,
|
||||
url: string,
|
||||
timeout: number,
|
||||
): Promise<FetchResult> {
|
||||
const proxyUrl = env.SCF_PROXY_URL;
|
||||
if (!proxyUrl) throw new Error('SCF_PROXY_URL not configured');
|
||||
if (Date.now() < proxySkipUntil) throw new Error('proxy temporarily disabled (recent failures)');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const res = await fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, timeout }),
|
||||
});
|
||||
// 代理这一层通了(HTTP 2xx)就不算代理故障
|
||||
if (res.ok) {
|
||||
proxyFailStreak = 0;
|
||||
proxySkipUntil = 0;
|
||||
}
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
if (!data.ok) {
|
||||
// 代理连得上,是目标站点抓不动(站点挂了/被墙)→ 不熔断代理
|
||||
throw new Error(data.error || 'target fetch failed');
|
||||
}
|
||||
if (data.status && data.status >= 400) throw new Error(`目标 HTTP ${data.status}`);
|
||||
proxyFailStreak = 0;
|
||||
return {
|
||||
text: data.body || '',
|
||||
contentType: (data.contentType || '').toLowerCase(),
|
||||
via: 'proxy',
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || '';
|
||||
// 只有"连不上代理/代理返回错误状态"才计故障;目标站点失败不算
|
||||
const proxyLevelFailure = /^(HTTP \d|proxy temporarily disabled|SCF_PROXY_URL)/.test(msg) ||
|
||||
/fetch failed|Network|TLS|abort/i.test(msg);
|
||||
if (proxyLevelFailure && !/target fetch failed/.test(msg)) {
|
||||
proxyFailStreak += 1;
|
||||
if (proxyFailStreak >= PROXY_FAIL_THRESHOLD) {
|
||||
proxySkipUntil = Date.now() + PROXY_SKIP_MS;
|
||||
proxyFailStreak = 0;
|
||||
console.log('⚠️ 国内代理连续失败,暂时跳过代理(10 分钟)');
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只走直连的轻量探测:用于"猜常见 feed 路径"。
|
||||
* 猜路径最多试几个候选,如果每个都再走一遍代理,一个挂掉的源能拖 3 分钟
|
||||
* (实测有单源 237 秒),所以探测只用直连 + 短超时。
|
||||
*/
|
||||
export async function probeDirect(url: string, timeout = 4000): Promise<FetchResult> {
|
||||
return fetchDirect(url, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取一个 URL:先直连,失败回退 SCF 代理。
|
||||
* 与 check-feeds.js 一致:feed.proxy === true 时强制走代理。
|
||||
*/
|
||||
export async function fetchUrl(
|
||||
env: Env,
|
||||
url: string,
|
||||
timeout = REQUEST_TIMEOUT,
|
||||
forceProxy = false,
|
||||
): Promise<FetchResult> {
|
||||
const proxyTimeout = timeout + PROXY_TIMEOUT_EXTRA;
|
||||
if (forceProxy) {
|
||||
return fetchViaSCF(env, url, proxyTimeout);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchDirect(url, timeout);
|
||||
} catch (directErr) {
|
||||
// 直连失败 → 回退国内代理
|
||||
try {
|
||||
const viaProxy = await fetchViaSCF(env, url, proxyTimeout);
|
||||
return viaProxy;
|
||||
} catch (proxyErr) {
|
||||
throw new Error(
|
||||
`直连失败(${(directErr as Error).message}),代理也失败(${(proxyErr as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 默认问候语词库(迁自 edgeone/functions/api/ai/greeting.js 的 defaultPool)
|
||||
|
||||
export interface Greeting {
|
||||
text: string;
|
||||
time: string | null;
|
||||
holiday: string | null;
|
||||
}
|
||||
|
||||
export function defaultPool(): Greeting[] {
|
||||
return [
|
||||
{ text: '早上好,今天的咖啡够浓吗', time: 'weekday-morning', holiday: null },
|
||||
{ text: '新的一天,新的 bug 等着你', time: 'weekday-morning', holiday: null },
|
||||
{ text: '周一的闹钟总是响得特别早', time: 'weekday-morning', holiday: null },
|
||||
{ text: '周二了,距离周末还有 3 天', time: 'weekday-morning', holiday: null },
|
||||
{ text: '周三,一周的折返点', time: 'weekday-morning', holiday: null },
|
||||
{ text: '周四了,胜利在望', time: 'weekday-morning', holiday: null },
|
||||
{ text: '周五早晨的空气都是甜的', time: 'weekday-morning', holiday: null },
|
||||
{ text: '上班前来看看博客吧', time: 'weekday-morning', holiday: null },
|
||||
{ text: '通勤路上,刷一篇好文章', time: 'weekday-morning', holiday: null },
|
||||
{ text: '阳光正好,写点什么吧', time: 'morning', holiday: null },
|
||||
{ text: '一杯茶,一篇文章,一个上午', time: 'morning', holiday: null },
|
||||
{ text: '灵感总在上午悄悄来访', time: 'morning', holiday: null },
|
||||
{ text: '窗外鸟鸣,键盘轻敲', time: 'morning', holiday: null },
|
||||
{ text: '午饭吃好了吗,来读篇博客', time: 'noon', holiday: null },
|
||||
{ text: '午休时间,偷得浮生一刻闲', time: 'noon', holiday: null },
|
||||
{ text: '饱了才有力气写代码', time: 'noon', holiday: null },
|
||||
{ text: '午餐后的惬意,属于博客时光', time: 'noon', holiday: null },
|
||||
{ text: '午后阳光很暖,文字也很温柔', time: 'afternoon', holiday: null },
|
||||
{ text: '来杯下午茶,配一篇好博客', time: 'afternoon', holiday: null },
|
||||
{ text: '不想工作的时候,就读博客吧', time: 'afternoon', holiday: null },
|
||||
{ text: '下午三点,正是摸鱼好时光', time: 'afternoon', holiday: null },
|
||||
{ text: '代码写累了,换个脑子', time: 'afternoon', holiday: null },
|
||||
{ text: '夕阳之下,该给今天收个尾了', time: 'evening', holiday: null },
|
||||
{ text: '晚霞温柔,适合安静地读点东西', time: 'evening', holiday: null },
|
||||
{ text: '下班了吗,博客等你回家', time: 'evening', holiday: null },
|
||||
{ text: '暮色四合,一天又悄悄过去了', time: 'evening', holiday: null },
|
||||
{ text: '夜深了,只有你还在折腾博客吧', time: 'night', holiday: null },
|
||||
{ text: '凌晨三点,灵感比白天更活跃', time: 'night', holiday: null },
|
||||
{ text: '熬夜冠军,博客世界永远亮着灯', time: 'night', holiday: null },
|
||||
{ text: '星星都睡了,你的博客还醒着', time: 'night', holiday: null },
|
||||
{ text: '深夜的代码写给自己看', time: 'night', holiday: null },
|
||||
{ text: '失眠的夜晚,幸好有博客陪伴', time: 'night', holiday: null },
|
||||
{ text: '周末不用早起,但可以早起写博客', time: 'weekend', holiday: null },
|
||||
{ text: '窝在沙发里,手机刷博客', time: 'weekend', holiday: null },
|
||||
{ text: '周末的早晨,适合赖床和码字', time: 'weekend', holiday: null },
|
||||
{ text: '终于有空了,把攒了一周的文章读完', time: 'weekend', holiday: null },
|
||||
{ text: '周末宅家,博客是最好的伴侣', time: 'weekend', holiday: null },
|
||||
{ text: '咖啡 + 面包 + 博客 = 完美周末', time: 'weekend', holiday: null },
|
||||
{ text: '没有 deadline 的周末,写点想写的', time: 'weekend', holiday: null },
|
||||
{ text: '元旦快乐,新的一年从一篇博客开始', time: null, holiday: '01-01' },
|
||||
{ text: '新年新气象,博客也要更新啦', time: null, holiday: '01-01' },
|
||||
{ text: '元旦快乐,今年第一篇写什么', time: null, holiday: '01-01' },
|
||||
{ text: '情人节快乐,代码和爱情可以兼得', time: null, holiday: '02-14' },
|
||||
{ text: '今天不写代码,陪 ta 看看博客', time: null, holiday: '02-14' },
|
||||
{ text: '三八妇女节,致敬所有闪闪发光的她', time: null, holiday: '03-08' },
|
||||
{ text: '愚人节快乐,今天看到什么都别信', time: null, holiday: '04-01' },
|
||||
{ text: '劳动节快乐,今天不写代码', time: null, holiday: '05-01' },
|
||||
{ text: '五一劳动节,劳动者最光荣', time: null, holiday: '05-01' },
|
||||
{ text: '五四青年节,趁年轻多写点博客', time: null, holiday: '05-04' },
|
||||
{ text: '六一快乐,谁还不是个孩子呢', time: null, holiday: '06-01' },
|
||||
{ text: '国庆快乐,祖国繁荣昌盛', time: null, holiday: '10-01' },
|
||||
{ text: '假期余额不多,抓紧时间写博客', time: null, holiday: '10-01' },
|
||||
{ text: '圣诞快乐,博客就是你的圣诞老人', time: null, holiday: '12-25' },
|
||||
{ text: '圣诞夜,许个愿,明年博客涨粉', time: null, holiday: '12-25' },
|
||||
{ text: '立春了,博客也要焕发新生', time: null, holiday: '02-04' },
|
||||
{ text: '春分时节,昼夜平分,灵感均分', time: null, holiday: '03-20' },
|
||||
{ text: '夏至已至,白天很长,文章也可以很长', time: null, holiday: '06-21' },
|
||||
{ text: '秋分,收获的季节,盘点一下今年的博客', time: null, holiday: '09-23' },
|
||||
{ text: '冬至了,吃碗饺子暖暖心,写篇博客暖暖手', time: null, holiday: '12-22' },
|
||||
{ text: '又是美好的一天', time: null, holiday: null },
|
||||
{ text: '今天想写点什么吗', time: null, holiday: null },
|
||||
{ text: '来博客串个门吧', time: null, holiday: null },
|
||||
{ text: '保持好奇心,世界不会无趣', time: null, holiday: null },
|
||||
{ text: '你有多久没有好好写点东西了', time: null, holiday: null },
|
||||
{ text: '每个博客都是一扇窗', time: null, holiday: null },
|
||||
{ text: '写作是和自己的对话', time: null, holiday: null },
|
||||
{ text: '今天遇到什么有趣的事了吗', time: null, holiday: null },
|
||||
{ text: '读别人的故事,写自己的心情', time: null, holiday: null },
|
||||
{ text: '博客是一个人的宇宙', time: null, holiday: null },
|
||||
{ text: '别让灵感溜走,赶紧码下来', time: null, holiday: null },
|
||||
{ text: '有人默默关注着你的博客呢', time: null, holiday: null },
|
||||
{ text: '每个字都是时间的印记', time: null, holiday: null },
|
||||
{ text: '博客不老,我们永远年轻', time: null, holiday: null },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 通知推送:飞书(邮件后续可加)
|
||||
// 迁自 check-feeds.js 的 sendFeishuNotification + formatRelativeTime
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import type { Article } from './parse';
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const diff = Date.now() - new Date(isoDate).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} 天前`;
|
||||
return new Date(isoDate).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function sendFeishuNotification(env: Env, newArticles: Article[]): Promise<void> {
|
||||
if (!env.FEISHU_WEBHOOK_URL) {
|
||||
console.log('⚠️ 未配置 FEISHU_WEBHOOK_URL,跳过飞书通知');
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped: Record<string, Article[]> = {};
|
||||
for (const article of newArticles) {
|
||||
const key = article.feedTitle || '未知博客';
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(article);
|
||||
}
|
||||
|
||||
const elements: unknown[] = [];
|
||||
for (const [feedName, articles] of Object.entries(grouped)) {
|
||||
elements.push({ tag: 'markdown', content: `**📰 ${feedName}**` });
|
||||
const articleLines = articles.map(
|
||||
(a) => `- [${a.title}](${a.link}) <font color="grey">${formatRelativeTime(a.pubDate)}</font>`,
|
||||
);
|
||||
elements.push({ tag: 'markdown', content: articleLines.join('\n') });
|
||||
}
|
||||
|
||||
const card = {
|
||||
msg_type: 'interactive',
|
||||
card: {
|
||||
header: {
|
||||
title: { tag: 'plain_text', content: `🔔 博客更新 · ${newArticles.length} 篇新文章` },
|
||||
template: 'blue',
|
||||
},
|
||||
elements,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(env.FEISHU_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(card),
|
||||
});
|
||||
const result = (await res.json()) as { code?: number };
|
||||
if (result.code === 0) {
|
||||
console.log(`✅ 飞书通知发送成功 (${newArticles.length} 篇新文章)`);
|
||||
} else {
|
||||
console.error('❌ 飞书通知发送失败:', result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ 飞书通知发送异常:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// RSS / Atom / JSON Feed / 自定义 JSON 解析
|
||||
// 迁自 check-feeds.js 的 parseFeedXml / parseJsonFeed / parseCustomJson 等。
|
||||
|
||||
export interface Article {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
author: string;
|
||||
feedTitle: string;
|
||||
}
|
||||
|
||||
export interface FeedConfig {
|
||||
url: string;
|
||||
feedTitle?: string;
|
||||
format?: string;
|
||||
path?: string;
|
||||
proxy?: boolean;
|
||||
mapping?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ParsedFeed {
|
||||
feedTitle: string;
|
||||
articles: Article[];
|
||||
}
|
||||
|
||||
function extractTag(xml: string, tag: string): string {
|
||||
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function decodeXml(str: string): string {
|
||||
return str
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function parseDate(val: unknown): string | null {
|
||||
if (!val) return null;
|
||||
if (typeof val === 'number') {
|
||||
const d = new Date(val > 9999999999 ? val : val * 1000);
|
||||
return isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
const str = String(val).trim();
|
||||
if (!str) return null;
|
||||
const d = new Date(str);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
try {
|
||||
return d.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFeedXml(xml: string, feedUrl: string): ParsedFeed {
|
||||
const articles: Article[] = [];
|
||||
let feedTitle = '';
|
||||
|
||||
const titleMatch = xml.match(/<title[^>]*>([\s\S]*?)<\/title>/);
|
||||
if (titleMatch) feedTitle = decodeXml(titleMatch[1]).trim();
|
||||
|
||||
const itemRegex = /<item[\s>]?>([\s\S]*?)<\/item>/gi;
|
||||
const entryRegex = /<entry[\s>]?>([\s\S]*?)<\/entry>/gi;
|
||||
|
||||
const parseItem = (itemXml: string) => {
|
||||
const title = extractTag(itemXml, 'title');
|
||||
let link = '';
|
||||
|
||||
const rssLink = extractTag(itemXml, 'link');
|
||||
if (rssLink) link = rssLink;
|
||||
|
||||
const atomLinkMatch = itemXml.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i);
|
||||
if (atomLinkMatch && atomLinkMatch[1]) link = atomLinkMatch[1];
|
||||
|
||||
const altLinkMatch = itemXml.match(
|
||||
/<link[^>]+rel=["']alternate["'][^>]+href=["']([^"']+)["']/i,
|
||||
);
|
||||
if (altLinkMatch) link = altLinkMatch[1];
|
||||
|
||||
const pubDate =
|
||||
extractTag(itemXml, 'pubDate') ||
|
||||
extractTag(itemXml, 'published') ||
|
||||
extractTag(itemXml, 'updated') ||
|
||||
extractTag(itemXml, 'dc:date') ||
|
||||
extractTag(itemXml, 'lastBuildDate') ||
|
||||
extractTag(itemXml, 'date') ||
|
||||
extractTag(itemXml, 'modified') ||
|
||||
extractTag(itemXml, 'created');
|
||||
|
||||
const author = extractTag(itemXml, 'dc:creator') || extractTag(itemXml, 'author') || '';
|
||||
const authorName = author.replace(/<name>([\s\S]*?)<\/name>/gi, '$1').trim();
|
||||
|
||||
if (title && link) {
|
||||
articles.push({
|
||||
title: decodeXml(title).trim(),
|
||||
link: link.trim(),
|
||||
pubDate: parseDate(pubDate) || new Date().toISOString(),
|
||||
author: decodeXml(authorName).trim(),
|
||||
feedTitle: feedTitle || feedUrl,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = itemRegex.exec(xml)) !== null) parseItem(match[1]);
|
||||
while ((match = entryRegex.exec(xml)) !== null) parseItem(match[1]);
|
||||
|
||||
return { feedTitle, articles };
|
||||
}
|
||||
|
||||
export function parseJsonFeed(json: any, feedUrl: string): ParsedFeed {
|
||||
const articles: Article[] = [];
|
||||
const feedTitle = json.title || feedUrl;
|
||||
const items = json.items || [];
|
||||
|
||||
for (const item of items) {
|
||||
const title = item.title || '';
|
||||
const link = item.url || item.id || '';
|
||||
const pubDate =
|
||||
item.date_published || item.date_modified || item.date || item.published || item.modified || item.created_at || item.createdAt || item.pubDate || item.timestamp || null;
|
||||
const author = Array.isArray(item.authors)
|
||||
? item.authors.map((a: any) => a.name).join(', ')
|
||||
: item.author?.name || item.author || '';
|
||||
|
||||
if (title && link) {
|
||||
articles.push({
|
||||
title: title.trim(),
|
||||
link: link.trim(),
|
||||
pubDate: parseDate(pubDate) || new Date().toISOString(),
|
||||
author: typeof author === 'string' ? author.trim() : '',
|
||||
feedTitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { feedTitle, articles };
|
||||
}
|
||||
|
||||
function getNestedValue(obj: any, path?: string): unknown {
|
||||
if (!obj || !path) return undefined;
|
||||
return path.split('.').reduce((o: any, key) => o?.[key], obj);
|
||||
}
|
||||
|
||||
export function parseCustomJson(json: any, config: FeedConfig): ParsedFeed {
|
||||
const articles: Article[] = [];
|
||||
const mapping = config.mapping || {};
|
||||
const path = config.path || '';
|
||||
|
||||
let data: any = json;
|
||||
if (path) {
|
||||
for (const key of path.split('.')) {
|
||||
if (data && typeof data === 'object') data = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
|
||||
const titleKey = mapping.title || 'title';
|
||||
const linkKey = mapping.link || 'link';
|
||||
const pubDateKey = mapping.pubDate || 'pubDate';
|
||||
const authorKey = mapping.author || 'author';
|
||||
const feedTitleKey = mapping.feedTitle || 'feedTitle';
|
||||
|
||||
for (const item of items) {
|
||||
const title = getNestedValue(item, titleKey) || '';
|
||||
const link = getNestedValue(item, linkKey) || getNestedValue(item, 'url') || '';
|
||||
const pubDate =
|
||||
getNestedValue(item, pubDateKey) ||
|
||||
getNestedValue(item, 'publishedAt') ||
|
||||
getNestedValue(item, 'createdAt') ||
|
||||
getNestedValue(item, 'created_at') ||
|
||||
getNestedValue(item, 'date') ||
|
||||
getNestedValue(item, 'published') ||
|
||||
getNestedValue(item, 'updatedAt') ||
|
||||
getNestedValue(item, 'timestamp') ||
|
||||
getNestedValue(item, 'datePublished') ||
|
||||
getNestedValue(item, 'dateModified') ||
|
||||
null;
|
||||
const author = getNestedValue(item, authorKey) || '';
|
||||
const feedTitle = getNestedValue(item, feedTitleKey) || config.feedTitle || config.url || '';
|
||||
|
||||
if (title && link) {
|
||||
articles.push({
|
||||
title: String(title).trim(),
|
||||
link: String(link).trim(),
|
||||
pubDate: parseDate(pubDate) || new Date().toISOString(),
|
||||
author: String(typeof author === 'object' ? '' : author).trim(),
|
||||
feedTitle: String(feedTitle),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { feedTitle: config.feedTitle || config.url || '', articles };
|
||||
}
|
||||
|
||||
export function isJsonResponse(text: string, contentType: string): boolean {
|
||||
if (contentType?.includes('json')) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return typeof parsed === 'object' && parsed !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 国内代理(腾讯云 SCF)体检。
|
||||
//
|
||||
// 为什么要在服务器端做:代理挂掉(最常见的原因是 SCF 自定义域名的免费证书 90 天到期、
|
||||
// 而它不会自动续)会让抓国内博客全部失败,但这件事本地电脑开着才会被想起来。
|
||||
// 这里放进 Worker 自己的每日 cron:电脑关着也能发提醒邮件。
|
||||
//
|
||||
// 判断方式不看证书日期,直接"能不能抓到东西" —— 用国内知名站点探测,
|
||||
// 任意一个成功就认为代理可用(比解析证书更贴近实际效果)。
|
||||
|
||||
import type { Env } from '../../types';
|
||||
import { notifyAdmin } from '../admin-notify';
|
||||
|
||||
/** 探测站:国内可直连、响应快、不易挂 */
|
||||
const PROBE_URLS = ['https://www.rz.sb', 'https://blog.qydzz.cn', 'https://t-t.live'];
|
||||
const PROBE_TIMEOUT_MS = 12000;
|
||||
|
||||
export interface ProxyProbe {
|
||||
url: string;
|
||||
ok: boolean;
|
||||
ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProxyHealth {
|
||||
/** 至少一个探测站成功 */
|
||||
ok: boolean;
|
||||
probes: ProxyProbe[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/** 通过代理抓一个 URL(与 RSS 抓取走同一条链路) */
|
||||
async function probe(env: Env, target: string): Promise<ProxyProbe> {
|
||||
const started = Date.now();
|
||||
const proxyUrl = env.SCF_PROXY_URL;
|
||||
if (!proxyUrl) return { url: target, ok: false, ms: 0, error: 'SCF_PROXY_URL not configured' };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: target, timeout: PROBE_TIMEOUT_MS }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
|
||||
const ms = Date.now() - started;
|
||||
if (res.ok && data.ok) return { url: target, ok: true, ms };
|
||||
return { url: target, ok: false, ms, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { url: target, ok: false, ms: Date.now() - started, error: (e as Error).message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkProxyHealth(env: Env): Promise<ProxyHealth> {
|
||||
const probes: ProxyProbe[] = [];
|
||||
for (const target of PROBE_URLS) {
|
||||
const r = await probe(env, target);
|
||||
probes.push(r);
|
||||
if (r.ok) break; // 有一个通就算代理没问题,不用继续
|
||||
}
|
||||
return {
|
||||
ok: probes.some((p) => p.ok),
|
||||
probes,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 代理不可用 → 给站长发提醒(48 小时最多一封,避免重复刷屏) */
|
||||
export async function notifyProxyDown(env: Env, health: ProxyHealth) {
|
||||
const lines = health.probes
|
||||
.map((p) => `· ${p.url}:${p.ok ? `OK(${p.ms}ms)` : `失败(${p.error || '未知'})`}`)
|
||||
.join('\n');
|
||||
const text =
|
||||
`国内代理 scfapi.usj.cc 体检不通过:${health.probes.length} 个探测站全部抓不到。\n\n` +
|
||||
`探测结果:\n${lines}\n\n` +
|
||||
'影响:Cloudflare Worker 抓国内博客时的代理回退会全部失败,友圈/订阅数据会停止更新(直连只对少数境外可访问的站点有效)。\n\n' +
|
||||
'最常见原因:scfapi.usj.cc 用的是腾讯云 SCF 自定义域名上的免费 DV 证书,90 天到期且不会自动续。\n\n' +
|
||||
'续期步骤(约 5 分钟):\n' +
|
||||
'1) 腾讯云 SSL 证书控制台 → 申请免费证书:域名 scfapi.usj.cc,验证方式选「自动 DNS 验证」(usj.cc 托管在 DNSPod,几分钟签发)\n' +
|
||||
'2) 打开 https://console.cloud.tencent.com/scf/domain?rid=1 (云函数 → 高级能力 → 自定义域名,广州地域)→ 找到 scfapi.usj.cc → 编辑 → HTTPS 里重新选择刚签发的新证书 → 保存\n' +
|
||||
'3) 验证:curl -s -X POST https://scfapi.usj.cc -H "Content-Type: application/json" -d \'{"url":"https://www.rz.sb","timeout":15000}\' 返回 {"ok":true,...} 即恢复\n\n' +
|
||||
'(本提醒由服务器端每日任务发出,同一状态最多两天一封;恢复后自动停止。)';
|
||||
|
||||
return notifyAdmin(env, {
|
||||
subject: '【提醒】国内代理 scfapi.usj.cc 不可用(多半是证书过期)',
|
||||
text,
|
||||
dedupeKey: 'scf-proxy-down',
|
||||
minGapHours: 48,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 通用工具:鉴权、CORS、KV 封装
|
||||
// 把原 EdgeOne 每个 api/*.js 里重复的 requireAuth / respond / corsOptions 收拢到一处
|
||||
|
||||
import type { Env } from '../../types';
|
||||
|
||||
export const CORS_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
} as const;
|
||||
|
||||
export function respond(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), { status, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export function corsOptions(): Response {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/** 校验站点口令:URL ?token= / Cookie site_token=。KV 无口令时放行(与原实现一致)。 */
|
||||
export async function requireAuth(request: Request, env: Env): Promise<boolean> {
|
||||
const url = new URL(request.url);
|
||||
const cookie = request.headers.get('Cookie') || '';
|
||||
const cookieMatch = cookie.match(/site_token=([^;]+)/);
|
||||
const token = url.searchParams.get('token') || (cookieMatch ? cookieMatch[1] : '');
|
||||
|
||||
const password = await env.RSS_KV.get('site_password');
|
||||
if (!password) return true; // 未设置口令 → 放行
|
||||
return token === password;
|
||||
}
|
||||
|
||||
/** 读 KV 里的 JSON,解析失败或不存在返回 fallback */
|
||||
export async function kvGetJson<T>(env: Env, key: string, fallback: T): Promise<T> {
|
||||
const raw = await env.RSS_KV.get(key);
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function kvPutJson(env: Env, key: string, value: unknown): Promise<void> {
|
||||
await env.RSS_KV.put(key, JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { Env, SessionUser, UserRow } from '../types';
|
||||
import { findUserByEmail, findUserById, getAdminUsers } from './db';
|
||||
import { md5 } from './md5';
|
||||
import { now } from './util';
|
||||
|
||||
// ================================================================= 编解码
|
||||
|
||||
function b64urlEncode(bytes: Uint8Array | string): string {
|
||||
const buf = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes;
|
||||
let bin = '';
|
||||
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function b64urlDecodeToText(s: string): string {
|
||||
const pad = s.length % 4 ? '='.repeat(4 - (s.length % 4)) : '';
|
||||
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function b64ToBytes(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// ================================================================= 密码
|
||||
|
||||
// ★ 不要超过 100000:Cloudflare Workers 生产环境的 WebCrypto 会直接报
|
||||
// "Pbkdf2 failed: iteration counts above 100000 are not supported"。
|
||||
// 本地 workerd 不拦这一条,所以只在线上才会炸——别再往上调。
|
||||
const PBKDF2_ITER = 100000;
|
||||
|
||||
export async function hashPassword(plain: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const bits = await pbkdf2(plain, salt, PBKDF2_ITER);
|
||||
return `(pbkdf2)${PBKDF2_ITER}$${bytesToB64(salt)}$${bytesToB64(bits)}`;
|
||||
}
|
||||
|
||||
async function pbkdf2(plain: string, salt: Uint8Array, iter: number): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(plain), 'PBKDF2', false, [
|
||||
'deriveBits',
|
||||
]);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as unknown as BufferSource, iterations: iter },
|
||||
key,
|
||||
256,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
/** 兼容 Artalk 的三种存储格式:(bcrypt) 不支持,这里只处理 pbkdf2 / md5 / 明文 */
|
||||
export async function verifyPassword(stored: string, input: string): Promise<boolean> {
|
||||
const v = (stored || '').trim();
|
||||
if (!v) return false;
|
||||
|
||||
if (v.startsWith('(pbkdf2)')) {
|
||||
const body = v.slice('(pbkdf2)'.length);
|
||||
const [iterStr, saltB64, hashB64] = body.split('$');
|
||||
const iter = parseInt(iterStr, 10);
|
||||
if (!iter || !saltB64 || !hashB64) return false;
|
||||
const bits = await pbkdf2(input, b64ToBytes(saltB64), iter);
|
||||
return timingSafeEqual(bytesToB64(bits), hashB64);
|
||||
}
|
||||
|
||||
if (v.startsWith('(md5)')) {
|
||||
return timingSafeEqual(md5(input), v.slice('(md5)'.length).toLowerCase());
|
||||
}
|
||||
|
||||
if (v.startsWith('(bcrypt)')) {
|
||||
// Workers 里不引入 bcrypt 依赖。若迁移自官方 Artalk 的 bcrypt 密码,
|
||||
// 请在后台重设一次密码(会写成 pbkdf2)。
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(input, v);
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// ================================================================= Token
|
||||
|
||||
const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天,与官方一致
|
||||
|
||||
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function signToken(env: Env, userId: number): Promise<string> {
|
||||
const payload = b64urlEncode(
|
||||
JSON.stringify({ uid: userId, iat: now(), exp: now() + TOKEN_TTL_MS }),
|
||||
);
|
||||
const key = await hmacKey(env.TOKEN_SECRET);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
|
||||
return `${payload}.${b64urlEncode(new Uint8Array(sig))}`;
|
||||
}
|
||||
|
||||
export async function verifyToken(
|
||||
env: Env,
|
||||
token: string,
|
||||
): Promise<{ uid: number; iat: number } | null> {
|
||||
const parts = (token || '').split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [payload, sig] = parts;
|
||||
try {
|
||||
const key = await hmacKey(env.TOKEN_SECRET);
|
||||
const pad = sig.length % 4 ? '='.repeat(4 - (sig.length % 4)) : '';
|
||||
const bin = atob(sig.replace(/-/g, '+').replace(/_/g, '/') + pad);
|
||||
const sigBytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) sigBytes[i] = bin.charCodeAt(i);
|
||||
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
sigBytes as unknown as BufferSource,
|
||||
new TextEncoder().encode(payload),
|
||||
);
|
||||
if (!valid) return null;
|
||||
|
||||
const data = JSON.parse(b64urlDecodeToText(payload)) as { uid: number; iat: number; exp: number };
|
||||
if (!data.exp || data.exp < now()) return null;
|
||||
return { uid: data.uid, iat: data.iat };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================= 当前用户
|
||||
|
||||
export async function userFromToken(env: Env, authHeader: string | null): Promise<UserRow | null> {
|
||||
if (!authHeader) return null;
|
||||
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (!m) return null;
|
||||
|
||||
const payload = await verifyToken(env, m[1].trim());
|
||||
if (!payload) return null;
|
||||
|
||||
const user = await findUserById(env, payload.uid);
|
||||
if (!user) return null;
|
||||
|
||||
// 改过密码 / 被踢下线:token_valid_from 之后的签发才有效
|
||||
if (user.token_valid_from && payload.iat < user.token_valid_from) return null;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function sessionOf(user: UserRow | null): SessionUser | null {
|
||||
if (!user) return null;
|
||||
return { id: user.id, name: user.name, email: user.email, isAdmin: !!user.is_admin };
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求是否拥有管理员权限:
|
||||
* 1) 带合法 token 且用户 is_admin=1,或
|
||||
* 2) 配置里的 admin_users(name+email 匹配)—— 兼容官方 Artalk 的 config 管理员
|
||||
*/
|
||||
export async function isAdminRequest(
|
||||
env: Env,
|
||||
req: Request,
|
||||
user?: UserRow | null,
|
||||
): Promise<boolean> {
|
||||
const u = user === undefined ? await userFromToken(env, req.headers.get('Authorization')) : user;
|
||||
if (u?.is_admin) return true;
|
||||
|
||||
const admins = await getAdminUsers(env);
|
||||
// 已登录用户:与配置里的管理员邮箱一致也算
|
||||
if (u && admins.some((a) => a.email && a.email.toLowerCase() === u.email.toLowerCase())) return true;
|
||||
|
||||
// 匿名请求:Artalk 允许带 name/email 的查询参数来判定
|
||||
const url = new URL(req.url);
|
||||
const name = url.searchParams.get('name') || '';
|
||||
const email = url.searchParams.get('email') || '';
|
||||
if (name && email) {
|
||||
return admins.some(
|
||||
(a) => a.name === name && a.email.toLowerCase() === email.toLowerCase(),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function isAdminByNameEmail(env: Env, name: string, email: string): Promise<boolean> {
|
||||
if (!name || !email) return false;
|
||||
const admins = await getAdminUsers(env);
|
||||
const hitConf = admins.some(
|
||||
(a) => a.name === name && a.email.toLowerCase() === email.toLowerCase(),
|
||||
);
|
||||
if (hitConf) return true;
|
||||
const existing = (await findUserByEmail(env, email)).find((u) => u.name === name);
|
||||
return !!existing?.is_admin;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Env } from '../types';
|
||||
|
||||
export const CN_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
/** 毫秒时间戳 → "YYYY-MM-DD HH:mm:ss"(Asia/Shanghai,中国无夏令时) */
|
||||
export function formatDateCN(ms: number): string {
|
||||
const d = new Date((ms || 0) + CN_OFFSET_MS);
|
||||
const p = (n: number, len = 2) => String(n).padStart(len, '0');
|
||||
return (
|
||||
`${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` +
|
||||
`${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
export function now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 响应封装
|
||||
|
||||
export function json(data: unknown, init: ResponseInit = {}): Response {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Content-Type', 'application/json; charset=utf-8');
|
||||
return new Response(JSON.stringify(data ?? {}), { ...init, headers });
|
||||
}
|
||||
|
||||
/** Artalk 成功响应:RespData 是「裸数据」,RespSuccess 是 {"msg":"Success"} */
|
||||
export function ok(data: unknown): Response {
|
||||
return json(data);
|
||||
}
|
||||
|
||||
export function okMsg(msg = 'Success', extra: Record<string, unknown> = {}): Response {
|
||||
return json({ msg, ...extra });
|
||||
}
|
||||
|
||||
/** Artalk 错误响应:{"msg": "..."} + 非 200 状态码 */
|
||||
export function fail(status: number, msg: string, extra: Record<string, unknown> = {}): Response {
|
||||
return json({ msg, ...extra }, { status });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 请求解析
|
||||
|
||||
export function getClientIP(req: Request): string {
|
||||
return (
|
||||
req.headers.get('CF-Connecting-IP') ||
|
||||
(req.headers.get('X-Forwarded-For') || '').split(',')[0].trim() ||
|
||||
'127.0.0.1'
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserAgent(req: Request): string {
|
||||
return req.headers.get('User-Agent') || '';
|
||||
}
|
||||
|
||||
export async function readBody(req: Request): Promise<Record<string, any>> {
|
||||
const ct = req.headers.get('Content-Type') || '';
|
||||
try {
|
||||
if (ct.includes('application/json')) {
|
||||
const v = await req.json();
|
||||
return v && typeof v === 'object' ? (v as Record<string, any>) : {};
|
||||
}
|
||||
if (ct.includes('form-urlencoded')) {
|
||||
const text = await req.text();
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, v] of new URLSearchParams(text)) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
const text = await req.text();
|
||||
if (!text) return {};
|
||||
try {
|
||||
const v = JSON.parse(text);
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
} catch {
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, v] of new URLSearchParams(text)) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function qp(url: URL, key: string): string {
|
||||
return url.searchParams.get(key) ?? '';
|
||||
}
|
||||
|
||||
export function qInt(url: URL, key: string, dft = 0): number {
|
||||
const v = parseInt(url.searchParams.get(key) ?? '', 10);
|
||||
return Number.isFinite(v) ? v : dft;
|
||||
}
|
||||
|
||||
export function qBool(url: URL, key: string): boolean {
|
||||
const v = (url.searchParams.get(key) ?? '').toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes';
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 校验
|
||||
|
||||
export function isEmail(s: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((s || '').trim());
|
||||
}
|
||||
|
||||
export function isUrl(s: string): boolean {
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function trimTo(s: string, max: number): string {
|
||||
const v = String(s ?? '');
|
||||
return v.length > max ? v.slice(0, max) : v;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CORS
|
||||
|
||||
export function corsHeaders(req: Request, env: Env): Headers {
|
||||
const headers = new Headers();
|
||||
const origin = req.headers.get('Origin') || '';
|
||||
const allow = (env.ALLOWED_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (origin && (allow.includes(origin) || allow.includes('*'))) {
|
||||
headers.set('Access-Control-Allow-Origin', origin);
|
||||
} else if (!origin) {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
headers.set('Vary', 'Origin');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
||||
headers.set(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Content-Type,Authorization,X-Requested-With,X-Api-Version',
|
||||
);
|
||||
headers.set('Access-Control-Max-Age', '86400');
|
||||
return headers;
|
||||
}
|
||||
Reference in new issue
Block a user