The seeded account is the single admin; it can create author accounts on the new /admin/users page (username + password) and grant each one access to specific tags. Authors sign in to a Posts-only panel where they can write, edit, publish, and unpublish their own posts — every post must carry at least one granted tag, tags outside the grants are rejected server-side, and only the admin can create tags or delete posts (or anything else: pages, comments, settings, and backups stay admin-only). Admin-only URLs bounce authors to their post list, and foreign post editors 404. posts.author_id records ownership; deleting an account keeps its posts as unowned, admin-managed rows and signs the account out everywhere. Backups (export v3) store the owner's username per post and re-attach ownership on import when the account still exists. Also fixes a latent form bug: a missing newTags field (author forms don't render it) failed zod validation with an invisible error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
58 lines
2 KiB
TypeScript
58 lines
2 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { eq, lt } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import { sessions, type UserRole, users } from "@/db/schema";
|
|
|
|
// Deliberately free of next/* imports so it can be exercised directly by
|
|
// integration tests; cookie handling lives in cookies.ts.
|
|
|
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
|
export type SessionUser = { id: number; username: string; role: UserRole };
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
/** Creates a DB session and returns the raw bearer token for the cookie. */
|
|
export async function createSession(
|
|
userId: number,
|
|
): Promise<{ token: string; expiresAt: Date }> {
|
|
const token = randomBytes(32).toString("base64url");
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
await db.insert(sessions).values({ id: hashToken(token), userId, expiresAt });
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
/** Resolves a raw token to its user, treating expired sessions as absent. */
|
|
export async function validateSessionToken(token: string): Promise<SessionUser | null> {
|
|
if (!token) return null;
|
|
const id = hashToken(token);
|
|
const [row] = await db
|
|
.select({
|
|
userId: users.id,
|
|
username: users.username,
|
|
role: users.role,
|
|
expiresAt: sessions.expiresAt,
|
|
})
|
|
.from(sessions)
|
|
.innerJoin(users, eq(users.id, sessions.userId))
|
|
.where(eq(sessions.id, id))
|
|
.limit(1);
|
|
if (!row) return null;
|
|
if (row.expiresAt.getTime() <= Date.now()) {
|
|
await db.delete(sessions).where(eq(sessions.id, id));
|
|
return null;
|
|
}
|
|
return { id: row.userId, username: row.username, role: row.role };
|
|
}
|
|
|
|
export async function deleteSession(token: string): Promise<void> {
|
|
await db.delete(sessions).where(eq(sessions.id, hashToken(token)));
|
|
}
|
|
|
|
/** Opportunistic cleanup, called on login so the table cannot grow unbounded. */
|
|
export async function deleteExpiredSessions(): Promise<void> {
|
|
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
|
|
}
|