374 lines
15 KiB
TypeScript
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 });
|