import { and, eq, isNull, ne } from 'drizzle-orm'; import type { Context } from 'hono'; import { deleteCookie, setCookie } from 'hono/cookie'; import { createHash, randomBytes } from 'node:crypto'; import type { AppContext } from '../context'; import { sessions, users, type SessionRow, type UserRow } from '../db/schema'; import { requestProto } from '../http'; export const SESSION_COOKIE = 'sl_session'; const DAY_MS = 86_400_000; export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); /** "iPhone · Safari", "Mac · Chrome" and so on, for the devices list. */ export function deviceLabel(userAgent: string | null | undefined): string { const ua = userAgent ?? ''; const device = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Macintosh|Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows PC' : /CrOS/.test(ua) ? 'Chromebook' : /Linux/.test(ua) ? 'Linux' : 'Unknown device'; const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null; return browser ? `${device} · ${browser}` : device; } export function createSession( ctx: AppContext, userId: number, meta: { userAgent?: string | null; ip?: string | null }, now = new Date(), ): { token: string; session: SessionRow } { const token = randomBytes(32).toString('base64url'); const session = ctx.db .insert(sessions) .values({ id: hashToken(token), userId, kind: 'browser', deviceLabel: deviceLabel(meta.userAgent), userAgent: meta.userAgent ?? null, ip: meta.ip ?? null, createdAt: now.toISOString(), lastSeenAt: now.toISOString(), expiresAt: new Date(now.getTime() + ctx.config.sessionDays * DAY_MS).toISOString(), }) .returning() .get(); return { token, session }; } export function loadSession(ctx: AppContext, token: string, now = new Date()): { session: SessionRow; user: UserRow } | null { const row = ctx.db .select({ session: sessions, user: users }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(and(eq(sessions.id, hashToken(token)), isNull(users.deletedAt))) .get(); if (!row) return null; if (Date.parse(row.session.expiresAt) <= now.getTime()) { ctx.db.delete(sessions).where(eq(sessions.id, row.session.id)).run(); return null; } return row; } /** * Keep a session alive. The expiry slides forward (at most once a day, to * save writes) so a device in regular use never signs out. Returns true when * the cookie should be re-sent with a fresh lifetime. */ export function touchSession(ctx: AppContext, session: SessionRow, ip: string, now = new Date()): boolean { const fullLife = ctx.config.sessionDays * DAY_MS; const remaining = Date.parse(session.expiresAt) - now.getTime(); if (remaining < fullLife - DAY_MS) { ctx.db .update(sessions) .set({ expiresAt: new Date(now.getTime() + fullLife).toISOString(), lastSeenAt: now.toISOString(), ip, }) .where(eq(sessions.id, session.id)) .run(); return true; } if (now.getTime() - Date.parse(session.lastSeenAt) > 5 * 60_000) { ctx.db.update(sessions).set({ lastSeenAt: now.toISOString(), ip }).where(eq(sessions.id, session.id)).run(); } return false; } export function revokeSession(ctx: AppContext, sessionId: string): void { ctx.db.delete(sessions).where(eq(sessions.id, sessionId)).run(); } /** Sign a person out everywhere, optionally keeping the device they're on. */ export function revokeUserSessions(ctx: AppContext, userId: number, exceptSessionId?: string): void { const where = exceptSessionId ? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId)) : eq(sessions.userId, userId); ctx.db.delete(sessions).where(where).run(); } export function setSessionCookie(c: Context, ctx: AppContext, token: string): void { setCookie(c, SESSION_COOKIE, token, { httpOnly: true, secure: requestProto(c, ctx) === 'https', sameSite: 'Lax', path: '/', maxAge: ctx.config.sessionDays * 86_400, }); } export function clearSessionCookie(c: Context): void { deleteCookie(c, SESSION_COOKIE, { path: '/' }); }