import type { Env } from './types'; import { Router } from './router'; import { corsHeaders } from './lib/util'; import { friendlyError } from './lib/errors'; import { gcCaptcha, gcRateLimits } from './lib/db'; import { checkProxyHealth, notifyProxyDown } from './lib/rss/proxy-health'; import { RssApi } from './rss-api'; import * as C from './routes/comments'; import * as P from './routes/public'; import * as U from './routes/user'; import * as A from './routes/admin'; import * as H from './routes/human'; import * as E from './routes/editor'; import * as S from './routes/ssl'; const router = new Router(); // ---------------------------------------------------------------- 公共 / 配置 router.all('/conf', P.getConf); router.get('/version', P.getVersion); router.get('/conf/domain', P.getConfDomain); router.get('/conf/auth/providers', P.getAuthProviders); router.get('/healthz', P.healthz); router.post('/setup', P.setup); // ---------------------------------------------------------------- 验证码 router.get('/captcha', P.getCaptcha); router.get('/captcha/status', P.getCaptchaStatus); router.post('/captcha/verify', P.verifyCaptcha); // ------------------------------------------------- 人机验证(自研「一键验证」) router.get('/human/challenge', H.humanChallenge); router.post('/human/verify', H.humanVerify); router.get('/human/status', H.humanStatus); // ---------------------------------------------------------------- 文章编辑 // 本 Worker 不碰仓库:只是鉴权后用 X-Editor-Token 反代到 editor-api 容器。 // 必须挂在 /api/v2 下 —— /api/*(非 v2)已被上面的 RSS 模块整个接走。 router.get('/editor/health', E.health); router.get('/editor/posts', E.listPosts); router.post('/editor/posts', E.createPost); router.get('/editor/posts/:slug', E.getPost); router.put('/editor/posts/:slug', E.savePost); router.del('/editor/posts/:slug', E.deletePost); router.post('/editor/upload', E.upload); router.get('/editor/git/status', E.gitStatus); router.post('/editor/git/publish', E.publish); router.post('/editor/git/sync', E.sync); router.get('/editor/handoff', E.handoff); // ---------------------------------------------------------------- SSL 证书管家 // 与 artalk-cf 同一个 Worker,共用后台会话与 KV。鉴权在自己的模块里 // (只认 Bearer 会话,放行 admin + ssl 两种角色),这里只负责挂路径。 // ★ 必须挂在 /api/v2 下 —— /api/*(非 v2)已被 RSS 模块整个接走。 for (const r of S.SSL_ROUTES) { if (r.method === 'GET') router.get(r.path, r.handler); else if (r.method === 'POST') router.post(r.path, r.handler); else if (r.method === 'PUT') router.put(r.path, r.handler); else router.del(r.path, r.handler); } // ---------------------------------------------------------------- 评论 // ★ /comments/lookup 必须注册在 /comments/:id 之前 —— 路由按注册顺序匹配, // 两段路径下 `:id` 会把 "lookup" 吃掉。 router.get('/comments', C.listComments); router.get('/comments/lookup', C.lookupCommenter); router.post('/comments', C.createComment); router.get('/comments/:id', C.getComment); router.put('/comments/:id', C.updateComment); router.del('/comments/:id', C.deleteComment); router.get('/featured/comments', C.listFeaturedComments); // ---------------------------------------------------------------- 投票 / 浏览量 // ★ 投票:Artalk 客户端把「方向」编进 target_name(comment_up / comment_down / // page_up / page_down),路径只有两段 → `POST /votes/:target_name/:target_id`。 // 早期这里只注册了三段形式(:choice),与客户端对不上,导致点赞点了 404 没反应。 // 现在两段(客户端契约)与三段(旧形式)都收。 router.get('/votes/:target_name/:target_id', P.voteGet); router.post('/votes/:target_name/:target_id', P.voteCreate); router.post('/votes/:target_name/:target_id/:choice', P.voteCreate); router.post('/votes/sync', P.voteSync); router.post('/pages/pv', P.pagePV); // ---------------------------------------------------------------- 用户 / 登录 router.get('/user', U.userInfo); router.post('/user', U.userUpdate); router.get('/user/status', U.userStatus); router.post('/user/access_token', U.userAccessToken); router.post('/auth/email/login', U.authEmailLogin); router.post('/auth/email/send', U.authEmailSend); router.post('/auth/email/register', U.authEmailRegister); router.get('/auth/merge', U.authMergeCheck); router.post('/auth/merge', U.authMergeApply); router.post('/sso/exchange', U.ssoExchange); // ---------------------------------------------------------------- 后台:设置 router.get('/settings', A.settingGet); router.put('/settings', A.settingApply); router.get('/settings/form', A.settingFormGet); router.put('/settings/form', A.settingFormApply); router.get('/settings/template/:locale', A.settingTemplate); // ---------------------------------------------------------------- 后台:站点 router.get('/sites', A.siteList); router.post('/sites', A.siteCreate); router.put('/sites/:id', A.siteUpdate); router.del('/sites/:id', A.siteDelete); // ---------------------------------------------------------------- 后台:页面 router.get('/pages', A.pageList); router.post('/pages/fetch', A.pageFetch); router.get('/pages/fetch/status', A.pageFetchStatus); router.post('/pages/:id/fetch', A.pageFetch); router.put('/pages/:id', A.pageUpdate); router.del('/pages/:id', A.pageDelete); // ---------------------------------------------------------------- 后台:用户 router.post('/users', A.userCreate); router.get('/users/:type', A.userList); router.put('/users/:id', A.userUpdateAdmin); router.del('/users/:id', A.userDeleteAdmin); // ---------------------------------------------------------------- 后台:消息 / 统计 / 缓存 router.get('/notifies', A.notifyList); router.post('/notifies/read', A.notifyReadAll); router.post('/notifies/:comment_id/:notify_key', A.notifyReadOne); router.get('/stats/:type', A.stat); router.post('/cache/flush', A.cacheFlush); router.post('/cache/warm_up', A.cacheWarmUp); // ---------------------------------------------------------------- 后台:上传 / 迁移 router.post('/upload', A.upload); router.get('/transfer/export', A.transferExport); router.post('/transfer/import', A.transferImport); router.post('/transfer/upload', A.transferUpload); // ---------------------------------------------------------------- 请求分发 async function dispatchRequest( req: Request, env: Env, ctx: ExecutionContext, url: URL, ): Promise { // R2 里的图片:/file/** 或 /api/v2/file/** if (req.method === 'GET' && /^(\/api\/v2)?\/file\//.test(url.pathname)) { return A.serveFile({ env, req, url, params: {}, user: null, }); } // RSS 订阅模块(rss-robot 合并进来):/api/*(Artalk 用 /api/v2/* 不会撞) // + /feed 订阅源管理页 + 微信验证文件 const p = url.pathname; if ( (p.startsWith('/api/') && !p.startsWith('/api/v2')) || p === '/feed' || p.startsWith('/MP_verify_') ) { return RssApi.fetch(req, env, ctx); } return router.dispatch(req, env, ctx); } // ---------------------------------------------------------------- 缓存策略兜底 // // ★ 为什么需要这个:Worker 开启了 Workers Cache(wrangler.toml 的 [cache] // enabled = true)之后,Cloudflare 会在**调用 Worker 之前**先查缓存。 // 而 Workers Cache 遵循 RFC 9111 —— 包括**启发式缓存**:一个完全不带 // Cache-Control 的响应也可能被判为可缓存! // // 实测教训:`/api/v2/comments` 本来就没设 Cache-Control,开启 Workers Cache // 后立刻被缓存(cf-cache-status: HIT, age: 29)→ 用户发的新评论看不见。 // // 规则(白名单式,只放行明确声明可缓存的): // - 上游已带任意 Cache-Control / CDN-Cache-Control → 原样保留(如 favicon 的 // s-maxage=86400,它本来就该缓存) // - 其余一律补 `Cache-Control: no-store`(评论、登录、管理接口等禁止缓存) // // ★ 必须是白名单思路:漏掉一个接口 = 数据陈旧 bug;而多补 no-store 只损失一点性能。 function ensureCachePolicy(res: Response, req: Request): Response { // 只处理 GET/HEAD(Workers Cache 本就只缓存这两种;其余原样返回) if (req.method !== 'GET' && req.method !== 'HEAD') return res; const hasExplicitPolicy = res.headers.has('Cache-Control') || res.headers.has('CDN-Cache-Control') || res.headers.has('Cloudflare-CDN-Cache-Control'); if (hasExplicitPolicy) return res; // 其余(含 3xx / 5xx)一律禁止缓存:宁可少缓存,不可把数据或错误固化 const headers = new Headers(res.headers); headers.set('Cache-Control', 'no-store'); return new Response(res.body, { status: res.status, statusText: res.statusText, headers, }); } export default { async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(req.url); try { const res = await dispatchRequest(req, env, ctx, url); return ensureCachePolicy(res, req); } catch (e) { // 兜底:任何漏网的异常也必须带 CORS 头,否则浏览器把它当"网络故障", // 读者只能看到 "TypeError: Failed to fetch",根本不知道发生了什么。 const err = friendlyError(e); console.error('[artalk-cf] unhandled:', err.code, err.detail); let cors = new Headers(); try { cors = corsHeaders(req, env); } catch { cors.set('Access-Control-Allow-Origin', '*'); } cors.set('Content-Type', 'application/json; charset=utf-8'); cors.set('Cache-Control', 'no-store'); return new Response( JSON.stringify({ msg: err.msg, code: err.code, detail: err.detail }), { status: err.status, headers: cors }, ); } }, // 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源) // 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存) // // ★ 2026-10-06:`10 4 * * *` 的**证书续期检查已移除** —— 签发+部署整条链路 // 搬到了国内机的 Docker 容器 `cn-certkeeper`(见 deploy/cn-certkeeper/)。 // 两边同时跑会重复签发(白耗 CA 配额)并争抢同一批站点部署。 // Worker 侧只保留**只读**能力:证书状态 / 自检 / 后台展示。 async scheduled(event: ScheduledController, env: Env): Promise { if (event.cron === '0 * * * *') { await RssApi.scheduled(event, env, undefined as unknown as ExecutionContext); return; } try { await gcRateLimits(env); await gcCaptcha(env); // 顺带刷新 healthz 用的评论数缓存:COUNT(*) 是全表扫描, // 放在 healthz 里每次请求都烧几千行 rows_read,挪到这里一天只烧一次 const n = await env.DB.prepare('SELECT COUNT(*) AS n FROM comments WHERE deleted_at = 0') .first<{ n: number }>(); if (n?.n != null) { await env.DB.prepare( `INSERT INTO settings (key, value, updated_at) VALUES ('healthz_comments', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, ) .bind(JSON.stringify(n.n), Date.now()) .run(); } } catch (e) { console.error('[artalk-cf] scheduled gc failed:', e instanceof Error ? e.message : e); } // 国内代理体检(每天一次):挂掉就给站长发提醒邮件。 // 刻意放在服务器端 —— 站长电脑关着也能收到,不依赖本地定时任务。 try { const health = await checkProxyHealth(env); if (!health.ok) { const r = await notifyProxyDown(env, health); console.log('[artalk-cf] proxy down, notify:', JSON.stringify(r)); } } catch (e) { console.error('[artalk-cf] proxy health check failed:', e instanceof Error ? e.message : e); } }, };