cents/services/api/src/server.ts

374 lines
15 KiB
TypeScript

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<User | null> {
const token = request.cookies[sessionCookieName];
if (!token) return null;
const tokenHash = hashSecret(token);
const result = await pool.query<User>(
`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<MemberRole | null> {
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<User>(
"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 });