SPB Git

spb/chat-spboucher Public

Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai

TypeScript 78.8% CSS 15.1% JavaScript 4.9% Shell 1.2%
4.0 KB · 122 lines typescript
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45import { hash as argon2Hash, verify as argon2Verify } from "@node-rs/argon2";6import crypto from "node:crypto";7import { getDb } from "@/lib/db/database";89export const SESSION_COOKIE = "spb_session";10const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days11const LOGIN_WINDOW_MS = 1000 * 60 * 15;12const MAX_ATTEMPTS_PER_WINDOW = 8;1314// argon2id with sane defaults for a single-user server15const ARGON2_OPTS = { memoryCost: 65536, timeCost: 3, parallelism: 2 };1617export interface SessionInfo {18  userId: string;19  username: string;20  sessionId: string;21}2223function sha256(input: string): string {24  return crypto.createHash("sha256").update(input).digest("hex");25}2627export async function hashPassword(password: string): Promise<string> {28  return argon2Hash(password, ARGON2_OPTS);29}3031export function loginRateLimited(ip: string): boolean {32  const db = getDb();33  const since = Date.now() - LOGIN_WINDOW_MS;34  const row = db35    .prepare(36      "SELECT COUNT(*) AS n FROM login_attempts WHERE ip = ? AND created_at > ? AND success = 0"37    )38    .get(ip, since) as { n: number };39  return row.n >= MAX_ATTEMPTS_PER_WINDOW;40}4142export function recordLoginAttempt(ip: string, success: boolean): void {43  const db = getDb();44  db.prepare("INSERT INTO login_attempts (ip, success, created_at) VALUES (?, ?, ?)").run(45    ip,46    success ? 1 : 0,47    Date.now()48  );49  // opportunistic pruning50  db.prepare("DELETE FROM login_attempts WHERE created_at < ?").run(Date.now() - LOGIN_WINDOW_MS * 8);51}5253export async function verifyCredentials(54  username: string,55  password: string56): Promise<{ id: string; username: string } | null> {57  const db = getDb();58  const user = db59    .prepare("SELECT id, username, password_hash FROM users WHERE username = ?")60    .get(username) as { id: string; username: string; password_hash: string } | undefined;61  if (!user) {62    // constant-ish time: still run a hash verification against a dummy63    await argon2Verify(64      "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",65      password66    ).catch(() => false);67    return null;68  }69  const ok = await argon2Verify(user.password_hash, password).catch(() => false);70  return ok ? { id: user.id, username: user.username } : null;71}7273export function createSession(userId: string, userAgent?: string, ip?: string): string {74  const db = getDb();75  const rawToken = crypto.randomBytes(32).toString("base64url");76  const now = Date.now();77  db.prepare(78    `INSERT INTO sessions (id, user_id, created_at, expires_at, last_seen_at, user_agent, ip)79     VALUES (?, ?, ?, ?, ?, ?, ?)`80  ).run(sha256(rawToken), userId, now, now + SESSION_TTL_MS, now, userAgent ?? null, ip ?? null);81  db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(now);82  return rawToken;83}8485export function getSession(rawToken: string | undefined): SessionInfo | null {86  if (!rawToken) return null;87  const db = getDb();88  const now = Date.now();89  const row = db90    .prepare(91      `SELECT s.id AS session_id, u.id AS user_id, u.username92       FROM sessions s JOIN users u ON u.id = s.user_id93       WHERE s.id = ? AND s.expires_at > ?`94    )95    .get(sha256(rawToken), now) as96    | { session_id: string; user_id: string; username: string }97    | undefined;98  if (!row) return null;99  db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").run(100    now,101    now + SESSION_TTL_MS,102    row.session_id103  );104  return { userId: row.user_id, username: row.username, sessionId: row.session_id };105}106107export function destroySession(rawToken: string | undefined): void {108  if (!rawToken) return;109  getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sha256(rawToken));110}111112export function sessionCookieHeader(rawToken: string, maxAgeSeconds: number): string {113  return [114    `${SESSION_COOKIE}=${rawToken}`,115    "Path=/",116    "HttpOnly",117    "Secure",118    "SameSite=Lax",119    `Max-Age=${maxAgeSeconds}`,120  ].join("; ");121}122