- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询 - 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册 - 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示 - 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」 - 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
// 平台无关的 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);
|
||
}
|
||
}
|