Files
work-hours-tracker/server/core/api.js
T
OpenClaw Agent 53733b39fc feat: 支持多用户与用户级MCP隔离,优化汇总诊断与UI展示
- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询
- 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册
- 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示
- 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」
- 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
2026-09-14 14:00:07 +08:00

285 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 {
readUserMcpConfig, saveUserMcpToken, setUserMcpEnabled, 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(),
allowRegister: !opts.allowRegister ? (await store.countUsers()) === 0 : true,
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 接入管理(登录态即可操作;单人系统无特权分级) ----------
// 开关与令牌存 user_mcp_tokens 表,每个用户独立隔离;令牌明文只在 POST /api/mcp/token 的响应里出现一次。
if (path.startsWith('/api/mcp/')) {
const user = await requireAuth(store, request);
const sub = path.slice('/api/mcp/'.length);
if (sub === 'status' && method === 'GET') {
const cfg = await readUserMcpConfig(store, user.id, 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 saveUserMcpToken(store, user.id, token);
return json({ ok: true, token, enabled: true, note: '明文仅此一次展示,请立即保存' });
}
if (sub === 'token' && method === 'GET') {
const cfg = await readUserMcpConfig(store, user.id, 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 setUserMcpEnabled(store, user.id, 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);
}
}