Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
*/

import { type Client, createClient } from "@libsql/client";
import type { Attendee, Event, EventWithCount, Settings } from "./types.ts";
import type {
Attendee,
Event,
EventWithCount,
Session,
Settings,
} from "./types.ts";

let db: Client | null = null;

Expand Down Expand Up @@ -97,6 +103,13 @@ export const initDb = async (): Promise<void> => {
} catch {
// Column already exists, ignore error
}

await client.execute(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
expires INTEGER NOT NULL
)
`);
};

/**
Expand Down Expand Up @@ -303,3 +316,48 @@ export const hasAvailableSpots = async (eventId: number): Promise<boolean> => {
if (!event) return false;
return event.attendee_count < event.max_attendees;
};

/**
* Create a new session
*/
export const createSession = async (
token: string,
expires: number,
): Promise<void> => {
await getDb().execute({
sql: "INSERT INTO sessions (token, expires) VALUES (?, ?)",
args: [token, expires],
});
};

/**
* Get a session by token
*/
export const getSession = async (token: string): Promise<Session | null> => {
const result = await getDb().execute({
sql: "SELECT token, expires FROM sessions WHERE token = ?",
args: [token],
});
if (result.rows.length === 0) return null;
return result.rows[0] as unknown as Session;
};

/**
* Delete a session by token
*/
export const deleteSession = async (token: string): Promise<void> => {
await getDb().execute({
sql: "DELETE FROM sessions WHERE token = ?",
args: [token],
});
};

/**
* Delete all expired sessions
*/
export const deleteExpiredSessions = async (): Promise<void> => {
await getDb().execute({
sql: "DELETE FROM sessions WHERE expires < ?",
args: [Date.now()],
});
};
5 changes: 5 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface Settings {
value: string;
}

export interface Session {
token: string;
expires: number;
}

export interface EventWithCount extends Event {
attendee_count: number;
}
23 changes: 12 additions & 11 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import { isPaymentsEnabled } from "./lib/config.ts";
import {
createAttendee,
createEvent,
createSession,
deleteAttendee,
deleteSession,
getAllEvents,
getAttendee,
getAttendees,
getEvent,
getEventWithCount,
getSession,
hasAvailableSpots,
updateAttendeePayment,
verifyAdminPassword,
Expand All @@ -33,8 +36,6 @@ import {
} from "./lib/stripe.ts";
import type { Attendee, Event, EventWithCount } from "./lib/types.ts";

export const sessions = new Map<string, { expires: number }>();

/**
* Generate a session token
*/
Expand Down Expand Up @@ -68,16 +69,16 @@ const parseCookies = (request: Request): Map<string, string> => {
/**
* Check if request has valid session
*/
const isAuthenticated = (request: Request): boolean => {
const isAuthenticated = async (request: Request): Promise<boolean> => {
const cookies = parseCookies(request);
const token = cookies.get("session");
if (!token) return false;

const session = sessions.get(token);
const session = await getSession(token);
if (!session) return false;

if (session.expires < Date.now()) {
sessions.delete(token);
await deleteSession(token);
return false;
}

Expand Down Expand Up @@ -116,7 +117,7 @@ const parseFormData = async (request: Request): Promise<URLSearchParams> => {
* Handle GET /admin/
*/
const handleAdminGet = async (request: Request): Promise<Response> => {
if (!isAuthenticated(request)) {
if (!(await isAuthenticated(request))) {
return htmlResponse(adminLoginPage());
}
const events = await getAllEvents();
Expand All @@ -137,7 +138,7 @@ const handleAdminLogin = async (request: Request): Promise<Response> => {

const token = generateSessionToken();
const expires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
sessions.set(token, { expires });
await createSession(token, expires);

return redirect(
"/admin/",
Expand All @@ -148,11 +149,11 @@ const handleAdminLogin = async (request: Request): Promise<Response> => {
/**
* Handle GET /admin/logout
*/
const handleAdminLogout = (request: Request): Response => {
const handleAdminLogout = async (request: Request): Promise<Response> => {
const cookies = parseCookies(request);
const token = cookies.get("session");
if (token) {
sessions.delete(token);
await deleteSession(token);
}
return redirect("/admin/", "session=; HttpOnly; Path=/; Max-Age=0");
};
Expand All @@ -161,7 +162,7 @@ const handleAdminLogout = (request: Request): Response => {
* Handle POST /admin/event (create event)
*/
const handleCreateEvent = async (request: Request): Promise<Response> => {
if (!isAuthenticated(request)) {
if (!(await isAuthenticated(request))) {
return redirect("/admin/");
}

Expand All @@ -187,7 +188,7 @@ const handleAdminEventGet = async (
request: Request,
eventId: number,
): Promise<Response> => {
if (!isAuthenticated(request)) {
if (!(await isAuthenticated(request))) {
return redirect("/admin/");
}

Expand Down
79 changes: 79 additions & 0 deletions test/lib/code-quality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { readdir } from "node:fs/promises";
import { join } from "node:path";

const SRC_DIR = join(import.meta.dir, "../../src");

/**
* Patterns that indicate in-memory state storage at module level.
* These should be stored in the database instead to survive restarts.
*/
const FORBIDDEN_PATTERNS = [
{
pattern: /^(?:export\s+)?(?:const|let)\s+\w+\s*=\s*new\s+Map\s*[<(]/m,
description: "Module-level Map (use database instead)",
},
{
pattern: /^(?:export\s+)?(?:const|let)\s+\w+\s*=\s*new\s+Set\s*[<(]/m,
description: "Module-level Set (use database instead)",
},
{
pattern: /^(?:export\s+)?(?:const|let)\s+\w+\s*:\s*Map\s*</m,
description: "Module-level typed Map (use database instead)",
},
{
pattern: /^(?:export\s+)?(?:const|let)\s+\w+\s*:\s*Set\s*</m,
description: "Module-level typed Set (use database instead)",
},
];

/**
* Files that are allowed to have in-memory state (e.g., test utilities)
*/
const ALLOWED_FILES = ["test-utils/index.ts", "test-utils/stripe-mock.ts"];

const getAllTsFiles = async (dir: string): Promise<string[]> => {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];

for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await getAllTsFiles(fullPath)));
} else if (entry.name.endsWith(".ts")) {
files.push(fullPath);
}
}

return files;
};

const getRelativePath = (fullPath: string): string =>
fullPath.replace(`${SRC_DIR}/`, "");

describe("code quality", () => {
describe("no in-memory state", () => {
test("source files should not use module-level Map or Set for state", async () => {
const files = await getAllTsFiles(SRC_DIR);
const violations: string[] = [];

for (const file of files) {
const relativePath = getRelativePath(file);

if (ALLOWED_FILES.includes(relativePath)) {
continue;
}

const content = await Bun.file(file).text();

for (const { pattern, description } of FORBIDDEN_PATTERNS) {
if (pattern.test(content)) {
violations.push(`${relativePath}: ${description}`);
}
}
}

expect(violations).toEqual([]);
});
});
});
42 changes: 42 additions & 0 deletions test/lib/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { createClient } from "@libsql/client";
import {
createAttendee,
createEvent,
createSession,
deleteAttendee,
deleteExpiredSessions,
deleteSession,
generatePassword,
getAllEvents,
getAttendee,
Expand All @@ -12,6 +15,7 @@ import {
getEvent,
getEventWithCount,
getOrCreateAdminPassword,
getSession,
getSetting,
hasAvailableSpots,
initDb,
Expand Down Expand Up @@ -452,4 +456,42 @@ describe("db", () => {
expect(client1).toBe(client2);
});
});

describe("sessions", () => {
test("createSession and getSession work together", async () => {
const expires = Date.now() + 1000;
await createSession("test-token", expires);

const session = await getSession("test-token");
expect(session).not.toBeNull();
expect(session?.token).toBe("test-token");
expect(session?.expires).toBe(expires);
});

test("getSession returns null for missing session", async () => {
const session = await getSession("nonexistent");
expect(session).toBeNull();
});

test("deleteSession removes session", async () => {
await createSession("delete-me", Date.now() + 1000);
await deleteSession("delete-me");

const session = await getSession("delete-me");
expect(session).toBeNull();
});

test("deleteExpiredSessions removes expired sessions", async () => {
await createSession("expired", Date.now() - 1000);
await createSession("valid", Date.now() + 10000);

await deleteExpiredSessions();

const expiredSession = await getSession("expired");
const validSession = await getSession("valid");

expect(expiredSession).toBeNull();
expect(validSession).not.toBeNull();
});
});
});
20 changes: 12 additions & 8 deletions test/lib/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { createClient } from "@libsql/client";
import {
createAttendee,
createEvent,
createSession,
getOrCreateAdminPassword,
getSession,
initDb,
setDb,
} from "#lib/db.ts";
import { resetStripeClient } from "#lib/stripe.ts";
import { handleRequest, sessions } from "#src/server.ts";
import { handleRequest } from "#src/server.ts";
import { mockFormRequest, mockRequest } from "#test-utils";

describe("server", () => {
Expand All @@ -20,7 +22,6 @@ describe("server", () => {

afterEach(() => {
setDb(null);
sessions.clear();
});

describe("GET /", () => {
Expand Down Expand Up @@ -307,8 +308,8 @@ describe("server", () => {
});

test("expired session is deleted and shows login page", async () => {
// Add an expired session directly
sessions.set("expired-token", { expires: Date.now() - 1000 });
// Add an expired session directly to the database
await createSession("expired-token", Date.now() - 1000);

const response = await handleRequest(
new Request("http://localhost/admin/", {
Expand All @@ -320,12 +321,13 @@ describe("server", () => {
expect(html).toContain("Admin Login");

// Verify the expired session was deleted
expect(sessions.has("expired-token")).toBe(false);
const session = await getSession("expired-token");
expect(session).toBeNull();
});
});

describe("logout with valid session", () => {
test("deletes session from sessions map", async () => {
test("deletes session from database", async () => {
// Log in first
const password = await getOrCreateAdminPassword();
const loginResponse = await handleRequest(
Expand All @@ -335,7 +337,8 @@ describe("server", () => {
const token = cookie.split("=")[1]?.split(";")[0] || "";

expect(token).not.toBe("");
expect(sessions.has(token)).toBe(true);
const sessionBefore = await getSession(token);
expect(sessionBefore).not.toBeNull();

// Now logout
const logoutResponse = await handleRequest(
Expand All @@ -346,7 +349,8 @@ describe("server", () => {
expect(logoutResponse.status).toBe(302);

// Verify session was deleted
expect(sessions.has(token)).toBe(false);
const sessionAfter = await getSession(token);
expect(sessionAfter).toBeNull();
});
});

Expand Down