SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
4.0 KB · 99 lines typescript
Raw Blame History
1import 'server-only';2import { cache } from 'react';3import { cookies } from 'next/headers';4import { redirect } from 'next/navigation';5import { and, eq, gt, isNull } from '@/lib/db';6import { newId } from '@rareindex/shared';7import { db, sessions, users } from '@/lib/db';8import { hmacToken, randomToken } from './crypto';9import { clientInfo } from './request';1011export const SESSION_COOKIE = 'ri_session';12export const DEVICE_COOKIE = 'ri_device';13export const CURRENCY_COOKIE = 'ri_currency';14const SESSION_DAYS = 30;1516export type SessionUser = typeof users.$inferSelect;1718const isProd = () => process.env.NODE_ENV === 'production';1920export async function createSession(userId: string): Promise<void> {21  const raw = randomToken(32);22  const { ip, userAgent } = await clientInfo();23  const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000);24  await db().insert(sessions).values({ id: hmacToken(raw, 'session'), userId, expiresAt, userAgent, ip, lastSeenAt: new Date() });25  const jar = await cookies();26  jar.set(SESSION_COOKIE, raw, { httpOnly: true, secure: isProd(), sameSite: 'lax', path: '/', expires: expiresAt });27}2829export async function destroySession(): Promise<void> {30  const jar = await cookies();31  const raw = jar.get(SESSION_COOKIE)?.value;32  if (raw) await db().update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, hmacToken(raw, 'session')));33  jar.delete(SESSION_COOKIE);34}3536export async function revokeAllSessions(userId: string, keepCurrent = true): Promise<number> {37  const jar = await cookies();38  const raw = jar.get(SESSION_COOKIE)?.value;39  const keepId = keepCurrent && raw ? hmacToken(raw, 'session') : null;40  const rows = await db().select({ id: sessions.id }).from(sessions).where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)));41  let n = 0;42  for (const r of rows) {43    if (r.id === keepId) continue;44    await db().update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, r.id));45    n++;46  }47  return n;48}4950export async function currentSessionId(): Promise<string | null> {51  const jar = await cookies();52  const raw = jar.get(SESSION_COOKIE)?.value;53  return raw ? hmacToken(raw, 'session') : null;54}5556/** Resolve the signed-in user for this request (memoised per request). Null when anonymous. */57export const getCurrentUser = cache(async (): Promise<SessionUser | null> => {58  const jar = await cookies();59  const raw = jar.get(SESSION_COOKIE)?.value;60  if (!raw) return null;61  const id = hmacToken(raw, 'session');62  const rows = await db()63    .select({ user: users, sessionId: sessions.id, lastSeenAt: sessions.lastSeenAt })64    .from(sessions)65    .innerJoin(users, eq(users.id, sessions.userId))66    .where(and(eq(sessions.id, id), isNull(sessions.revokedAt), gt(sessions.expiresAt, new Date())))67    .limit(1);68  const row = rows[0];69  if (!row) return null;70  if (row.user.deletedAt) return null;71  // touch at most every 10 minutes (cheap activity tracking)72  if (!row.lastSeenAt || Date.now() - row.lastSeenAt.getTime() > 600_000) {73    void db().update(sessions).set({ lastSeenAt: new Date() }).where(eq(sessions.id, id)).catch(() => {});74  }75  return row.user;76});7778export async function requireUser(next?: string): Promise<SessionUser> {79  const u = await getCurrentUser();80  if (!u) redirect(`/login${next ? `?next=${encodeURIComponent(next)}` : ''}`);81  return u;82}8384export function newUserId(): string {85  return newId('user');86}8788export const DISPLAY_CURRENCIES = ['USD', 'CAD', 'EUR', 'GBP', 'JPY'] as const;89export type DisplayCurrency = (typeof DISPLAY_CURRENCIES)[number];9091/** Display currency for this request: user preference → cookie → USD. */92export async function getDisplayCurrency(): Promise<DisplayCurrency> {93  const u = await getCurrentUser();94  const pref = u?.displayCurrency;95  if (pref && (DISPLAY_CURRENCIES as readonly string[]).includes(pref)) return pref as DisplayCurrency;96  const c = (await cookies()).get(CURRENCY_COOKIE)?.value;97  return c && (DISPLAY_CURRENCIES as readonly string[]).includes(c) ? (c as DisplayCurrency) : 'USD';98}99