removed providers in favor of services

This commit is contained in:
rykuno 2024-09-01 23:36:41 -05:00
parent 4f38b4a6d0
commit 70a0c74948
53 changed files with 413 additions and 212 deletions

View file

@ -1,5 +1,15 @@
# API
ORIGIN=http://localhost:5173
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
# Redis
REDIS_URL=redis://localhost:6379
# Storage
PUBLIC_IMAGE_URI=http://localhost:9000/dev
STORAGE_BUCKET=dev
STORAGE_URL=http://localhost:9000
STORAGE_ACCESS_KEY=user
STORAGE_SECRET_KEY=password

View file

@ -1,8 +1,8 @@
import type { Config } from 'drizzle-kit';
export default {
out: './src/lib/server/api/databases/migrations',
schema: './src/lib/server/api/databases/tables/*.table.ts',
out: './src/lib/server/api/databases/postgres/migrations',
schema: './src/lib/server/api/databases/postgres/tables/*.table.ts',
breakpoints: false,
strict: true,
dialect: 'postgresql',

View file

@ -87,6 +87,8 @@
"mode-watcher": "^0.4.1",
"paneforge": "^0.0.5",
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0",
"redis-om": "^0.4.6",
"resend": "^3.5.0",
"svelte-sonner": "^0.3.27",
"tailwind-merge": "^2.4.0",

View file

@ -38,6 +38,12 @@ importers:
rate-limit-redis:
specifier: ^4.2.0
version: 4.2.0(express-rate-limit@7.4.0(express@4.19.2))
redis:
specifier: ^4.7.0
version: 4.7.0
redis-om:
specifier: ^0.4.6
version: 0.4.6
resend:
specifier: ^3.5.0
version: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -1416,6 +1422,35 @@ packages:
react: ^18.2.0
react-dom: ^18.2.0
'@redis/bloom@1.2.0':
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/client@1.6.0':
resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==}
engines: {node: '>=14'}
'@redis/graph@1.1.1':
resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/json@1.0.7':
resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/search@1.2.0':
resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/time-series@1.1.0':
resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
peerDependencies:
'@redis/client': ^1.0.0
'@rollup/plugin-commonjs@26.0.1':
resolution: {integrity: sha512-UnsKoZK6/aGIH6AdkptXhNvhaqftcjq3zZdT+LY5Ftms6JR06nADcDsYp5hTU9E2lbJUEOhdlY5J4DNTneM+jQ==}
engines: {node: '>=16.0.0 || 14 >= 14.17'}
@ -2638,6 +2673,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
generic-pool@3.9.0:
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
engines: {node: '>= 4'}
get-func-name@2.0.2:
resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==}
@ -2872,6 +2911,10 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
jsonpath-plus@7.2.0:
resolution: {integrity: sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==}
engines: {node: '>=12.0.0'}
just-clone@6.2.0:
resolution: {integrity: sha512-1IynUYEc/HAwxhi3WDpIpxJbZpMCvvrrmZVqvj9EhpvbH8lls7HhdhiByjL7DkAaWlLIzpC0Xc/VPvy/UxLNjA==}
@ -3494,10 +3537,17 @@ packages:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-om@0.4.6:
resolution: {integrity: sha512-L6cfZfG+I7ES+hHfBBKQwUEbfmGQJhIvcreP5NgxkxX+LtYLLXrcpP/sIIW1jMyjdgJE1KRjAbiyiuL2AAHe3g==}
engines: {node: '>= 14'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
redis@4.7.0:
resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@ -3883,6 +3933,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ulid@2.3.0:
resolution: {integrity: sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==}
hasBin: true
undici-types@6.13.0:
resolution: {integrity: sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==}
@ -4024,6 +4078,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
@ -5252,6 +5309,32 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
react-promise-suspense: 0.3.4
'@redis/bloom@1.2.0(@redis/client@1.6.0)':
dependencies:
'@redis/client': 1.6.0
'@redis/client@1.6.0':
dependencies:
cluster-key-slot: 1.1.2
generic-pool: 3.9.0
yallist: 4.0.0
'@redis/graph@1.1.1(@redis/client@1.6.0)':
dependencies:
'@redis/client': 1.6.0
'@redis/json@1.0.7(@redis/client@1.6.0)':
dependencies:
'@redis/client': 1.6.0
'@redis/search@1.2.0(@redis/client@1.6.0)':
dependencies:
'@redis/client': 1.6.0
'@redis/time-series@1.1.0(@redis/client@1.6.0)':
dependencies:
'@redis/client': 1.6.0
'@rollup/plugin-commonjs@26.0.1(rollup@4.20.0)':
dependencies:
'@rollup/pluginutils': 5.1.0(rollup@4.20.0)
@ -6690,6 +6773,8 @@ snapshots:
function-bind@1.1.2: {}
generic-pool@3.9.0: {}
get-func-name@2.0.2: {}
get-intrinsic@1.2.4:
@ -6945,6 +7030,8 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
jsonpath-plus@7.2.0: {}
just-clone@6.2.0: {}
keyv@4.5.4:
@ -7428,10 +7515,26 @@ snapshots:
redis-errors@1.2.0: {}
redis-om@0.4.6:
dependencies:
jsonpath-plus: 7.2.0
just-clone: 6.2.0
redis: 4.7.0
ulid: 2.3.0
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
redis@4.7.0:
dependencies:
'@redis/bloom': 1.2.0(@redis/client@1.6.0)
'@redis/client': 1.6.0
'@redis/graph': 1.1.1(@redis/client@1.6.0)
'@redis/json': 1.0.7(@redis/client@1.6.0)
'@redis/search': 1.2.0(@redis/client@1.6.0)
'@redis/time-series': 1.1.0(@redis/client@1.6.0)
reflect-metadata@0.2.2: {}
regenerator-runtime@0.14.1:
@ -7870,6 +7973,8 @@ snapshots:
typescript@5.5.4: {}
ulid@2.3.0: {}
undici-types@6.13.0: {}
unpipe@1.0.0: {}
@ -7995,6 +8100,8 @@ snapshots:
xtend@4.0.2: {}
yallist@4.0.0: {}
yaml@1.10.2: {}
yaml@2.5.0: {}

View file

@ -0,0 +1,22 @@
import * as envs from '$env/static/private';
import type { Config } from './types/config.type';
export const config: Config = {
isProduction: envs.NODE_ENV === 'production',
api: {
origin: envs.ORIGIN,
},
storage: {
accessKey: envs.STORAGE_ACCESS_KEY,
secretKey: envs.STORAGE_SECRET_KEY,
bucket: envs.STORAGE_BUCKET,
url: envs.STORAGE_URL
},
postgres: {
url: envs.DATABASE_URL
},
redis: {
url: envs.REDIS_URL
}
}

View file

@ -1,5 +0,0 @@
import type { DatabaseProvider } from "../../providers/database.provider";
export interface Repository {
trxHost(trx: DatabaseProvider): any;
}

View file

@ -0,0 +1,3 @@
export abstract class AsyncService {
async init(): Promise<void> { }
}

View file

@ -0,0 +1,26 @@
export interface Config {
isProduction: boolean;
api: ApiConfig;
storage: StorageConfig;
redis: RedisConfig;
postgres: PostgresConfig;
}
interface ApiConfig {
origin: string;
}
interface StorageConfig {
accessKey: string;
secretKey: string;
bucket: string;
url: string;
}
interface RedisConfig {
url: string;
}
interface PostgresConfig {
url: string;
}

View file

@ -1,6 +1,6 @@
import { Hono } from "hono";
import type { HonoTypes } from "../types/hono.type";
import type { BlankSchema, Env, Schema } from "hono/types";
import type { HonoTypes } from "./hono";
import type { BlankSchema } from "hono/types";
export abstract class Controler {
protected readonly controller: Hono<HonoTypes, BlankSchema, '/'>;

View file

@ -1,3 +0,0 @@
import * as envs from '$env/static/private';
export const env = { ...envs, isProduction: process.env.NODE_ENV === 'production' };

View file

@ -1,22 +1,21 @@
import { Hono, type Schema } from 'hono';
import { setCookie } from 'hono/cookie';
import { inject, injectable } from 'tsyringe';
import { zValidator } from '@hono/zod-validator';
import { IamService } from '../services/iam.service';
import { LuciaProvider } from '../providers/lucia.provider';
import { limiter } from '../middlewares/rate-limiter.middlware';
import { requireAuth } from '../middlewares/auth.middleware';
import { Controler } from '../common/classes/controller.class';
import { Controler } from '../common/types/controller';
import { registerEmailDto } from '$lib/server/api/dtos/register-email.dto';
import { signInEmailDto } from '$lib/server/api/dtos/signin-email.dto';
import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import { verifyEmailDto } from '$lib/server/api/dtos/verify-email.dto';
import { LuciaService } from '../services/lucia.service';
@injectable()
export class IamController extends Controler {
constructor(
@inject(IamService) private iamService: IamService,
@inject(LuciaProvider) private lucia: LuciaProvider,
@inject(LuciaService) private luciaService: LuciaService,
) {
super();
}
@ -35,7 +34,7 @@ export class IamController extends Controler {
.post('/login/verify', zValidator('json', signInEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email, token } = c.req.valid('json');
const session = await this.iamService.verifyLoginRequest({ email, token });
const sessionCookie = this.lucia.createSessionCookie(session.id);
const sessionCookie = this.luciaService.lucia.createSessionCookie(session.id);
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge: sessionCookie.attributes.maxAge,
@ -50,7 +49,7 @@ export class IamController extends Controler {
.post('/logout', requireAuth, async (c) => {
const sessionId = c.var.session.id;
await this.iamService.logout(sessionId);
const sessionCookie = this.lucia.createBlankSessionCookie();
const sessionCookie = this.luciaService.lucia.createBlankSessionCookie();
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge: sessionCookie.attributes.maxAge,

View file

@ -2,7 +2,7 @@ import { createId } from '@paralleldrive/cuid2';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { usersTable } from './users.table';
import { timestamps } from '../../common/utils/table.utils';
import { timestamps } from '../../../common/utils/table';
export const emailVerificationsTable = pgTable('email_verifications', {
id: text('id')

View file

@ -1,5 +1,5 @@
import { bigint, pgTable, text } from "drizzle-orm/pg-core";
import { cuid2, timestamps } from "../../common/utils/table.utils";
import { cuid2, timestamps } from "../../../common/utils/table";
import { createId } from "@paralleldrive/cuid2";
export const filesTable = pgTable('files', {

View file

@ -1,7 +1,7 @@
import { relations } from 'drizzle-orm';
import { createId } from '@paralleldrive/cuid2';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { timestamps } from '../../common/utils/table.utils';
import { timestamps } from '../../../common/utils/table';
export const loginRequestsTable = pgTable('login_requests', {
id: text('id')

View file

@ -1,4 +1,4 @@
import { cuid2 } from '../../common/utils/table.utils';
import { cuid2 } from '../../../common/utils/table';
import { usersTable } from './users.table';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';

View file

@ -3,7 +3,7 @@ import { createId } from '@paralleldrive/cuid2';
import { sessionsTable } from './sessions.table';
import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
import { emailVerificationsTable } from './email-verifications.table';
import { citext, cuid2, timestamps } from '../../common/utils/table.utils';
import { citext, cuid2, timestamps } from '../../../common/utils/table';
import { filesTable } from './files.table';

View file

@ -0,0 +1,7 @@
import { Schema } from 'redis-om'
export const loginRequestSchema = new Schema('album', {
id: { type: 'string' },
hashedToken: { type: 'string' },
email: { type: 'string' },
})

View file

@ -1,4 +1,4 @@
import type { Email } from "../common/inferfaces/email.interface"
import type { Email } from "../common/types/email"
export class EmailChangeNoticeEmail implements Email {
constructor() { }

View file

@ -1,4 +1,4 @@
import type { Email } from "../common/inferfaces/email.interface"
import type { Email } from "../common/types/email"
export class LoginVerificationEmail implements Email {
constructor(private readonly token: string) { }

View file

@ -1,4 +1,4 @@
import type { Email } from "../common/inferfaces/email.interface";
import type { Email } from "../common/types/email";
export class WelcomeEmail implements Email {
constructor() { }

View file

@ -3,7 +3,7 @@ import { Hono } from 'hono';
import { hc } from 'hono/client';
import { container } from 'tsyringe';
import { IamController } from './controllers/iam.controller';
import { env } from './configs/envs.config';
import { config, env } from './common/config';
import { validateAuthSession, verifyOrigin } from './middlewares/auth.middleware';
import { AuthCleanupJobs } from './jobs/auth-cleanup.job';
@ -32,7 +32,7 @@ container.resolve(AuthCleanupJobs).deleteStaleLoginRequests();
/* -------------------------------------------------------------------------- */
/* Exports */
/* -------------------------------------------------------------------------- */
const rpc = hc<typeof routes>(env.ORIGIN);
const rpc = hc<typeof routes>(config.api.origin);
export type ApiClient = typeof rpc;
export type ApiRoutes = typeof routes;

View file

@ -8,10 +8,10 @@ export class AuthCleanupJobs {
private queue;
constructor(@inject(JobsService) private jobsService: JobsService) {
/* ------------------------------ Create Queue ------------------------------ */
// create queue
this.queue = this.jobsService.createQueue('auth_cleanup')
/* ---------------------------- Register Workers ---------------------------- */
// register workers
this.worker();
}

View file

@ -3,9 +3,14 @@ import { createMiddleware } from 'hono/factory';
import { verifyRequestOrigin } from 'lucia';
import type { Session, User } from 'lucia';
import { Unauthorized } from '../common/exceptions';
import type { HonoTypes } from '../common/types/hono.type';
import { lucia } from '../packages/lucia';
import type { HonoTypes } from '../common/types/hono';
import { container } from 'tsyringe';
import { LuciaService } from '../services/lucia.service';
// resolve dependencies from the container
const { lucia } = container.resolve(LuciaService)
// Middleware to verify the origin of the request
export const verifyOrigin: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => {
if (c.req.method === "GET") {
return next();

View file

@ -1,11 +1,13 @@
import { rateLimiter } from "hono-rate-limiter";
import { RedisStore } from 'rate-limit-redis'
import RedisClient from 'ioredis'
import { env } from "../configs/envs.config";
import type { HonoTypes } from "../common/types/hono.type";
import type { HonoTypes } from "../common/types/hono";
import { container } from "tsyringe";
import { RedisService } from '../services/redis.service';
const client = new RedisClient(env.REDIS_URL)
// resolve dependencies from the container
const { client } = container.resolve(RedisService);
// Rate limiter middleware
export function limiter({ limit, minutes, key = "" }: {
limit: number;
minutes: number;
@ -23,8 +25,7 @@ export function limiter({ limit, minutes, key = "" }: {
}, // Method to generate custom identifiers for clients.
// Redis store configuration
store: new RedisStore({
// @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis
sendCommand: (...args: string[]) => client.call(...args),
sendCommand: (...args: string[]) => client.sendCommand(args),
}) as any,
})
}

View file

@ -1,7 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../databases/tables';
import { env } from '../configs/envs.config';
const client = postgres(env.DATABASE_URL!, { max: 1 });
export const db = drizzle(client, { schema });

View file

@ -1,22 +0,0 @@
import { Lucia } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import { sessionsTable, usersTable } from '../databases/tables';
import { env } from '../configs/envs.config';
import { db } from './drizzle';
const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: env.isProduction
}
},
getUserAttributes: (attributes) => {
return {
// attributes has the type of DatabaseUserAttributes
...attributes
};
}
});

View file

@ -1,12 +0,0 @@
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '$env/dynamic/private';
export const s3Client = new S3Client({
region: 'auto',
endpoint: env.STORAGE_API_URL,
credentials: {
accessKeyId: env.STORAGE_API_ACCESS_KEY,
secretAccessKey: env.STORAGE_API_SECRET_KEY
},
forcePathStyle: true
})

View file

@ -1,6 +0,0 @@
import { container } from 'tsyringe';
import { db } from '../packages/drizzle';
export const DatabaseProvider = Symbol('DATABASE_TOKEN');
export type DatabaseProvider = typeof db;
container.register<DatabaseProvider>(DatabaseProvider, { useValue: db });

View file

@ -1,6 +0,0 @@
import { container } from 'tsyringe';
import { lucia } from '../packages/lucia';
export const LuciaProvider = Symbol('LUCIA_PROVIDER');
export type LuciaProvider = typeof lucia;
container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });

View file

@ -1,11 +0,0 @@
import { container } from 'tsyringe';
import { env } from '../configs/envs.config';
import RedisClient from 'ioredis'
export const RedisProvider = Symbol('REDIS_TOKEN');
export type RedisProvider = RedisClient;
container.register<RedisProvider>(RedisProvider, {
useValue: new RedisClient(env.REDIS_URL, {
maxRetriesPerRequest: null
})
});

View file

@ -1,9 +0,0 @@
import { container } from 'tsyringe';
import { S3Client } from '@aws-sdk/client-s3';
import { s3Client } from '../packages/s3';
export const S3ClientProvider = Symbol('STORAGE_TOKEN');
export type S3ClientProvider = S3Client;
container.register<S3ClientProvider>(S3ClientProvider, {
useValue: s3Client
});

View file

@ -1,39 +1,35 @@
import { inject, injectable } from "tsyringe";
import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm";
import { emailVerificationsTable } from "../databases/tables";
import type { Repository } from "../common/inferfaces/repository.interface";
import { DatabaseProvider } from "../providers/database.provider";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
import { and, eq, gte, type InferInsertModel } from "drizzle-orm";
import { emailVerificationsTable } from "../databases/postgres/tables";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository";
import { DrizzleService } from "../services/drizzle.service";
export type CreateEmailVerification = Pick<InferInsertModel<typeof emailVerificationsTable>, 'requestedEmail' | 'hashedToken' | 'userId' | 'expiresAt'>;
@injectable()
export class EmailVerificationsRepository implements Repository {
constructor(@inject(DatabaseProvider) private readonly db: DatabaseProvider) { }
export class EmailVerificationsRepository {
constructor(@inject(DrizzleService) private readonly drizzle: DrizzleService) { }
// creates a new email verification record or updates an existing one
async create(data: CreateEmailVerification) {
return this.db.insert(emailVerificationsTable).values(data).onConflictDoUpdate({
return this.drizzle.db.insert(emailVerificationsTable).values(data).onConflictDoUpdate({
target: emailVerificationsTable.userId,
set: data
}).returning().then(takeFirstOrThrow)
}
// finds a valid record by token and userId
async findValidRecord(userId: string) {
return this.db.select().from(emailVerificationsTable).where(
async findValidRecord(userId: string, db = this.drizzle.db) {
return db.select().from(emailVerificationsTable).where(
and(
eq(emailVerificationsTable.userId, userId),
gte(emailVerificationsTable.expiresAt, new Date())
)).then(takeFirst)
}
async deleteById(id: string) {
return this.db.delete(emailVerificationsTable).where(eq(emailVerificationsTable.id, id))
async deleteById(id: string, db = this.drizzle.db) {
return db.delete(emailVerificationsTable).where(eq(emailVerificationsTable.id, id))
}
trxHost(trx: DatabaseProvider) {
return new EmailVerificationsRepository(trx)
}
}

View file

@ -1,29 +1,29 @@
import { inject } from "tsyringe";
import { StorageService } from "../services/storage.service";
import { DatabaseProvider } from "../providers/database.provider";
import { eq } from "drizzle-orm";
import { filesTable } from "../databases/tables/files.table";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
import { filesTable } from "../databases/postgres/tables/files.table";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository";
import { DrizzleService } from "../services/drizzle.service";
export class FilesRepository {
constructor(
@inject(StorageService) private readonly storageService: StorageService,
@inject(DatabaseProvider) private readonly db: DatabaseProvider) { }
@inject(DrizzleService) private readonly drizzle: DrizzleService) { }
async create(file: File, db = this.db) {
async create(file: File, db = this.drizzle.db) {
const asset = await this.storageService.upload(file);
return db.insert(filesTable).values({ key: asset.key, contentType: asset.type, size: BigInt(asset.size) }).returning().then(takeFirst)
}
async findOneById(id: string, db = this.db) {
async findOneById(id: string, db = this.drizzle.db) {
return db.select().from(filesTable).where(eq(filesTable.id, id)).then(takeFirst)
}
async findOneByIdOrThrow(id: string, db = this.db) {
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
return db.select().from(filesTable).where(eq(filesTable.id, id)).then(takeFirstOrThrow)
}
async update(id: string, file: File, db = this.db) {
async update(id: string, file: File, db = this.drizzle.db) {
// upload new file
const newAsset = await this.storageService.upload(file);
await db.update(filesTable).set({ key: newAsset.key, contentType: newAsset.type, size: BigInt(newAsset.size) }).where(eq(filesTable.id, id))
@ -33,7 +33,7 @@ export class FilesRepository {
await this.storageService.delete(oldAsset.key)
}
async delete(id: string, db = this.db) {
async delete(id: string, db = this.drizzle.db) {
const asset = await this.findOneByIdOrThrow(id)
await this.storageService.delete(asset.key)
await db.delete(filesTable).where(eq(filesTable.id, id))

View file

@ -1,26 +1,27 @@
import { inject, injectable } from "tsyringe";
import { and, eq, gte, type InferInsertModel } from "drizzle-orm";
import { loginRequestsTable } from "../databases/tables";
import type { Repository } from "../common/inferfaces/repository.interface";
import { DatabaseProvider } from "../providers/database.provider";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
import { loginRequestsTable } from "../databases/postgres/tables";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository";
import { DrizzleService } from "../services/drizzle.service";
export type CreateLoginRequest = Pick<InferInsertModel<typeof loginRequestsTable>, 'email' | 'expiresAt' | 'hashedToken'>;
@injectable()
export class LoginRequestsRepository implements Repository {
constructor(@inject(DatabaseProvider) private readonly db: DatabaseProvider) { }
export class LoginRequestsRepository {
constructor(
@inject(DrizzleService) private readonly drizzle: DrizzleService,
) { }
async create(data: CreateLoginRequest) {
return this.db.insert(loginRequestsTable).values(data).onConflictDoUpdate({
async create(data: CreateLoginRequest, db = this.drizzle.db) {
return db.insert(loginRequestsTable).values(data).onConflictDoUpdate({
target: loginRequestsTable.email,
set: data
}).returning().then(takeFirstOrThrow)
}
async findOneByEmail(email: string) {
return this.db.select().from(loginRequestsTable).where(
async findOneByEmail(email: string, db = this.drizzle.db) {
return db.select().from(loginRequestsTable).where(
and(
eq(loginRequestsTable.email, email),
gte(loginRequestsTable.expiresAt, new Date())
@ -28,11 +29,7 @@ export class LoginRequestsRepository implements Repository {
).then(takeFirst)
}
async deleteById(id: string) {
return this.db.delete(loginRequestsTable).where(eq(loginRequestsTable.id, id));
}
trxHost(trx: DatabaseProvider) {
return new LoginRequestsRepository(trx);
async deleteById(id: string, db = this.drizzle.db) {
return db.delete(loginRequestsTable).where(eq(loginRequestsTable.id, id));
}
}

View file

@ -1,49 +1,45 @@
import { inject, injectable } from 'tsyringe';
import { usersTable } from '../databases/tables';
import { usersTable } from '../databases/postgres/tables';
import { eq, type InferInsertModel } from 'drizzle-orm';
import { DatabaseProvider } from '../providers/database.provider';
import { takeFirstOrThrow } from '../common/utils/repository.utils';
import { takeFirstOrThrow } from '../common/utils/repository';
import type { Repository } from '../common/inferfaces/repository.interface';
import { DrizzleService } from '../services/drizzle.service';
export type CreateUser = InferInsertModel<typeof usersTable>;
export type UpdateUser = Partial<CreateUser>;
@injectable()
export class UsersRepository implements Repository {
constructor(@inject(DatabaseProvider) private db: DatabaseProvider) { }
export class UsersRepository {
constructor(@inject(DrizzleService) private drizzle: DrizzleService) { }
async findOneById(id: string) {
return this.db.query.usersTable.findFirst({
async findOneById(id: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.id, id)
});
}
async findOneByIdOrThrow(id: string) {
const user = await this.findOneById(id);
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const user = await this.findOneById(id, db);
if (!user) throw Error('User not found');
return user;
}
async findOneByEmail(email: string) {
return this.db.query.usersTable.findFirst({
async findOneByEmail(email: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.email, email)
});
}
async create(data: CreateUser) {
return this.db.insert(usersTable).values(data).returning().then(takeFirstOrThrow);
async create(data: CreateUser, db = this.drizzle.db) {
return db.insert(usersTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateUser) {
return this.db
async update(id: string, data: UpdateUser, db = this.drizzle.db) {
return db
.update(usersTable)
.set(data)
.where(eq(usersTable.id, id))
.returning()
.then(takeFirstOrThrow);
}
trxHost(trx: DatabaseProvider) {
return new UsersRepository(trx);
}
}

View file

@ -0,0 +1,22 @@
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { injectable, type Disposable } from "tsyringe";
import { config } from "../common/config";
import * as schema from '../databases/postgres/tables';
@injectable()
export class DrizzleService implements Disposable {
protected readonly client: postgres.Sql<{}>
readonly db: PostgresJsDatabase<typeof schema>
readonly schema: typeof schema = schema;
constructor() {
const client = postgres(config.postgres.url, { max: 1 })
this.client = client;
this.db = drizzle(client, { schema })
}
dispose(): Promise<void> | void {
this.client.end();
}
}

View file

@ -1,23 +1,23 @@
import { inject, injectable } from 'tsyringe';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { LuciaProvider } from '../providers/lucia.provider';
import { UsersRepository } from '../repositories/users.repository';
import type { SignInEmailDto } from '../dtos/signin-email.dto';
import type { RegisterEmailDto } from '../dtos/register-email.dto';
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
import { LoginVerificationEmail } from '../emails/login-verification.email';
import { DatabaseProvider } from '../providers/database.provider';
import { BadRequest } from '../common/exceptions';
import { WelcomeEmail } from '../emails/welcome.email';
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
import { EmailChangeNoticeEmail } from '../emails/email-change-notice.email';
import { DrizzleService } from './drizzle.service';
import { LuciaService } from './lucia.service';
@injectable()
export class IamService {
constructor(
@inject(LuciaProvider) private readonly lucia: LuciaProvider,
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
@inject(LuciaService) private readonly luciaService: LuciaService,
@inject(DrizzleService) private readonly drizzleService: DrizzleService,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@ -42,10 +42,10 @@ export class IamService {
if (!existingUser) {
const newUser = await this.handleNewUserRegistration(data.email);
return this.lucia.createSession(newUser.id, {});
return this.luciaService.lucia.createSession(newUser.id, {});
}
return this.lucia.createSession(existingUser.id, {});
return this.luciaService.lucia.createSession(existingUser.id, {});
}
// These steps follow the process outlined in OWASP's "Changing A User's Email Address" guide.
@ -80,7 +80,7 @@ export class IamService {
}
async logout(sessionId: string) {
return this.lucia.invalidateSession(sessionId);
return this.luciaService.lucia.invalidateSession(sessionId);
}
// Create a new user and send a welcome email - or other onboarding process
@ -93,9 +93,9 @@ export class IamService {
// Fetch a valid request from the database, verify the token and burn the request if it is valid
private async getValidLoginRequest(email: string, token: string) {
return await this.db.transaction(async (trx) => {
return await this.drizzleService.db.transaction(async (trx) => {
// fetch the login request
const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email)
const loginRequest = await this.loginRequestsRepository.findOneByEmail(email, trx)
if (!loginRequest) return null;
// check if the token is valid
@ -103,15 +103,15 @@ export class IamService {
if (!isValidRequest) return null
// if the token is valid, burn the request
await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id);
await this.loginRequestsRepository.deleteById(loginRequest.id, trx);
return loginRequest
})
}
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
return this.db.transaction(async (trx) => {
return this.drizzleService.db.transaction(async (trx) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository.trxHost(trx).findValidRecord(userId);
const emailVerificationRecord = await this.emailVerificationsRepository.findValidRecord(userId, trx);
if (!emailVerificationRecord) return null;
// check if the token is valid
@ -119,7 +119,7 @@ export class IamService {
if (!isValidRecord) return null
// burn the token if it is valid
await this.emailVerificationsRepository.trxHost(trx).deleteById(emailVerificationRecord.id)
await this.emailVerificationsRepository.deleteById(emailVerificationRecord.id, trx)
return emailVerificationRecord
})
}

View file

@ -1,16 +1,28 @@
import { inject, injectable } from "tsyringe";
import { injectable } from "tsyringe";
import { Queue, Worker, type Processor } from 'bullmq';
import { RedisProvider } from "../providers/redis.provider";
import RedisClient from "ioredis";
import { config } from "../common/config";
// BullMQ utilizes ioredis, which is no longer maintained but still works fine.
// I recommend using BullMQ with ioredis for now, but keep an eye out for future updates.
@injectable()
export class JobsService {
constructor(@inject(RedisProvider) private readonly redis: RedisProvider) { }
constructor() { }
createQueue(name: string) {
return new Queue(name, { connection: this.redis })
return new Queue(name, {
connection: new RedisClient(config.redis.url, {
maxRetriesPerRequest: null
})
})
}
createWorker(name: string, prcoessor: Processor) {
return new Worker(name, prcoessor, { connection: this.redis })
return new Worker(name, prcoessor, {
connection: new RedisClient(config.redis.url, {
maxRetriesPerRequest: null
})
})
}
}

View file

@ -0,0 +1,25 @@
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { Lucia } from "lucia";
import { inject, injectable } from "tsyringe";
import { DrizzleService } from "./drizzle.service";
import { config } from "../common/config";
@injectable()
export class LuciaService {
readonly lucia: Lucia;
constructor(@inject(DrizzleService) private readonly drizzle: DrizzleService) {
const adapter = new DrizzlePostgreSQLAdapter(this.drizzle.db, this.drizzle.schema.sessionsTable, this.drizzle.schema.usersTable);
this.lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: config.isProduction
}
},
getUserAttributes: (attributes) => {
return {
...attributes
};
}
});
}
}

View file

@ -1,6 +1,6 @@
import { injectable } from 'tsyringe';
import { env } from '../configs/envs.config';
import type { Email } from '../common/inferfaces/email.interface';
import { config } from '../common/config';
type SendProps = {
to: string | string[];
@ -11,7 +11,7 @@ type SendProps = {
export class MailerService {
async send(data: SendProps) {
const mailer = env.isProduction ? this.sendProd : this.sendDev;
const mailer = config.isProduction ? this.sendProd : this.sendDev;
await mailer(data);
}

View file

@ -0,0 +1,42 @@
import { createClient, type RedisClientType } from "redis";
import { injectable, type Disposable } from "tsyringe";
import { config } from "../common/config";
import type { AsyncService } from "../common/inferfaces/async-service.interface";
@injectable()
export class RedisService implements Disposable, AsyncService {
readonly client: RedisClientType;
private isConnected: boolean = false;
constructor() {
this.client = createClient({
url: config.redis.url,
});
this.init();
}
async ensureConnected(): Promise<void> {
if (!this.isConnected) {
await this.init();
}
}
async init(): Promise<void> {
try {
await this.client.connect();
this.isConnected = this.client.isReady;
console.log('Redis connected');
} catch (error) {
console.error('Failed to connect to Redis:', error);
throw error;
}
}
async dispose(): Promise<void> {
if (this.isConnected) {
await this.client.disconnect();
this.isConnected = false;
console.log('Redis disconnected');
}
}
}

View file

@ -1,17 +1,28 @@
import { PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { PutObjectCommand, DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { createId } from '@paralleldrive/cuid2';
import { inject, injectable } from 'tsyringe';
import { env } from '../configs/envs.config';
import { S3ClientProvider } from '../providers/s3.provider';
import { injectable } from 'tsyringe';
import { config } from '../common/config';
@injectable()
export class StorageService {
constructor(@inject(S3ClientProvider) private readonly s3Client: S3ClientProvider) { }
protected readonly s3Client: S3Client
constructor() {
this.s3Client = new S3Client({
region: 'auto',
endpoint: config.storage.url,
credentials: {
accessKeyId: config.storage.accessKey,
secretAccessKey: config.storage.secretKey
},
forcePathStyle: true
})
}
async upload(file: File) {
const key = createId();
const uploadCommand = new PutObjectCommand({
Bucket: env.STORAGE_BUCKET_NAME,
Bucket: config.storage.bucket,
ACL: 'public-read',
Key: key,
ContentType: file.type,
@ -24,7 +35,7 @@ export class StorageService {
delete(key: string) {
const deleteCommand = new DeleteObjectCommand({
Bucket: env.STORAGE_BUCKET_NAME,
Bucket: config.storage.bucket,
Key: key
});

View file

@ -1,23 +1,22 @@
import 'reflect-metadata';
import { LoginRequestsService } from '../services/login-requests.service';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { TokensService } from '../services/tokens.service';
import { MailerService } from '../services/mailer.service';
import { UsersRepository } from '../repositories/users.repository';
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
import { PgDatabase } from 'drizzle-orm/pg-core';
import { container } from 'tsyringe';
import { LuciaProvider } from '../providers/lucia.provider';
import { DatabaseProvider } from '../providers/database.provider';
import { LuciaService } from '../services/lucia.service';
import { DrizzleService } from '../services/drizzle.service';
import { IamService } from '../services/iam.service';
describe('LoginRequestService', () => {
let service: LoginRequestsService;
let service: IamService;
let tokensService = vi.mocked(TokensService.prototype)
let mailerService = vi.mocked(MailerService.prototype);
let usersRepository = vi.mocked(UsersRepository.prototype);
let loginRequestsRepository = vi.mocked(LoginRequestsRepository.prototype);
let luciaProvider = vi.mocked(LuciaProvider);
let databaseProvider = vi.mocked(PgDatabase);
let luciaService = vi.mocked(LuciaService.prototype);
let drizzleService = vi.mocked(DrizzleService.prototype);
beforeAll(() => {
service = container
@ -25,9 +24,9 @@ describe('LoginRequestService', () => {
.register<MailerService>(MailerService, { useValue: mailerService })
.register<UsersRepository>(UsersRepository, { useValue: usersRepository })
.register(LoginRequestsRepository, { useValue: loginRequestsRepository })
.register(LuciaProvider, { useValue: luciaProvider })
.register(DatabaseProvider, { useValue: databaseProvider })
.resolve(LoginRequestsService);
.register(LuciaService, { useValue: luciaService })
.register(DrizzleService, { useValue: drizzleService })
.resolve(IamService);
});
afterAll(() => {
@ -57,7 +56,7 @@ describe('LoginRequestService', () => {
const spy_loginRequestsRepository_create = vi.spyOn(loginRequestsRepository, 'create')
it('should resolve', async () => {
await expect(service.create({ email: "test" })).resolves.toBeUndefined()
await expect(service.createLoginRequest({ email: "test" })).resolves.toBeUndefined()
})
it('should generate a token with expiry and hash', async () => {
expect(spy_tokensService_generateTokenWithExpiryAndHash).toBeCalledTimes(1)

View file

@ -7,7 +7,7 @@ import { registerFormSchema, signInFormSchema } from './schemas';
export const load = async () => {
return {
emailRegisterForm: await superValidate(zod(registerFormSchema)),
emailSigninForm: await superValidate(zod(signInFormSchema))
emailSigninForm: await superValidate({email: 'test'}, zod(signInFormSchema))
};
};