Skip to content

Commit

Permalink
Merge pull request #129 from studentinovisad/as/fix/db-connection
Browse files Browse the repository at this point in the history
fix(db): remove ugly workaround for db conn
  • Loading branch information
aleksasiriski authored Jan 8, 2025
2 parents e92bbd2 + 1e0bb50 commit 6132495
Show file tree
Hide file tree
Showing 21 changed files with 175 additions and 153 deletions.
2 changes: 0 additions & 2 deletions docker/node.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ RUN corepack enable
FROM base AS build
RUN pnpm i --frozen-lockfile
COPY . .
# Ugly workaround some build bug that tries to connect to the DB while bundling
ENV STAGE build
RUN pnpm run build

FROM base AS deps
Expand Down
2 changes: 2 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Database } from '$lib/server/db/connect';
import type { User } from '$lib/server/db/schema/user';
import type { Session } from '$lib/server/db/schema/session';

declare global {
namespace App {
interface Locals {
database: Database;
user: User | null;
session: Session | null;
}
Expand Down
9 changes: 8 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { connectDatabase } from '$lib/server/db/connect';
import { validateSessionToken } from '$lib/server/db/session';
import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/server/session';
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';

// Connect to database on startup
const { database } = await connectDatabase();

export const handle: Handle = async ({ event, resolve }) => {
// Set the database in locals for the rest of the request
event.locals.database = database;

// Allow access to the admin pages without authentication (secret is required)
if (event.url.pathname.startsWith('/admin')) {
return resolve(event);
Expand All @@ -23,7 +30,7 @@ export const handle: Handle = async ({ event, resolve }) => {
}

// Validate session token
const { session, user } = await validateSessionToken(token);
const { session, user } = await validateSessionToken(database, token);
if (session !== null) {
// If session is valid, ensure the token is up-to-date
setSessionTokenCookie(event, token, session.timestamp);
Expand Down
6 changes: 3 additions & 3 deletions src/lib/server/db/building.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Database } from './connect';
import type { Building } from '$lib/types/db';
import { DB as db } from './connect';
import { building } from './schema/building';

export async function getBuildings(): Promise<Building[]> {
export async function getBuildings(db: Database): Promise<Building[]> {
return await db.select().from(building).orderBy(building.name);
}

export async function createBuilding(name: string): Promise<void> {
export async function createBuilding(db: Database, name: string): Promise<void> {
// Assert that name is valid
if (name === null || name === undefined || name === '') {
throw new Error('Invalid name');
Expand Down
68 changes: 54 additions & 14 deletions src/lib/server/db/connect.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import { type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { env } from '$env/dynamic/private';
import { connectDatabaseWithURL } from './connect_generic';
import { sleep } from '$lib/utils/sleep';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';

export type Database = PostgresJsDatabase<Record<string, never>>;
export type Client = postgres.Sql<{}>; // eslint-disable-line @typescript-eslint/no-empty-object-type

// Connects to the database using the DATABASE_URL environment variable
export async function connectDatabase(): Promise<{
database: Database | null;
client: Client | null;
database: Database;
client: Client;
}> {
const stage = env.STAGE;
// WARN: Return immediately if the stage is build
// TODO: Why is this needed JS lords???
if (stage === 'build') {
return { database: null, client: null };
}

const dbUrl = env.DATABASE_URL;
// Assert that the DATABASE_URL environment variable is set
if (!dbUrl) throw new Error('DATABASE_URL is not set');
Expand All @@ -29,5 +24,50 @@ export async function connectDatabase(): Promise<{
return await connectDatabaseWithURL(dbUrl, migrationsPath);
}

// WARN: Forces Database | null to become Database (problems with building)
export const DB = (await connectDatabase()).database as Database;
// Connects to the database using the provided URL
export async function connectDatabaseWithURL(
dbUrl: string,
migrationsPath: string
): Promise<{ database: Database; client: Client }> {
// Assert non-null and non-empty dbUrl and migrationsPath
if (!dbUrl) throw new Error('dbUrl is required');
if (!migrationsPath) throw new Error('migrationsPath is required');

console.log('Connecting to database');
console.debug('DEBUG: Database url (contains secret):', dbUrl);
console.log('Running migrations from:', migrationsPath);

const maxAttempts = 10; // Maximum retry attempts (1 attempt per second)
const delayMs = 1000; // Delay between attempts (1 second)

let attempts = 0;

// Helper function to attempt the database connection
const tryConnect = async (): Promise<{ database: Database; client: Client }> => {
attempts++;
try {
// Connect to the database
const client = postgres(dbUrl);
const database = drizzle(client);

// Run migrations
await migrate(database, { migrationsFolder: migrationsPath });

console.log('Database connection successful after', attempts, 'attempts');

return { database, client };
} catch (err) {
if (attempts >= maxAttempts) {
throw new Error(`Database connection failed after 10 seconds: ${(err as Error).message}`);
}
console.log(
`Connection attempt ${attempts} failed. Retrying in ${delayMs / 1000} second(s)...`
);
await sleep(delayMs); // Wait before retrying
return tryConnect(); // Recursively retry the connection
}
};

// Start the connection attempt
return tryConnect();
}
53 changes: 0 additions & 53 deletions src/lib/server/db/connect_generic.ts

This file was deleted.

6 changes: 3 additions & 3 deletions src/lib/server/db/department.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Database } from './connect';
import type { Department } from '$lib/types/db';
import { DB as db } from './connect';
import { department } from './schema/department';

export async function getDepartments(): Promise<Department[]> {
export async function getDepartments(db: Database): Promise<Department[]> {
return await db.select().from(department);
}

export async function createDepartment(name: string): Promise<void> {
export async function createDepartment(db: Database, name: string): Promise<void> {
// Assert that name is valid
if (name === null || name === undefined || name === '') {
throw new Error('Invalid name');
Expand Down
17 changes: 12 additions & 5 deletions src/lib/server/db/person.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type { Database } from './connect';
import { or, eq, and, max, gt, count, isNull } from 'drizzle-orm';
import { person, personEntry, personExit } from './schema/person';
import { StateInside, StateOutside, type State } from '$lib/types/state';
import { fuzzySearchFilters } from './fuzzysearch';
import { sqlConcat, sqlLeast, sqlLevenshteinDistance } from './utils';
import { isInside } from '../isInside';
import { DB as db } from './connect';
import { capitalizeString, sanitizeString } from '$lib/utils/sanitize';
import { building } from './schema/building';
import { isPersonType, type PersonType } from '$lib/types/person';

// Gets all persons using optional filters
export async function getPersons(
db: Database,
limit: number,
offset: number,
searchQuery?: string
Expand Down Expand Up @@ -176,7 +177,7 @@ export async function getPersons(
}

// Gets the count of all persons per department
export async function getPersonsCountPerDepartment(): Promise<
export async function getPersonsCountPerDepartment(db: Database): Promise<
{
type: PersonType;
department: string;
Expand Down Expand Up @@ -209,7 +210,7 @@ export async function getPersonsCountPerDepartment(): Promise<
}

// Gets the count of all persons that are inside, per building
export async function getPersonsCountPerBuilding(): Promise<
export async function getPersonsCountPerBuilding(db: Database): Promise<
{
type: PersonType | null;
building: string;
Expand Down Expand Up @@ -263,6 +264,7 @@ export async function getPersonsCountPerBuilding(): Promise<

// Creates a person and the entry timestamp
export async function createPerson(
db: Database,
identifierD: string,
type: PersonType,
fnameD: string,
Expand Down Expand Up @@ -344,6 +346,7 @@ export async function createPerson(

// Toggles the state of a person (inside to outside and vice versa)
export async function togglePersonState(
db: Database,
id: number,
building: string,
creator: string
Expand Down Expand Up @@ -401,7 +404,7 @@ export async function togglePersonState(
}
}

export async function getPersonTypes(): Promise<PersonType[]> {
export async function getPersonTypes(db: Database): Promise<PersonType[]> {
try {
const types = await db
.selectDistinctOn([person.type], { type: person.type })
Expand All @@ -418,7 +421,11 @@ export async function getPersonTypes(): Promise<PersonType[]> {
}
}

export async function removePersonsFromBuilding(building: string, type: string): Promise<void> {
export async function removePersonsFromBuilding(
db: Database,
building: string,
type: string
): Promise<void> {
try {
return await db.transaction(async (tx) => {
const personsInside = await tx
Expand Down
21 changes: 9 additions & 12 deletions src/lib/server/db/session.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import type { Database } from './connect';
import { and, desc, eq, notInArray } from 'drizzle-orm';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { encodeHexLowerCase } from '@oslojs/encoding';
import { sha256 } from '@oslojs/crypto/sha2';
import { userTable, type User } from './schema/user';
import { sessionTable, type Session } from './schema/session';
import { DB as db } from './connect';
import { env } from '$env/dynamic/private';

export const inactivityTimeout = Number.parseInt(env.INACTIVITY_TIMEOUT ?? '120') * 60 * 1000;
export const maxActiveSessions = Number.parseInt(env.MAX_ACTIVE_SESSIONS ?? '3');

export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}

export async function createSession(
db: Database,
token: string,
userId: number,
building: string
Expand Down Expand Up @@ -51,7 +45,10 @@ export async function createSession(
}
}

export async function validateSessionToken(token: string): Promise<SessionValidationResult> {
export async function validateSessionToken(
db: Database,
token: string
): Promise<SessionValidationResult> {
// Assert that token is valid
if (token === null || token === undefined || token === '') {
throw new Error('Invalid token');
Expand Down Expand Up @@ -86,7 +83,7 @@ export async function validateSessionToken(token: string): Promise<SessionValida
}
}

export async function invalidateSession(sessionId: string): Promise<void> {
export async function invalidateSession(db: Database, sessionId: string): Promise<void> {
// Assert that sessionId is valid
if (sessionId === null || sessionId === undefined || sessionId === '') {
throw new Error('Invalid sessionId');
Expand All @@ -100,7 +97,7 @@ export async function invalidateSession(sessionId: string): Promise<void> {
}

// Invalidate sessions that exceed the maximum number of sessions
export async function invalidateExcessSessions(userId: number): Promise<void> {
export async function invalidateExcessSessions(db: Database, userId: number): Promise<void> {
// Assert that userId is valid
if (userId === null || userId === undefined) {
throw new Error('Invalid userId');
Expand Down
6 changes: 4 additions & 2 deletions src/lib/server/db/user.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Database } from './connect';
import { and, desc, eq, gt } from 'drizzle-orm';
import { ratelimitTable, userTable } from './schema/user';
import { hashPassword, verifyPasswordStrength } from '../password';
import { DB as db } from './connect';

export async function createUser(username: string, password: string): Promise<void> {
export async function createUser(db: Database, username: string, password: string): Promise<void> {
// Assert that username is valid
if (username === undefined || username === null || username === '') {
throw new Error('Invalid username');
Expand Down Expand Up @@ -32,6 +32,7 @@ export async function createUser(username: string, password: string): Promise<vo
}

export async function getUserIdAndPasswordHash(
db: Database,
username: string
): Promise<{ id: number; passwordHash: string }> {
// Assert that username is valid
Expand Down Expand Up @@ -61,6 +62,7 @@ export async function getUserIdAndPasswordHash(

// Returns true if the user is ratelimited, otherwise false.
export async function checkUserRatelimit(
db: Database,
userId: number,
ratelimitMaxAttempts: number,
ratelimitTimeout: number
Expand Down
8 changes: 8 additions & 0 deletions src/lib/server/session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { RequestEvent } from '@sveltejs/kit';
import { inactivityTimeout } from './db/session';
import { encodeBase32LowerCaseNoPadding } from '@oslojs/encoding';

export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}

export function setSessionTokenCookie(event: RequestEvent, token: string, timestamp: Date): void {
event.cookies.set('session', token, {
Expand Down
6 changes: 0 additions & 6 deletions src/lib/utils/search.ts

This file was deleted.

Loading

0 comments on commit 6132495

Please sign in to comment.