import cookie from "@fastify/cookie"; import helmet from "@fastify/helmet"; import rateLimit from "@fastify/rate-limit"; import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; import { initializeDatabase, pool } from "./db.js"; import { clearSessionCookie, createId, createSecret, hashPassword, hashSecret, INVITATION_TTL_MS, normalizeName, SESSION_TTL_MS, sessionCookieName, setSessionCookie, validPassword, verifyPassword, } from "./security.js"; type User = { id: string; name: string }; type MemberRole = "owner" | "member"; const server = Fastify({ logger: true, trustProxy: true }); await server.register(cookie); await server.register(helmet, { contentSecurityPolicy: false }); await server.register(rateLimit, { max: 120, timeWindow: "1 minute" }); await initializeDatabase(); server.addHook("onRequest", async (request, reply) => { if (!["POST", "PATCH", "PUT", "DELETE"].includes(request.method)) return; const origin = request.headers.origin; if (!origin) return; try { const configuredOrigin = process.env.PUBLIC_ORIGIN ? new URL(process.env.PUBLIC_ORIGIN).origin : null; if (new URL(origin).host !== request.headers.host && new URL(origin).origin !== configuredOrigin) { return reply.code(403).send({ error: "不允许跨站提交" }); } } catch { return reply.code(403).send({ error: "请求来源无效" }); } }); server.addHook("onSend", async (request, reply, payload) => { if ( request.url.startsWith("/api/auth/") || request.url.startsWith("/api/invitations/") || request.url.startsWith("/api/ledger-invitations/") || request.url === "/api/me" ) { reply.header("Cache-Control", "no-store"); } return payload; }); function uniqueViolation(error: unknown) { return typeof error === "object" && error !== null && "code" in error && error.code === "23505"; } async function currentUser(request: FastifyRequest, reply?: FastifyReply): Promise { const token = request.cookies[sessionCookieName]; if (!token) return null; const tokenHash = hashSecret(token); const result = await pool.query( `SELECT u.id, u.name FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()`, [tokenHash], ); const user = result.rows[0]; if (!user) return null; await pool.query( "UPDATE sessions SET last_seen_at = now(), expires_at = $1 WHERE token_hash = $2", [new Date(Date.now() + SESSION_TTL_MS), tokenHash], ); if (reply) setSessionCookie(reply, token); return user; } async function requireUser(request: FastifyRequest, reply: FastifyReply) { const user = await currentUser(request, reply); if (!user) { clearSessionCookie(reply); await reply.code(401).send({ error: "请先登录" }); return null; } return user; } async function ledgerRole(userId: string, ledgerId: string): Promise { const result = await pool.query<{ role: MemberRole }>( `SELECT role FROM ledger_members WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`, [ledgerId, userId], ); return result.rows[0]?.role ?? null; } async function createSession(userId: string) { const token = createSecret(); await pool.query( "INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)", [createId(), userId, hashSecret(token), new Date(Date.now() + SESSION_TTL_MS)], ); return token; } function invitationStatus(invitation: { expiresAt: Date | string; acceptedAt: Date | string | null; revokedAt: Date | string | null }) { if (invitation.revokedAt) return "revoked"; if (invitation.acceptedAt) return "accepted"; if (new Date(invitation.expiresAt).getTime() <= Date.now()) return "expired"; return "valid"; } server.get("/health", async () => ({ ok: true, service: "cents-api" })); server.get("/api/auth/session", async (request, reply) => ({ user: await currentUser(request, reply) })); server.post<{ Body: { name?: string; password?: string } }>( "/api/auth/login", { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (request, reply) => { const nameKey = normalizeName(request.body.name ?? ""); const password = request.body.password ?? ""; const result = await pool.query<{ id: string; name: string; passwordHash: string | null }>( `SELECT id, name, password_hash AS "passwordHash" FROM users WHERE name_key = $1`, [nameKey], ); const account = result.rows[0]; const valid = account?.passwordHash ? await verifyPassword(account.passwordHash, password).catch(() => false) : (await hashPassword(password || "invalid-password"), false); if (!account || !valid) return reply.code(401).send({ error: "姓名或密码错误" }); const token = await createSession(account.id); setSessionCookie(reply, token); return { user: { id: account.id, name: account.name } }; }, ); server.post("/api/auth/logout", async (request, reply) => { const token = request.cookies[sessionCookieName]; if (token) await pool.query("UPDATE sessions SET revoked_at = now() WHERE token_hash = $1", [hashSecret(token)]); clearSessionCookie(reply); return { ok: true }; }); server.patch<{ Body: { name?: string } }>("/api/me", async (request, reply) => { const user = await requireUser(request, reply); if (!user) return; const name = request.body.name?.trim() ?? ""; if (!name || name.length > 40) return reply.code(400).send({ error: "姓名应为 1 至 40 个字符" }); try { const result = await pool.query( "UPDATE users SET name = $1, name_key = $2, updated_at = now() WHERE id = $3 RETURNING id, name", [name, normalizeName(name), user.id], ); return { user: result.rows[0] }; } catch (error) { if (uniqueViolation(error)) return reply.code(409).send({ error: "该姓名已被使用" }); throw error; } }); server.get("/api/ledgers", async (request, reply) => { const user = await requireUser(request, reply); if (!user) return; const result = await pool.query( `SELECT l.id, l.name, l.color, l.default_currency AS "defaultCurrency", l.created_at AS "createdAt", l.updated_at AS "updatedAt", l.archived_at AS "archivedAt", m.role FROM ledgers l JOIN ledger_members m ON m.ledger_id = l.id WHERE m.user_id = $1 AND m.removed_at IS NULL AND l.archived_at IS NULL ORDER BY l.updated_at DESC`, [user.id], ); return { ledgers: result.rows }; }); server.get<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/members", async (request, reply) => { const user = await requireUser(request, reply); if (!user) return; if (!await ledgerRole(user.id, request.params.ledgerId)) return reply.code(403).send({ error: "无权访问该账本" }); const result = await pool.query( `SELECT u.id, u.name, m.role, m.joined_at AS "joinedAt" FROM ledger_members m JOIN users u ON u.id = m.user_id WHERE m.ledger_id = $1 AND m.removed_at IS NULL ORDER BY CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, m.joined_at`, [request.params.ledgerId], ); return { members: result.rows }; }); server.patch<{ Params: { ledgerId: string }; Body: { name?: string; color?: string; defaultCurrency?: string }; }>("/api/ledgers/:ledgerId", async (request, reply) => { const user = await requireUser(request, reply); if (!user) return; if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") { return reply.code(403).send({ error: "只有账本拥有者可以修改设置" }); } const name = request.body.name?.trim() ?? ""; if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" }); const result = await pool.query( `UPDATE ledgers SET name = $1, color = $2, default_currency = $3, updated_at = now() WHERE id = $4 RETURNING id, name, color, default_currency AS "defaultCurrency", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`, [name, request.body.color ?? "#087f72", request.body.defaultCurrency ?? "CNY", request.params.ledgerId], ); return { ledger: result.rows[0] }; }); // Application invitations create accounts but never grant access to an existing ledger. server.get<{ Params: { key: string } }>("/api/invitations/:key", async (request, reply) => { const user = await currentUser(request, reply); if (user) return { user, alreadyLoggedIn: true }; if (request.params.key.length < 32) return reply.code(404).send({ error: "邀请链接无效" }); const result = await pool.query( `SELECT expires_at AS "expiresAt", accepted_at AS "acceptedAt", revoked_at AS "revokedAt" FROM app_invitations WHERE key_hash = $1`, [hashSecret(request.params.key)], ); const invitation = result.rows[0]; if (!invitation) return reply.code(404).send({ error: "邀请链接无效" }); return { invitation: { ...invitation, status: invitationStatus(invitation) }, user: null }; }); server.post<{ Params: { key: string }; Body: { name?: string; password?: string } }>( "/api/invitations/:key/accept", { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (request, reply) => { const existingUser = await currentUser(request, reply); if (existingUser) return { user: existingUser, created: false }; const name = request.body.name?.trim() ?? ""; const password = request.body.password ?? ""; if (!name || name.length > 40) return reply.code(400).send({ error: "请填写 1 至 40 个字符的姓名" }); if (!validPassword(password)) return reply.code(400).send({ error: "密码应为 8 至 128 个字符" }); const passwordHash = await hashPassword(password); const client = await pool.connect(); try { await client.query("BEGIN"); const result = await client.query<{ id: string; expiresAt: Date; acceptedAt: Date | null; revokedAt: Date | null; }>( `SELECT id, expires_at AS "expiresAt", accepted_at AS "acceptedAt", revoked_at AS "revokedAt" FROM app_invitations WHERE key_hash = $1 FOR UPDATE`, [hashSecret(request.params.key)], ); const invitation = result.rows[0]; const status = invitation ? invitationStatus(invitation) : "missing"; if (status !== "valid") { await client.query("ROLLBACK"); const code = status === "accepted" ? 409 : status === "missing" ? 404 : 410; return reply.code(code).send({ error: status === "accepted" ? "邀请已被使用" : status === "expired" ? "邀请已过期" : status === "revoked" ? "邀请已撤销" : "邀请链接无效" }); } const user: User = { id: createId(), name }; await client.query( "INSERT INTO users (id, name, name_key, password_hash) VALUES ($1, $2, $3, $4)", [user.id, user.name, normalizeName(user.name), passwordHash], ); const ledgerId = createId(); await client.query("INSERT INTO ledgers (id, name) VALUES ($1, '家庭日常')", [ledgerId]); await client.query( "INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')", [ledgerId, user.id], ); await client.query( "UPDATE app_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2", [user.id, invitation!.id], ); const sessionToken = createSecret(); await client.query( "INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)", [createId(), user.id, hashSecret(sessionToken), new Date(Date.now() + SESSION_TTL_MS)], ); await client.query("COMMIT"); setSessionCookie(reply, sessionToken); return { user, created: true }; } catch (error) { await client.query("ROLLBACK"); if (uniqueViolation(error)) return reply.code(409).send({ error: "该姓名已被使用" }); throw error; } finally { client.release(); } }, ); server.post<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/invitations", async (request, reply) => { const user = await requireUser(request, reply); if (!user) return; if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") { return reply.code(403).send({ error: "只有账本拥有者可以邀请成员" }); } const key = createSecret(); const expiresAt = new Date(Date.now() + INVITATION_TTL_MS); await pool.query( `INSERT INTO ledger_invitations (id, ledger_id, key_hash, role, created_by, expires_at) VALUES ($1, $2, $3, 'member', $4, $5)`, [createId(), request.params.ledgerId, hashSecret(key), user.id, expiresAt], ); const origin = process.env.PUBLIC_ORIGIN ?? `${request.protocol}://${request.headers.host}`; return { invitation: { key, url: `${origin}/join-ledger?key=${encodeURIComponent(key)}`, expiresAt } }; }); server.get<{ Params: { key: string } }>("/api/ledger-invitations/:key", async (request, reply) => { const result = await pool.query( `SELECT i.expires_at AS "expiresAt", i.accepted_at AS "acceptedAt", i.revoked_at AS "revokedAt", l.id AS "ledgerId", l.name AS "ledgerName", u.name AS "inviterName" FROM ledger_invitations i JOIN ledgers l ON l.id = i.ledger_id LEFT JOIN users u ON u.id = i.created_by WHERE i.key_hash = $1`, [hashSecret(request.params.key)], ); const invitation = result.rows[0]; if (!invitation) return reply.code(404).send({ error: "账本邀请无效" }); return { invitation: { ...invitation, status: invitationStatus(invitation) } }; }); server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept", async (request, reply) => { const user = await requireUser(request, reply); if (!user) return; const client = await pool.connect(); try { await client.query("BEGIN"); const result = await client.query<{ id: string; ledgerId: string; role: MemberRole; expiresAt: Date; acceptedAt: Date | null; revokedAt: Date | null; }>( `SELECT id, ledger_id AS "ledgerId", role, expires_at AS "expiresAt", accepted_at AS "acceptedAt", revoked_at AS "revokedAt" FROM ledger_invitations WHERE key_hash = $1 FOR UPDATE`, [hashSecret(request.params.key)], ); const invitation = result.rows[0]; const status = invitation ? invitationStatus(invitation) : "missing"; if (status !== "valid") { await client.query("ROLLBACK"); return reply.code(status === "accepted" ? 409 : status === "missing" ? 404 : 410).send({ error: "账本邀请不可用" }); } await client.query( `INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT (ledger_id, user_id) DO UPDATE SET removed_at = NULL, joined_at = now()`, [invitation!.ledgerId, user.id, invitation!.role], ); await client.query( "UPDATE ledger_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2", [user.id, invitation!.id], ); await client.query("COMMIT"); return { ledgerId: invitation!.ledgerId }; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }); server.post("/api/sync/push", async (request, reply) => { if (!await requireUser(request, reply)) return; return { accepted: true, operations: [] }; }); server.get("/api/sync/pull", async (request, reply) => { if (!await requireUser(request, reply)) return; return { cursor: null, operations: [] }; }); const port = Number(process.env.PORT ?? 3000); const host = process.env.HOST ?? "0.0.0.0"; await server.listen({ port, host });