import Database from "better-sqlite3"; import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto"; import { mkdirSync } from "fs"; import { join } from "path"; import { cookies } from "next/headers"; const DATA_DIR = join(process.cwd(), "data"); const DB_FILE = join(DATA_DIR, "tasks.db"); const SESSION_COOKIE_NAME = "gantt_session"; const SESSION_HOURS_SHORT = 12; const SESSION_DAYS_REMEMBER = 30; type SqliteDb = InstanceType; let db: SqliteDb | null = null; export interface AuthUser { id: string; name: string; email: string; avatarUrl?: string; createdAt: string; } interface UserRow extends AuthUser { passwordHash: string; } function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } function normalizeAvatarDataUrl(value: string | null | undefined): string | undefined { if (value == null) return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; if (!trimmed.startsWith("data:image/")) { throw new Error("Avatar must be an image"); } if (trimmed.length > 2_000_000) { throw new Error("Avatar image is too large"); } return trimmed; } function ensureUserSchema(database: SqliteDb) { const userColumns = database.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; if (!userColumns.some((column) => column.name === "avatarUrl")) { database.exec("ALTER TABLE users ADD COLUMN avatarUrl TEXT;"); } } function getDb(): SqliteDb { if (db) { ensureUserSchema(db); return db; } mkdirSync(DATA_DIR, { recursive: true }); const database = new Database(DB_FILE); database.pragma("journal_mode = WAL"); database.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, avatarUrl TEXT, passwordHash TEXT NOT NULL, createdAt TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, userId TEXT NOT NULL, tokenHash TEXT NOT NULL UNIQUE, createdAt TEXT NOT NULL, expiresAt TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(tokenHash); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(userId); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt); `); ensureUserSchema(database); db = database; return database; } function hashPassword(password: string, salt?: string): string { const safeSalt = salt || randomBytes(16).toString("hex"); const derived = scryptSync(password, safeSalt, 64).toString("hex"); return `scrypt$${safeSalt}$${derived}`; } function verifyPassword(password: string, stored: string): boolean { const parts = stored.split("$"); if (parts.length !== 3 || parts[0] !== "scrypt") return false; const [, salt, digest] = parts; const candidate = hashPassword(password, salt); const candidateDigest = candidate.split("$")[2]; const a = Buffer.from(digest, "hex"); const b = Buffer.from(candidateDigest, "hex"); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } function hashSessionToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } function deleteExpiredSessions(database: SqliteDb) { const now = new Date().toISOString(); database.prepare("DELETE FROM sessions WHERE expiresAt <= ?").run(now); } export function registerUser(params: { name: string; email: string; password: string; }): AuthUser { const database = getDb(); deleteExpiredSessions(database); const name = params.name.trim(); const email = normalizeEmail(params.email); const password = params.password; if (name.length < 2) throw new Error("Name must be at least 2 characters"); if (!email.includes("@")) throw new Error("Invalid email"); if (password.length < 8) throw new Error("Password must be at least 8 characters"); const existing = database .prepare("SELECT id FROM users WHERE email = ? LIMIT 1") .get(email) as { id: string } | undefined; if (existing) { throw new Error("Email already exists"); } const user: AuthUser = { id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name, email, avatarUrl: undefined, createdAt: new Date().toISOString(), }; database .prepare("INSERT INTO users (id, name, email, avatarUrl, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?, ?)") .run(user.id, user.name, user.email, user.avatarUrl ?? null, hashPassword(password), user.createdAt); return user; } export function authenticateUser(params: { email: string; password: string; }): AuthUser | null { const database = getDb(); deleteExpiredSessions(database); const email = normalizeEmail(params.email); const row = database .prepare("SELECT id, name, email, avatarUrl, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1") .get(email) as UserRow | undefined; if (!row) return null; if (!verifyPassword(params.password, row.passwordHash)) return null; return { id: row.id, name: row.name, email: row.email, avatarUrl: row.avatarUrl ?? undefined, createdAt: row.createdAt, }; } export function updateUserAccount(params: { userId: string; name?: string; email?: string; avatarUrl?: string | null; currentPassword?: string; newPassword?: string; }): AuthUser { const database = getDb(); deleteExpiredSessions(database); const row = database .prepare("SELECT id, name, email, avatarUrl, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1") .get(params.userId) as UserRow | undefined; if (!row) throw new Error("User not found"); const requestedName = typeof params.name === "string" ? params.name.trim() : row.name; const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email; const hasAvatarInput = Object.prototype.hasOwnProperty.call(params, "avatarUrl"); const requestedAvatar = hasAvatarInput ? normalizeAvatarDataUrl(params.avatarUrl) : row.avatarUrl; const currentPassword = params.currentPassword || ""; const newPassword = params.newPassword || ""; if (requestedName.length < 2) throw new Error("Name must be at least 2 characters"); if (!requestedEmail.includes("@")) throw new Error("Invalid email"); if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters"); const emailChanged = requestedEmail !== row.email; const passwordChanged = newPassword.length > 0; const needsPasswordCheck = emailChanged || passwordChanged; if (needsPasswordCheck) { if (!currentPassword) throw new Error("Current password is required"); if (!verifyPassword(currentPassword, row.passwordHash)) { throw new Error("Current password is incorrect"); } } if (emailChanged) { const existing = database .prepare("SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1") .get(requestedEmail, row.id) as { id: string } | undefined; if (existing) throw new Error("Email already exists"); } const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash; database .prepare("UPDATE users SET name = ?, email = ?, avatarUrl = ?, passwordHash = ? WHERE id = ?") .run(requestedName, requestedEmail, requestedAvatar ?? null, nextPasswordHash, row.id); return { id: row.id, name: requestedName, email: requestedEmail, avatarUrl: requestedAvatar ?? undefined, createdAt: row.createdAt, }; } export function listUsers(): AuthUser[] { const database = getDb(); return database .prepare("SELECT id, name, email, avatarUrl, createdAt FROM users ORDER BY LOWER(name) ASC") .all() as AuthUser[]; } export function createUserSession(userId: string, rememberMe: boolean): { token: string; expiresAt: string; } { const database = getDb(); deleteExpiredSessions(database); const now = Date.now(); const ttlMs = rememberMe ? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000 : SESSION_HOURS_SHORT * 60 * 60 * 1000; const createdAt = new Date(now).toISOString(); const expiresAt = new Date(now + ttlMs).toISOString(); const token = randomBytes(32).toString("hex"); const tokenHash = hashSessionToken(token); database .prepare("INSERT INTO sessions (id, userId, tokenHash, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)") .run(`session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, userId, tokenHash, createdAt, expiresAt); return { token, expiresAt }; } export function revokeSession(token: string) { const database = getDb(); const tokenHash = hashSessionToken(token); database.prepare("DELETE FROM sessions WHERE tokenHash = ?").run(tokenHash); } export function getUserBySessionToken(token: string): AuthUser | null { const database = getDb(); deleteExpiredSessions(database); const tokenHash = hashSessionToken(token); const now = new Date().toISOString(); const row = database .prepare(` SELECT u.id, u.name, u.email, u.avatarUrl, u.createdAt FROM sessions s JOIN users u ON u.id = s.userId WHERE s.tokenHash = ? AND s.expiresAt > ? LIMIT 1 `) .get(tokenHash, now) as AuthUser | undefined; return row ?? null; } export async function setSessionCookie(token: string, rememberMe: boolean) { const cookieStore = await cookies(); const baseOptions = { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", } as const; if (rememberMe) { cookieStore.set(SESSION_COOKIE_NAME, token, { ...baseOptions, maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60, }); return; } // Session cookie (clears on browser close) cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions); } export async function clearSessionCookie() { const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE_NAME, "", { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 0, }); } export async function getSessionTokenFromCookies(): Promise { const cookieStore = await cookies(); return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; } export async function getAuthenticatedUser(): Promise { const token = await getSessionTokenFromCookies(); if (!token) return null; return getUserBySessionToken(token); }