import dayjs from "dayjs" import uid from "uid-safe" import { db } from "~/database.ts" import { type User, DEMO_USER } from "~/user/user.ts" interface Session { id: string signedId: string userId: string durationMs: number expiresAt: number } const SESSION_ID_BYTE_LENGTH = 24 const SESSION_ID_COOKIE_NAME = "session-id" const SESSION_DURATION_MS = 30 * 60 * 1000 function startBackgroundSessionCleanup() { const deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time") setInterval(() => { deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() }) }, 5000) } async function newSessionId(): Promise { return await uid(SESSION_ID_BYTE_LENGTH) } function signSessionId(sessionId: string): string { const hasher = new Bun.CryptoHasher("sha256", Bun.env.SESSION_SECRET) hasher.update(sessionId) return `${sessionId}.${hasher.digest("base64url")}` } async function createSessionForUser(user: User, cookies: Bun.CookieMap) { const sessionId = await newSessionId() const signedSessionId = signSessionId(sessionId) const expiryDate = dayjs().add(30, "minutes").valueOf() const saveSessionQuery = db.query( "INSERT INTO sessions (session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)", ) saveSessionQuery.run({ sessionId, userId: user.id, expiresAt: expiryDate, }) cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, { maxAge: user.id === DEMO_USER.id ? undefined : SESSION_DURATION_MS, path: "/api", httpOnly: true, }) console.log("session created for user", user.id) } async function saveSession(session: Session, cookies: Bun.CookieMap) { cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, { maxAge: SESSION_DURATION_MS, path: "/api", httpOnly: true, }) } function verifySession(cookie: Bun.CookieMap): Session | null { const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME) if (!signedSessionId) { console.log("no cookie") return null } const value = signedSessionId.slice(0, signedSessionId.lastIndexOf(".")) const expected = signSessionId(value) const a = Buffer.from(signedSessionId) const b = Buffer.from(expected) const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b) if (!isEqual) { console.log("not equal") return null } const findSessionQuery = db.query("SELECT user_id, expires_at_unix_ms FROM sessions WHERE session_id = $sessionId") const row = findSessionQuery.get({ sessionId: value }) if (!row) { console.log("no row") return null } const foundSession = row as { user_id: string; expires_at_unix_ms: number } const now = dayjs().valueOf() if (now > foundSession.expires_at_unix_ms) { const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId") deleteSessionQuery.run({ sessionId: value }) console.log("session expired!") return null } return { id: value, signedId: signedSessionId, userId: foundSession.user_id, expiresAt: foundSession.expires_at_unix_ms, durationMs: SESSION_DURATION_MS, } } function extendSession(session: Session): Session { const newExpiryDate = dayjs().add(30, "minutes").valueOf() const extendSessionQuery = db.query( "UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id", ) extendSessionQuery.run({ sessionId: session.id, newExpiryDate, }) return { ...session, expiresAt: newExpiryDate, } } function forgetAllSessions(user: User) { const forgetAllSessionsQuery = db.query("DELETE FROM sessions WHERE user_id = $userId") forgetAllSessionsQuery.run({ userId: user.id }) } export { startBackgroundSessionCleanup, newSessionId, createSessionForUser, verifySession, saveSession, extendSession, forgetAllSessions, }