Vectrum
Sign In
62 lines · 2.1 KB · TypeScript
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { cache } from "react";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { db } from "./db";
import { getUserById } from "./users";

const SESSION_COOKIE = "session";
const SESSION_DAYS = 30;

const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");

export async function startSession(userId: number) {
  const token = randomBytes(32).toString("base64url");
  const now = Date.now();
  const expires = now + SESSION_DAYS * 86400e3;
  db.prepare("INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
    sha256(token),
    userId,
    now,
    expires,
  );
  const h = await headers();
  (await cookies()).set(SESSION_COOKIE, token, {
    httpOnly: true,
    sameSite: "lax",
    secure: h.get("x-forwarded-proto") === "https",
    path: "/",
    expires: new Date(expires),
  });
}

export async function endSession() {
  const jar = await cookies();
  const token = jar.get(SESSION_COOKIE)?.value;
  if (token) db.prepare("DELETE FROM sessions WHERE id = ?").run(sha256(token));
  jar.delete(SESSION_COOKIE);
}

/** The signed-in user for this request, or null. */
export const getViewer = cache(async () => {
  const token = (await cookies()).get(SESSION_COOKIE)?.value;
  if (!token) return null;
  const session = db.prepare("SELECT user_id, expires_at FROM sessions WHERE id = ?").get(sha256(token)) as
    | { user_id: number; expires_at: number }
    | undefined;
  if (!session || session.expires_at < Date.now()) return null;
  return getUserById(session.user_id);
});

/** Returns the signed-in user, or redirects to the login page. */
export async function requireViewer(returnTo?: string) {
  const viewer = await getViewer();
  if (!viewer) redirect(returnTo ? `/login?next=${encodeURIComponent(returnTo)}` : "/login");
  return viewer;
}

/** Only allow same-site relative redirects after login. */
export function safeNext(next: unknown) {
  return typeof next === "string" && next.startsWith("/") && !next.startsWith("//") ? next : "/";
}