import { db } from "./db.mjs"; import { json, readBody } from "./http.mjs"; import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs"; import { hashPassword, verifyPassword } from "./security.mjs"; import { hasPermission, publicPermissions } from "./permissions.mjs"; export function publicUser(user) { if (!user) return null; return { id: user.id, email: user.email, name: user.name, avatarUrl: user.avatar_url, role: user.role, status: user.status || "active", emailVerified: Boolean(user.email_verified), createdAt: user.created_at, updatedAt: user.updated_at, lastLoginAt: user.last_login_at, permissions: publicPermissions(user), }; } export function getRequestSession(request) { const cookies = parseCookies(request.headers.cookie); const sessionId = cookies.vpc_session; const session = getSession(sessionId); return { sessionId, session }; } export function requireCmsUser(request, response) { const { session } = getRequestSession(request); if (!session?.user?.id) { json(response, 401, { error: "Not signed in" }); return undefined; } return session.user; } export function requirePermission(request, response, permission) { const user = requireCmsUser(request, response); if (!user) return undefined; if (!hasPermission(user, permission)) { json(response, 403, { error: "Permission denied" }); return undefined; } return user; } function settingEnabled(key, fallback = false) { const row = db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key); return (row?.value ?? String(fallback)) === "true"; } function validateEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } async function handleMe(request, response) { const { session } = getRequestSession(request); json(response, 200, { user: session?.user ?? null }); } async function handleOptions(request, response) { json(response, 200, { options: { allowEmailLogin: settingEnabled("auth.allow_email_login", true), allowGitHubLogin: settingEnabled("auth.allow_github_login", true), allowRegistration: settingEnabled("auth.allow_registration", false), }, }); } async function handleLogin(request, response) { if (!settingEnabled("auth.allow_email_login", true)) { json(response, 403, { error: "Email login is disabled" }); return; } const body = JSON.parse(await readBody(request) || "{}"); const email = String(body.email || "").trim().toLowerCase(); const password = String(body.password || ""); if (!email || !password) { json(response, 400, { error: "Email and password are required" }); return; } const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email); if (!user?.password_hash || !verifyPassword(password, user.password_hash)) { json(response, 401, { error: "Invalid email or password" }); return; } if ((user.status || "active") !== "active") { json(response, 403, { error: "This account is disabled" }); return; } db.prepare("UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(user.id); const nextUser = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id); const sessionUser = publicUser(nextUser); const sessionId = createSession({ user: sessionUser, }); json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) }); } async function handleRegister(request, response) { if (!settingEnabled("auth.allow_registration", false)) { json(response, 403, { error: "Registration is disabled" }); return; } const body = JSON.parse((await readBody(request)) || "{}"); const email = String(body.email || "").trim().toLowerCase(); const name = String(body.name || "").trim(); const password = String(body.password || ""); if (!validateEmail(email)) { json(response, 400, { error: "A valid email is required" }); return; } if (!name) { json(response, 400, { error: "Name is required" }); return; } if (password.length < 8) { json(response, 400, { error: "Password must be at least 8 characters" }); return; } const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); if (existing) { json(response, 409, { error: "Email already exists" }); return; } const result = db .prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, 'user', 0)") .run(email, hashPassword(password), name); const user = publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid)); const sessionId = createSession({ user }); json(response, 201, { user }, { "Set-Cookie": sessionCookie(sessionId) }); } async function handleLogout(request, response) { const { sessionId } = getRequestSession(request); deleteSession(sessionId); json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() }); } async function handleUpdateProfile(request, response) { const { sessionId, session } = getRequestSession(request); if (!session?.user?.id) { json(response, 401, { error: "Not signed in" }); return; } const body = JSON.parse((await readBody(request)) || "{}"); const name = String(body.name || "").trim(); if (!name) { json(response, 400, { error: "Name is required" }); return; } db.prepare("UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(name, session.user.id); const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id); const sessionUser = publicUser(user); delete session.user; const nextSession = { ...session, user: sessionUser, }; deleteSession(sessionId); const nextSessionId = createSession(nextSession); json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(nextSessionId) }); } async function handleChangePassword(request, response) { const { session } = getRequestSession(request); if (!session?.user?.id) { json(response, 401, { error: "Not signed in" }); return; } const body = JSON.parse((await readBody(request)) || "{}"); const currentPassword = String(body.currentPassword || ""); const newPassword = String(body.newPassword || ""); const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id); if (newPassword.length < 8) { json(response, 400, { error: "New password must be at least 8 characters" }); return; } if (user.password_hash && !verifyPassword(currentPassword, user.password_hash)) { json(response, 403, { error: "Current password is incorrect" }); return; } db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run( hashPassword(newPassword), session.user.id, ); json(response, 200, { ok: true }); } export async function handleAuthApi(request, response, url) { try { if (url.pathname === "/api/auth/me" && request.method === "GET") { await handleMe(request, response); return true; } if (url.pathname === "/api/auth/options" && request.method === "GET") { await handleOptions(request, response); return true; } if (url.pathname === "/api/auth/login" && request.method === "POST") { await handleLogin(request, response); return true; } if (url.pathname === "/api/auth/register" && request.method === "POST") { await handleRegister(request, response); return true; } if (url.pathname === "/api/auth/logout" && request.method === "POST") { await handleLogout(request, response); return true; } if (url.pathname === "/api/auth/profile" && request.method === "PATCH") { await handleUpdateProfile(request, response); return true; } if (url.pathname === "/api/auth/password" && request.method === "POST") { await handleChangePassword(request, response); return true; } return false; } catch (error) { json(response, 500, { error: error instanceof Error ? error.message : "Unknown auth API error", }); return true; } }