Creating controllers, updating deps, and adding more repositories.

This commit is contained in:
Bradley Shellnut 2024-07-28 18:39:42 -07:00
parent 16191509b4
commit 3190e9601e
16 changed files with 799 additions and 539 deletions

View file

@ -25,17 +25,17 @@
"devDependencies": {
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.45.2",
"@playwright/test": "^1.45.3",
"@resvg/resvg-js": "^2.6.2",
"@sveltejs/adapter-auto": "^3.2.2",
"@sveltejs/enhanced-img": "^0.3.0",
"@sveltejs/enhanced-img": "^0.3.1",
"@sveltejs/kit": "^2.5.18",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@types/cookie": "^0.6.0",
"@types/node": "^20.14.11",
"@types/node": "^20.14.13",
"@types/pg": "^8.11.6",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0",
"autoprefixer": "^10.4.19",
"drizzle-kit": "^0.23.0",
"eslint": "^8.57.0",
@ -43,7 +43,7 @@
"eslint-plugin-svelte": "^2.43.0",
"just-clone": "^6.2.0",
"just-debounce-it": "^3.2.0",
"postcss": "^8.4.39",
"postcss": "^8.4.40",
"postcss-import": "^16.1.0",
"postcss-load-config": "^5.1.0",
"postcss-preset-env": "^9.6.0",
@ -61,12 +61,12 @@
"sveltekit-flash-message": "^2.4.4",
"sveltekit-rate-limiter": "^0.5.2",
"sveltekit-superforms": "^2.16.1",
"tailwindcss": "^3.4.6",
"tailwindcss": "^3.4.7",
"ts-node": "^10.9.2",
"tslib": "^2.6.3",
"tsx": "^4.16.2",
"typescript": "^5.5.3",
"vite": "^5.3.4",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vitest": "^1.6.0",
"zod": "^3.23.8"
},
@ -80,7 +80,7 @@
"@hono/zod-validator": "^0.2.2",
"@iconify-icons/line-md": "^1.2.30",
"@iconify-icons/mdi": "^1.2.48",
"@internationalized/date": "^3.5.4",
"@internationalized/date": "^3.5.5",
"@lucia-auth/adapter-drizzle": "^1.0.7",
"@lukeed/uuid": "^2.0.1",
"@neondatabase/serverless": "^0.9.4",
@ -96,10 +96,10 @@
"cookie": "^0.6.0",
"dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6",
"drizzle-orm": "^0.32.0",
"drizzle-orm": "^0.32.1",
"feather-icons": "^4.29.2",
"formsnap": "^1.0.1",
"hono": "^4.5.0",
"hono": "^4.5.2",
"hono-rate-limiter": "^0.4.0",
"html-entities": "^2.5.2",
"iconify-icon": "^2.1.0",
@ -122,6 +122,6 @@
"tailwind-merge": "^2.4.0",
"tailwind-variants": "^0.2.1",
"tailwindcss-animate": "^1.0.7",
"zod-to-json-schema": "^3.23.1"
"zod-to-json-schema": "^3.23.2"
}
}

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ import { requireAuth } from "../middleware/auth.middleware";
import { registerEmailPasswordDto } from '$lib/dtos/register-emailpassword.dto';
import { limiter } from '../middleware/rate-limiter.middleware';
const users = new Hono()
const app = new Hono()
.get('/me', requireAuth, async (c) => {
const user = c.var.user;
return c.json({ user });
@ -19,5 +19,4 @@ const users = new Hono()
return c.json({ message: 'Verification email sent' });
});
export default users;
export type UsersType = typeof users
export default app;

View file

@ -0,0 +1,13 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { registerEmailPasswordDto } from '$lib/dtos/register-emailpassword.dto';
import { limiter } from '../middleware/rate-limiter.middleware';
const app = new Hono()
.post('/', zValidator('json', registerEmailPasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email } = c.req.valid('json');
await loginRequestsService.create({ email });
return c.json({ message: 'Verification email sent' });
});
export default app;

View file

@ -0,0 +1,22 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { requireAuth } from "../middleware/auth.middleware";
import { registerEmailPasswordDto } from '$lib/dtos/register-emailpassword.dto';
import { limiter } from '../middleware/rate-limiter.middleware';
const app = new Hono()
.get('/me', requireAuth, async (c) => {
const user = c.var.user;
return c.json({ user });
})
.get('/user', requireAuth, async (c) => {
const user = c.var.user;
return c.json({ user });
})
.post('/login/request', zValidator('json', registerEmailPasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email } = c.req.valid('json');
await this.loginRequestsService.create({ email });
return c.json({ message: 'Verification email sent' });
});
export default app;

View file

@ -1,7 +1,9 @@
import { Hono } from 'hono';
import { hc } from 'hono/client';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { validateAuthSession, verifyOrigin } from './middleware/auth.middleware';
import users from './controllers/iam.controller';
import users from './controllers/user.controller';
import { config } from './common/config';
/* ----------------------------------- Api ---------------------------------- */
@ -9,10 +11,27 @@ const app = new Hono().basePath('/api');
/* --------------------------- Global Middlewares --------------------------- */
app.use(verifyOrigin).use(validateAuthSession);
app.use(logger());
app.use(
'/*',
cors({
origin: [
'http://localhost:5173',
'http://localhost:80',
'http://host.docker.internal:80',
'http://host.docker.internal:5173'
], // Replace with your allowed domains
allowMethods: ['POST'],
allowHeaders: ['Content-Type']
// credentials: true, // If you need to send cookies or HTTP authentication
})
);
/* --------------------------------- Routes --------------------------------- */
const routes = app
.route('/iam', users)
.route('/user', users)
.get('/', (c) => c.json({ message: 'Server is healthy' }));
/* -------------------------------------------------------------------------- */

View file

@ -4,7 +4,7 @@ import { type InferSelectModel, relations } from 'drizzle-orm';
import { timestamps } from '../utils';
import user_roles from './userRoles';
const usersTable = pgTable('users', {
export const usersTable = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
cuid: text('cuid')
.unique()
@ -25,5 +25,3 @@ export const userRelations = relations(usersTable, ({ many }) => ({
}));
export type Users = InferSelectModel<typeof usersTable>;
export default usersTable;

View file

@ -1,11 +1,10 @@
import { container } from 'tsyringe';
import { lucia } from '../infrastructure/auth/lucia';
// import { lucia } from '../infrastructure/auth/lucia';
// Symbol
export const LuciaProvider = Symbol('LUCIA_PROVIDER');
// // Symbol
// export const LuciaProvider = Symbol('LUCIA_PROVIDER');
// Type
export type LuciaProvider = typeof lucia;
// // Type
// export type LuciaProvider = typeof lucia;
// Register
container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });
// // Register
// container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });

View file

@ -0,0 +1,56 @@
import { eq, type InferInsertModel } from 'drizzle-orm';
import { usersTable } from '../infrastructure/database/tables/users.table';
import { takeFirstOrThrow } from '../infrastructure/database/utils';
import { db } from '../infrastructure/database';
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Repositories are the layer that interacts with the database. They are responsible for retrieving and
storing data. They should not contain any business logic, only database queries.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
Repositories should only contain methods for CRUD operations and any other database interactions.
Any complex logic should be delegated to a service. If a repository method requires a transaction,
it should be passed in as an argument or the class should have a method to set the transaction.
In our case the method 'trxHost' is used to set the transaction context.
*/
export type CreateUser = InferInsertModel<typeof usersTable>;
export type UpdateUser = Partial<CreateUser>;
export class UsersRepository {
async findOneById(id: string) {
return db.query.usersTable.findFirst({
where: eq(usersTable.id, id)
});
}
async findOneByIdOrThrow(id: string) {
const user = await this.findOneById(id);
if (!user) throw Error('User not found');
return user;
}
async findOneByEmail(email: string) {
return db.query.usersTable.findFirst({
where: eq(usersTable.email, email)
});
}
async create(data: CreateUser) {
return db.insert(usersTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateUser) {
return db
.update(usersTable)
.set(data)
.where(eq(usersTable.id, id))
.returning()
.then(takeFirstOrThrow);
}
}

View file

@ -0,0 +1,32 @@
import { Argon2id } from "oslo/password";
/* ---------------------------------- Note ---------------------------------- */
/*
Reference: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
I use Scrpt as the hashing algorithm due to its higher compatability
with vite's build system and it uses less memory than Argon2id.
You can use Argon2id or any other hashing algorithm you prefer.
*/
/* -------------------------------------------------------------------------- */
/*
With Argon2id, you get the following error at times when vite optimizes its dependencies at times,
Error: Build failed with 2 errors:
node_modules/.pnpm/@node-rs+argon2@1.7.0/node_modules/@node-rs/argon2/index.js:159:36: ERROR: No loader is configured for ".node" files: node_module
*/
/* -------------------------------------------------------------------------- */
// If you don't use a hasher from oslo, which are preconfigured with recommended parameters from OWASP,
// ensure that you configure them properly.
export class HashingService {
private readonly hasher = new Argon2id();
async hash(data: string) {
return this.hasher.hash(data);
}
async verify(hash: string, data: string) {
return this.hasher.verify(hash, data)
}
}

View file

@ -0,0 +1,24 @@
import { lucia } from '../infrastructure/auth/lucia';
/* -------------------------------------------------------------------------- */
/* Service */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Services are responsible for handling business logic and data manipulation.
They genreally call on repositories or other services to complete a use-case.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
Services should be kept as clean and simple as possible.
Create private functions to handle complex logic and keep the public methods as
simple as possible. This makes the service easier to read, test and understand.
*/
/* -------------------------------------------------------------------------- */
export class IamService {
async logout(sessionId: string) {
return lucia.invalidateSession(sessionId);
}
}

View file

@ -1,62 +0,0 @@
import { BadRequest } from '../common/errors';
import { DatabaseProvider } from '../providers';
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';
export class LoginRequestsService {
async create(data: RegisterEmailDto) {
// generate a token, expiry date, and hash
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm');
// save the login request to the database - ensuring we save the hashedToken
await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
// send the login request email
await this.mailerService.sendLoginRequest({
to: data.email,
props: { token: token }
});
}
async verify(data: SignInEmailDto) {
const validLoginRequest = await this.fetchValidRequest(data.email, data.token);
if (!validLoginRequest) throw BadRequest('Invalid token');
let existingUser = await this.usersRepository.findOneByEmail(data.email);
if (!existingUser) {
const newUser = await this.handleNewUserRegistration(data.email);
return this.lucia.createSession(newUser.id, {});
}
return this.lucia.createSession(existingUser.id, {});
}
// Create a new user and send a welcome email - or other onboarding process
private async handleNewUserRegistration(email: string) {
const newUser = await this.usersRepository.create({ email, verified: true, avatar: null })
this.mailerService.sendWelcome({ to: email, props: null });
// TODO: add whatever onboarding process or extra data you need here
return newUser
}
// Fetch a valid request from the database, verify the token and burn the request if it is valid
private async fetchValidRequest(email: string, token: string) {
return await this.db.transaction(async (trx) => {
// fetch the login request
const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email)
if (!loginRequest) return null;
// check if the token is valid
const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token);
if (!isValidRequest) return null
// if the token is valid, burn the request
await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id);
return loginRequest
})
}
}

View file

@ -0,0 +1,104 @@
import fs from 'fs';
import path from 'path';
import nodemailer from 'nodemailer';
import handlebars from 'handlebars';
import { fileURLToPath } from 'url';
import { injectable } from 'tsyringe';
/* -------------------------------------------------------------------------- */
/* Service */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Services are responsible for handling business logic and data manipulation.
They genreally call on repositories or other services to complete a use-case.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
Services should be kept as clean and simple as possible.
Create private functions to handle complex logic and keep the public methods as
simple as possible. This makes the service easier to read, test and understand.
*/
/* -------------------------------------------------------------------------- */
type SendMail = {
to: string | string[];
subject: string;
html: string;
};
type SendTemplate<T> = {
to: string | string[];
props: T;
};
@injectable()
export class MailerService {
private nodemailer = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false, // Use `true` for port 465, `false` for all other ports
auth: {
user: 'adella.hoppe@ethereal.email',
pass: 'dshNQZYhATsdJ3ENke'
}
});
sendEmailVerificationToken(data: SendTemplate<{ token: string }>) {
const template = handlebars.compile(this.getTemplate('email-verification-token'));
return this.send({
to: data.to,
subject: 'Email Verification',
html: template({ token: data.props.token })
});
}
sendEmailChangeNotification(data: SendTemplate<null>) {
const template = handlebars.compile(this.getTemplate('email-change-notice'));
return this.send({
to: data.to,
subject: 'Email Change Notice',
html: template(null)
});
}
sendLoginRequest(data: SendTemplate<{ token: string }>) {
const template = handlebars.compile(this.getTemplate('email-verification-token'));
return this.send({
to: data.to,
subject: 'Login Request',
html: template({ token: data.props.token })
});
}
sendWelcome(data: SendTemplate<null>) {
const template = handlebars.compile(this.getTemplate('welcome'));
return this.send({
to: data.to,
subject: 'Welcome!',
html: template(null)
});
}
private async send({ to, subject, html }: SendMail) {
const message = await this.nodemailer.sendMail({
from: '"Example" <example@ethereal.email>', // sender address
bcc: to,
subject, // Subject line
text: html,
html
});
console.log(nodemailer.getTestMessageUrl(message));
}
private getTemplate(template: string) {
const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
const __dirname = path.dirname(__filename); // get the name of the directory
return fs.readFileSync(
path.join(__dirname, `../infrastructure/email-templates/${template}.hbs`),
'utf-8'
);
}
}

View file

@ -0,0 +1,19 @@
import { injectable } from "tsyringe";
import RedisClient from 'ioredis'
import { config } from "../common/config";
import { Queue, Worker, type Processor } from 'bullmq';
@injectable()
export class QueuesServices {
connection = new RedisClient(config.REDIS_URL);
constructor() { }
createQueue(name: string) {
return new Queue(name, { connection: this.connection })
}
createWorker(name: string, prcoessor: Processor) {
return new Worker(name, prcoessor, { connection: this.connection })
}
}

View file

@ -0,0 +1,33 @@
import { generateRandomString } from "oslo/crypto";
import { TimeSpan, createDate, type TimeSpanUnit } from 'oslo';
import { HashingService } from "./hashing.service";
export class TokensService {
private readonly hashingService = new HashingService();
generateToken() {
const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; // alphabet with removed look-alike characters (0, 1, O, I)
return generateRandomString(6, alphabet);
}
generateTokenWithExpiry(number: number, lifespan: TimeSpanUnit) {
return {
token: this.generateToken(),
expiry: createDate(new TimeSpan(number, lifespan))
}
}
async generateTokenWithExpiryAndHash(number: number, lifespan: TimeSpanUnit) {
const token = this.generateToken()
const hashedToken = await this.hashingService.hash(token)
return {
token,
hashedToken,
expiry: createDate(new TimeSpan(number, lifespan))
}
}
async verifyHashedToken(hashedToken: string, token: string) {
return this.hashingService.verify(hashedToken, token)
}
}