184 lines
7.4 KiB
TypeScript
184 lines
7.4 KiB
TypeScript
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';
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- 评论
|
|||
|
|
router.get('/comments', C.listComments);
|
|||
|
|
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('/votes/:target_name/:target_id', P.voteGet);
|
|||
|
|
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/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);
|
|||
|
|
|
|||
|
|
export default {
|
|||
|
|
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|||
|
|
const url = new URL(req.url);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 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);
|
|||
|
|
} 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 缓存)
|
|||
|
|
async scheduled(event: ScheduledController, env: Env): Promise<void> {
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|