import "server-only"; import { randomBytes } from "node:crypto"; import bcrypt from "bcryptjs"; import { cookies } from "next/headers"; import { eq } from "drizzle-orm"; import { db } from "./client"; import { sessions, users } from "./schema"; import type { User } from "@/lib/types"; export const SESSION_COOKIE = "places_session"; // Best-effort persistence — Chrome/Safari now cap cookie lifetime to ~400d // regardless of what we set. The DB row has no expiry (expires_at = NULL), // so re-login isn't required even if the cookie eventually gets pruned. const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10; // 10 years function newToken(): string { return randomBytes(32).toString("base64url"); } function deriveInitials(name: string): string { const parts = name.trim().split(/\s+/); if (parts.length >= 2) { return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } return parts[0].slice(0, 2).toUpperCase(); } export type AuthResult = | { ok: true; user: User } | { ok: false; error: string }; export async function registerUser( email: string, password: string, name: string, ): Promise { const cleanEmail = email.trim().toLowerCase(); const cleanName = name.trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) { return { ok: false, error: "Email không hợp lệ" }; } if (password.length < 8) { return { ok: false, error: "Mật khẩu cần ít nhất 8 ký tự" }; } if (cleanName.length < 1) { return { ok: false, error: "Vui lòng nhập tên" }; } const existing = await db .select({ id: users.id }) .from(users) .where(eq(users.email, cleanEmail)) .limit(1); if (existing.length) { return { ok: false, error: "Email đã được đăng ký" }; } const hash = await bcrypt.hash(password, 10); const initials = deriveInitials(cleanName); const [inserted] = await db .insert(users) .values({ email: cleanEmail, passwordHash: hash, name: cleanName, initials, }) .returning({ id: users.id, email: users.email, name: users.name, initials: users.initials, avatarUrl: users.avatarUrl, color: users.color, }); await createSessionCookie(inserted.id); return { ok: true, user: { id: inserted.id, email: inserted.email, name: inserted.name, initials: inserted.initials, avatar_url: inserted.avatarUrl, color: inserted.color ?? undefined, }, }; } export async function loginUser( email: string, password: string, ): Promise { const cleanEmail = email.trim().toLowerCase(); const [row] = await db .select() .from(users) .where(eq(users.email, cleanEmail)) .limit(1); if (!row) return { ok: false, error: "Email hoặc mật khẩu không đúng" }; const ok = await bcrypt.compare(password, row.passwordHash); if (!ok) return { ok: false, error: "Email hoặc mật khẩu không đúng" }; await createSessionCookie(row.id); return { ok: true, user: { id: row.id, email: row.email, name: row.name, initials: row.initials, avatar_url: row.avatarUrl, color: row.color ?? undefined, }, }; } async function createSessionCookie(userId: number): Promise { const token = newToken(); await db.insert(sessions).values({ id: token, userId, expiresAt: null }); const c = await cookies(); c.set(SESSION_COOKIE, token, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: COOKIE_MAX_AGE_SECONDS, }); } export async function getCurrentUserId(): Promise { const c = await cookies(); const token = c.get(SESSION_COOKIE)?.value; if (!token) return null; const [row] = await db .select({ userId: sessions.userId, expiresAt: sessions.expiresAt }) .from(sessions) .where(eq(sessions.id, token)) .limit(1); if (!row) return null; // expiresAt = NULL means the session never expires. Legacy rows with a // timestamp value still respect that expiry. if (row.expiresAt && row.expiresAt.getTime() < Date.now()) { await db.delete(sessions).where(eq(sessions.id, token)); return null; } return row.userId; } export async function logout(): Promise { const c = await cookies(); const token = c.get(SESSION_COOKIE)?.value; if (token) { await db.delete(sessions).where(eq(sessions.id, token)); } c.delete(SESSION_COOKIE); } export async function requireUserId(): Promise { const id = await getCurrentUserId(); if (!id) throw new Error("not authenticated"); return id; }