import { Router } from "express"; import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import { db, usersTable, configTable, creditTransactionsTable } from "@workspace/db"; import { eq, count } from "drizzle-orm"; const router = Router(); const JWT_SECRET = process.env.SESSION_SECRET || "dev-secret-change-me"; const COOKIE_NAME = "sf_auth"; const COOKIE_OPTS = { httpOnly: true, sameSite: "lax" as const, secure: process.env.NODE_ENV === "production", maxAge: 30 * 24 * 60 * 60 * 1000, path: "/", }; function signToken(userId: number, email: string) { return jwt.sign({ userId, email }, JWT_SECRET, { expiresIn: "30d" }); } async function isFirstUser(): Promise { const [row] = await db.select({ count: count() }).from(usersTable); return Number(row.count) === 0; } router.post("/signup", async (req, res) => { const { email, password, displayName } = req.body as { email?: string; password?: string; displayName?: string; }; if (!email || !password) { return res.status(400).json({ error: "Email and password are required" }); } if (password.length < 6) { return res.status(400).json({ error: "Password must be at least 6 characters" }); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: "Invalid email format" }); } const existing = await db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1); if (existing.length > 0) { return res.status(409).json({ error: "Email already registered" }); } const firstUser = await isFirstUser(); const passwordHash = await bcrypt.hash(password, 12); const [user] = await db.insert(usersTable).values({ email: email.toLowerCase(), passwordHash, displayName: displayName?.trim() || null, isAdmin: firstUser, }).returning({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, isAdmin: usersTable.isAdmin, }); // Grant signup credits from config const signupCreditRow = await db.select({ value: configTable.value }).from(configTable).where(eq(configTable.key, "signup_credits")).limit(1); const signupCredits = Number(signupCreditRow[0]?.value) || 0; if (signupCredits > 0) { await db.update(usersTable).set({ credits: signupCredits }).where(eq(usersTable.id, user.id)); await db.insert(creditTransactionsTable).values({ userId: user.id, amount: signupCredits, type: "signup", description: "新用戶註冊贈點" }); } const token = signToken(user.id, user.email); res.cookie(COOKIE_NAME, token, COOKIE_OPTS); res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin, credits: signupCredits }); }); router.post("/login", async (req, res) => { const { email, password } = req.body as { email?: string; password?: string }; if (!email || !password) { return res.status(400).json({ error: "Email and password are required" }); } const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1); if (!user) { return res.status(401).json({ error: "Invalid email or password" }); } const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) { return res.status(401).json({ error: "Invalid email or password" }); } const token = signToken(user.id, user.email); res.cookie(COOKIE_NAME, token, COOKIE_OPTS); res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin }); }); router.post("/logout", (_req, res) => { res.clearCookie(COOKIE_NAME, { path: "/" }); res.json({ success: true }); }); router.get("/me", async (req, res) => { const token = req.cookies?.[COOKIE_NAME]; if (!token) return res.status(401).json({ error: "Not authenticated" }); try { const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string }; const [user] = await db .select({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, isAdmin: usersTable.isAdmin }) .from(usersTable) .where(eq(usersTable.id, payload.userId)) .limit(1); if (!user) { res.clearCookie(COOKIE_NAME, { path: "/" }); return res.status(401).json({ error: "User not found" }); } res.json(user); } catch { res.clearCookie(COOKIE_NAME, { path: "/" }); res.status(401).json({ error: "Session expired" }); } }); export function requireJwtAuth(req: any, res: any, next: any) { const token = req.cookies?.[COOKIE_NAME]; if (!token) return res.status(401).json({ error: "Unauthorized" }); try { const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string }; req.jwtUserId = payload.userId; req.jwtEmail = payload.email; next(); } catch { res.clearCookie(COOKIE_NAME, { path: "/" }); res.status(401).json({ error: "Session expired" }); } } export function optionalJwtAuth(req: any, _res: any, next: any) { const token = req.cookies?.[COOKIE_NAME]; if (token) { try { const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string }; req.jwtUserId = payload.userId; req.jwtEmail = payload.email; } catch { // invalid token, continue as guest } } next(); } export default router;