Files
work-hours-tracker/server/core/api.js
T
OpenClaw Agent 26306eee52 feat: 设置页 MCP 管理卡片(方案B)— DB 真开关 + 即时生效 + env 仅首次种子
- app_settings 表(key/value):MCP 开关与令牌存 DB,不属于任何用户、不参与同步协议
- 配置读取/写入收敛到 core/mcp.js(readMcpConfig/saveMcpToken/setMcpEnabled)
- 状态语义:未配置→404(端点视为不存在);已配置关闭→503(可重新开启);令牌错→401
- env MCP_AUTH_TOKEN 仅首次种子(库里从没有过令牌时迁入 DB),不设也行;后台改动即时生效无需重启
- 管理 API(登录态):GET /api/mcp/status(掩码)、POST /api/mcp/token(生成/重置,明文仅一次)、GET /api/mcp/token(复制用)、POST /api/mcp/enabled
- 设置页新增「AI 接入 (MCP)」卡片:开关 toggle、掩码展示、一键生成并复制(writeClipboard 兜底)、重置令牌;未登录徽章「本设备请先登录」
- 入口接线:node-server.js 与 worker.js 均挂 /mcp(seedToken 透传);sw.js 放行裸 /mcp(v2→v3)
- 两个适配器补 getSetting/setSetting(node:sqlite 与 D1 同语义)
- 测试:server/test/mcp.test.js 39 项(未配置关闭/鉴权/JSON-RPC 全链路/开关热切换/令牌轮换);npm test 全绿(107+72+39)
- 线上 E2E:生产令牌 initialize/tools 7 个/2026-09 大盘(出勤11天/82h/挣得1599);公网 HTTPS 链路通;UI puppeteer 17/17(含关→503→开→200 热切换,全程无重启)
- 文档:README + server/README(环境变量表/接口表/数据模型/MCP 章节)
2026-09-13 10:24:33 +08:00

279 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 平台无关的 API 核心。
// 输入标准 Request,输出标准 Response,不碰任何 Node 或 Workers 专属 API。
// VPS(node-server.js)与 Cloudflare Workers(worker.js)都只是把请求转进来。
import {
hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS
} from './auth.js';
import {
readMcpConfig, saveMcpToken, setMcpEnabled, newMcpToken, maskToken
} from './mcp.js';
import {
BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings,
recordRowToClient, adjustRowToClient, DAY_RE, MONTH_RE
} from './store.js';
export const API_VERSION = 1;
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' };
function json(data, status = 200, extra = {}) {
return new Response(JSON.stringify(data), { status, headers: { ...JSON_HEADERS, ...extra } });
}
function err(message, status = 400, code) {
return json({ ok: false, error: message, code: code || undefined }, status);
}
async function readJson(request, maxBytes = 4 * 1024 * 1024) {
const len = Number(request.headers.get('content-length') || 0);
if (len > maxBytes) throw new BadRequest('请求体过大');
let text;
try { text = await request.text(); } catch { throw new BadRequest('请求体读取失败'); }
if (text.length > maxBytes) throw new BadRequest('请求体过大');
if (!text) return {};
try { return JSON.parse(text); } catch { throw new BadRequest('请求体不是合法 JSON'); }
}
/** 简易滑窗限流:登录/注册专用,防在线爆破。内存态,Workers 冷启动会重置,够用。 */
class RateLimiter {
constructor(limit = 10, windowMs = 60000) {
this.limit = limit; this.windowMs = windowMs; this.hits = new Map();
}
check(key) {
const now = Date.now();
const arr = (this.hits.get(key) || []).filter(t => now - t < this.windowMs);
if (arr.length >= this.limit) return false;
arr.push(now);
this.hits.set(key, arr);
if (this.hits.size > 5000) this.hits.clear(); // 兜底,别把内存吃了
return true;
}
}
const loginLimiter = new RateLimiter(10, 60000);
function clientKey(request) {
return request.headers.get('cf-connecting-ip')
|| request.headers.get('x-forwarded-for')
|| request.headers.get('x-real-ip')
|| 'local';
}
async function requireAuth(store, request) {
const token = bearerFrom(request);
if (!token) throw Object.assign(new Error('未登录'), { status: 401 });
const hit = await store.getSessionWithUser(await hashToken(token));
if (!hit) throw Object.assign(new Error('登录已失效,请重新登录'), { status: 401 });
if (new Date(hit.session.expires_at).getTime() < Date.now()) {
await store.deleteSession(hit.session.token_hash);
throw Object.assign(new Error('登录已过期,请重新登录'), { status: 401 });
}
return hit.user;
}
function validUsername(u) {
return typeof u === 'string' && /^[A-Za-z0-9_.-]{3,32}$/.test(u);
}
/**
* 主入口。
* @param {Request} request
* @param {object} store 适配器实例(见 core/store.js 顶部契约)
* @param {object} opts { allowRegister } allowRegister=false 时只允许首个用户注册
*/
export async function handleApi(request, store, opts = {}) {
const url = new URL(request.url);
const path = url.pathname.replace(/\/+$/, '') || '/';
const method = request.method.toUpperCase();
if (method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'access-control-allow-origin': request.headers.get('origin') || '*',
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
'access-control-allow-headers': 'content-type,authorization',
'access-control-max-age': '86400'
}
});
}
try {
// ---------- 健康检查 ----------
if (path === '/api/health' && method === 'GET') {
return json({ ok: true, api: API_VERSION, users: await store.countUsers(), now: new Date().toISOString() });
}
// ---------- 注册 ----------
// 默认策略:只有库里还没有任何用户时才允许注册(首用户即管理员)。
// 这样公网暴露也不会被陌生人开号。要开放注册就显式传 allowRegister:true。
if (path === '/api/register' && method === 'POST') {
if (!loginLimiter.check('reg:' + clientKey(request))) return err('操作过于频繁,请稍后再试', 429);
const body = await readJson(request);
const username = String(body.username || '').trim();
const password = String(body.password || '');
if (!validUsername(username)) return err('用户名需 3–32 位,仅限字母数字与 _ . -');
if (password.length < 8) return err('密码至少 8 位');
const existing = await store.countUsers();
if (existing > 0 && !opts.allowRegister) return err('注册已关闭', 403, 'REGISTER_CLOSED');
if (await store.getUserByUsername(username)) return err('用户名已被占用', 409);
const { pwHash, pwSalt, pwIter } = await hashPassword(password);
const user = await store.createUser({ username, pwHash, pwSalt, pwIter, createdAt: new Date().toISOString() });
return json({ ok: true, user: { id: user.id, username: user.username } }, 201);
}
// ---------- 登录 ----------
if (path === '/api/login' && method === 'POST') {
if (!loginLimiter.check('login:' + clientKey(request))) return err('尝试过于频繁,请稍后再试', 429);
const body = await readJson(request);
const username = String(body.username || '').trim();
const password = String(body.password || '');
const user = await store.getUserByUsername(username);
// 用户不存在也要走一次真实的密码校验开销,否则能靠响应时间枚举用户名
const okPw = user
? await verifyPassword(password, user)
: await verifyPassword(password, { pw_salt: '00'.repeat(16), pw_iter: 100000, pw_hash: '' });
if (!user || !okPw) return err('用户名或密码错误', 401);
const token = newToken();
const now = new Date();
await store.createSession({
tokenHash: await hashToken(token),
userId: user.id,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + SESSION_TTL_MS).toISOString()
});
await store.purgeExpiredSessions(now.toISOString());
return json({ ok: true, token, user: { id: user.id, username: user.username }, rev: user.rev });
}
// ---------- 以下均需登录 ----------
if (path === '/api/logout' && method === 'POST') {
const token = bearerFrom(request);
if (token) await store.deleteSession(await hashToken(token));
return json({ ok: true });
}
if (path === '/api/me' && method === 'GET') {
const user = await requireAuth(store, request);
return json({ ok: true, user: { id: user.id, username: user.username }, rev: await store.getRev(user.id) });
}
// 全量拉取:首次登录、或本地想直接覆盖时用
if (path === '/api/data' && method === 'GET') {
const user = await requireAuth(store, request);
const dump = await store.fullDump(user.id);
return json({ ok: true, ...dump });
}
// 增量拉取:?since=<rev>,只回比它新的变更(含删除墓碑)
if (path === '/api/changes' && method === 'GET') {
const user = await requireAuth(store, request);
const since = Number(url.searchParams.get('since') || 0);
if (!Number.isFinite(since) || since < 0) return err('since 必须是非负整数');
const out = await store.changesSince(user.id, since);
return json({ ok: true, since, ...out });
}
// 推送本地变更。整批原子写入,返回新 rev。
if (path === '/api/push' && method === 'POST') {
const user = await requireAuth(store, request);
const body = await readJson(request);
const records = [];
if (body.records && typeof body.records === 'object') {
for (const [day, raw] of Object.entries(body.records)) records.push(normalizeRecord(day, raw));
}
const adjust = [];
if (body.monthAdjust && typeof body.monthAdjust === 'object') {
for (const [month, raw] of Object.entries(body.monthAdjust)) adjust.push(normalizeAdjust(month, raw));
}
const settings = body.settings !== undefined && body.settings !== null
? normalizeSettings(body.settings)
: null;
if (!records.length && !adjust.length && !settings) {
return json({ ok: true, rev: await store.getRev(user.id), applied: 0 });
}
const rev = await store.applyPush(user.id, { records, adjust, settings });
return json({ ok: true, rev, applied: records.length + adjust.length + (settings ? 1 : 0) });
}
// 删除单日 / 单月:等价于 push 一个墓碑,给客户端一个更直白的入口
if (method === 'DELETE' && path.startsWith('/api/record/')) {
const user = await requireAuth(store, request);
const day = path.slice('/api/record/'.length);
if (!DAY_RE.test(day)) return err('日期格式错误');
const rev = await store.applyPush(user.id, { records: [{ day, deleted: 1 }], adjust: [], settings: null });
return json({ ok: true, rev });
}
if (method === 'DELETE' && path.startsWith('/api/adjust/')) {
const user = await requireAuth(store, request);
const month = path.slice('/api/adjust/'.length);
if (!MONTH_RE.test(month)) return err('月份格式错误');
const rev = await store.applyPush(user.id, { records: [], adjust: [{ month, deleted: 1 }], settings: null });
return json({ ok: true, rev });
}
// 服务端导出:与前端「导出备份」同一份 bundle 结构,可直接互相导入
if (path === '/api/export' && method === 'GET') {
const user = await requireAuth(store, request);
const dump = await store.fullDump(user.id);
const recs = {};
for (const [k, v] of Object.entries(dump.records)) recs[k] = recordRowToClient(v);
const adj = {};
for (const [k, v] of Object.entries(dump.adjust)) adj[k] = adjustRowToClient(v);
return json({
app: 'work-hours-tracker',
version: 3,
exportedAt: new Date().toISOString(),
rev: dump.rev,
settings: dump.settings || {},
records: recs,
monthAdjust: adj
}, 200, { 'content-disposition': 'attachment; filename="work-hours-backup.json"' });
}
// ---------- MCP 接入管理(登录态即可操作;单人系统无特权分级) ----------
// 开关与令牌存 DB,改动即时生效;令牌明文只在 POST /api/mcp/token 的响应里出现一次。
if (path.startsWith('/api/mcp/')) {
await requireAuth(store, request);
const sub = path.slice('/api/mcp/'.length);
if (sub === 'status' && method === 'GET') {
const cfg = await readMcpConfig(store, opts.mcpSeedToken);
return json({
ok: true, configured: cfg.configured, enabled: cfg.enabled,
masked: cfg.configured ? maskToken(cfg.token) : null
});
}
if (sub === 'token' && method === 'POST') {
const token = newMcpToken();
await saveMcpToken(store, token);
return json({ ok: true, token, enabled: true, note: '明文仅此一次展示,请立即保存' });
}
if (sub === 'token' && method === 'GET') {
const cfg = await readMcpConfig(store, opts.mcpSeedToken);
if (!cfg.configured) return err('尚未配置 MCP 令牌,请先生成', 404);
return json({ ok: true, token: cfg.token });
}
if (sub === 'enabled' && method === 'POST') {
const body = await readJson(request);
if (typeof body.enabled !== 'boolean') return err('enabled 必须是布尔值');
await setMcpEnabled(store, body.enabled);
return json({ ok: true, enabled: body.enabled });
}
return err('接口不存在: ' + path, 404);
}
return err('接口不存在: ' + path, 404);
} catch (e) {
const status = e.status || (e instanceof BadRequest ? 400 : 500);
if (status >= 500) console.error('[api]', e);
return err(e.message || '服务器内部错误', status);
}
}