Merge branch 'feature/project-restructure'

This commit is contained in:
rykuno 2024-09-01 23:36:58 -05:00
commit 53ebe9a157
68 changed files with 575 additions and 375 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

3
Dockerfile.minio Normal file
View file

@ -0,0 +1,3 @@
FROM nginx:stable-alpine-slim
COPY minio-console.conf.template /etc/nginx/templates/
RUN rm /etc/nginx/conf.d/default.conf /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh

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

@ -0,0 +1,41 @@
upstream minio_console {
server ${MINIO_HOST}:${MINIO_CONSOLE_PORT};
}
server {
listen ${PORT};
# Allow special characters in headers
ignore_invalid_headers off;
# Allow any size file to be uploaded.
# Set to a value such as 1000m; to restrict file size to a specific value
client_max_body_size 0;
# Disable buffering
proxy_buffering off;
proxy_request_buffering off;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
# This is necessary to pass the correct IP to be hashed
real_ip_header X-Real-IP;
proxy_connect_timeout 300;
# To support websockets in MinIO versions released after January 2023
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Some environments may encounter CORS errors (Kubernetes + Nginx Ingress)
# Uncomment the following line to set the Origin request to an empty string
# proxy_set_header Origin '';
chunked_transfer_encoding off;
proxy_pass http://minio_console; # This uses the upstream directive definition to load balance
}
}

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

@ -1,4 +1,5 @@
services:
# Web
- type: web
name: web
runtime: node
@ -9,6 +10,21 @@ services:
startCommand: npm run db:migrate && node build/index.js
healthCheckPath: /
envVars:
- key: STORAGE_API_SECRET_KEY
fromService:
name: minio-server
type: web
property: MINIO_ROOT_PASSWORD
- key: STORAGE_ACCESS_KEY
fromService:
name: minio-server
type: web
property: MINIO_ROOT_USER
- key: STORAGE_API_URL
fromService:
name: minio-server
type: web
property: connectionString
- key: DATABASE_URL
fromDatabase:
name: tofustack
@ -23,10 +39,62 @@ services:
name: web
type: web
property: host
# Redis
- type: redis
name: redis
ipAllowList: [] # Only allow internal connections
# MinIO Server
- type: web
name: minio-server
healthCheckPath: /minio/health/liveweb
runtime: image
image:
url: docker.io/minio/minio:latest
dockerCommand: minio server /data --address $HOST:$PORT --console-address $HOST:$CONSOLE_PORT
# Use the following 'dockerCommand' if removing the 'minio-console'
# web service
# dockerCommand: minio server /data --address $HOST:$PORT
autoDeploy: false
disk:
name: data
mountPath: /data
envVars:
- key: MINIO_ROOT_USER
generateValue: true
- key: MINIO_ROOT_PASSWORD
generateValue: true
- key: HOST
value: "0.0.0.0"
- key: PORT
value: 9000
- key: CONSOLE_PORT
value: 9090
# Uncomment the following key/value pair if you are removing the
# 'minio-console' web service
# - key: MINIO_BROWSER
# value: "off"
# MinIO Console
- type: web
name: minio-console
runtime: docker
dockerContext: /
dockerfilePath: ./Dockerfile.minio
autoDeploy: false
envVars:
- key: PORT
value: 10000
- key: MINIO_HOST
fromService:
name: minio-server
type: web
property: host
- key: MINIO_CONSOLE_PORT
fromService:
name: minio-server
type: web
envVarKey: CONSOLE_PORT
databases:
- name: db
databaseName: tofustack

View file

@ -1,25 +0,0 @@
import { z } from 'zod';
/* -------------------------------------------------------------------------- */
/* DTO */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Data Transfer Objects (DTOs) are used to define the shape of data that is passed.
They are used to validate data and ensure that the correct data is being passed
to the correct methods.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
DTO's are pretty flexible. You can use them anywhere you want in this application to
validate or shape data. They are especially useful in API routes and services to
ensure that the correct data is being passed around.
*/
/* -------------------------------------------------------------------------- */
export const registerEmailDto = z.object({
email: z.string().email()
});
export type RegisterEmailDto = z.infer<typeof registerEmailDto>;

View file

@ -1,26 +0,0 @@
import { z } from 'zod';
/* -------------------------------------------------------------------------- */
/* DTO */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Data Transfer Objects (DTOs) are used to define the shape of data that is passed.
They are used to validate data and ensure that the correct data is being passed
to the correct methods.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
DTO's are pretty flexible. You can use them anywhere you want in this application to
validate or shape data. They are especially useful in API routes and services to
ensure that the correct data is being passed around.
*/
/* -------------------------------------------------------------------------- */
export const signInEmailDto = z.object({
email: z.string().email(),
token: z.string()
});
export type SignInEmailDto = z.infer<typeof signInEmailDto>;

View file

@ -1,24 +0,0 @@
import { z } from 'zod';
/* -------------------------------------------------------------------------- */
/* DTO */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Data Transfer Objects (DTOs) are used to define the shape of data that is passed.
They are used to validate data and ensure that the correct data is being passed
to the correct methods.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
DTO's are pretty flexible. You can use them anywhere you want in this application to
validate or shape data. They are especially useful in API routes and services to
ensure that the correct data is being passed around.
*/
/* -------------------------------------------------------------------------- */
export const updateEmailDto = z.object({
email: z.string().email()
});
export type UpdateEmailDto = z.infer<typeof updateEmailDto>;

View file

@ -1,24 +0,0 @@
import { z } from 'zod';
/* -------------------------------------------------------------------------- */
/* DTO */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Data Transfer Objects (DTOs) are used to define the shape of data that is passed.
They are used to validate data and ensure that the correct data is being passed
to the correct methods.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
DTO's are pretty flexible. You can use them anywhere you want in this application to
validate or shape data. They are especially useful in API routes and services to
ensure that the correct data is being passed around.
*/
/* -------------------------------------------------------------------------- */
export const verifyEmailDto = z.object({
token: z.string()
});
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>;

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,7 +0,0 @@
import { Hono } from 'hono';
import type { BlankSchema } from 'hono/types';
import type { HonoTypes } from '../types/hono.type';
// export interface Controller {
// routes()
// }

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,6 +1,3 @@
export interface Email {
subject(): string
html(): string;

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 { signInEmailDto } from '../../../dtos/signin-email.dto';
import { updateEmailDto } from '../../../dtos/update-email.dto';
import { verifyEmailDto } from '../../../dtos/verify-email.dto';
import { registerEmailDto } from '../../../dtos/register-email.dto';
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 {
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

@ -3,4 +3,5 @@ import { z } from 'zod';
export const verifyEmailDto = z.object({
token: z.string()
});
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>;

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

@ -1,17 +1,17 @@
import { inject, injectable } from "tsyringe";
import { JobsService } from "../services/jobs.service";
// Example on how to create a job that runs once a week at midnight on Sunday
@injectable()
export class AuthCleanupJobs {
private queue;
constructor(
@inject(JobsService) private jobsService: JobsService,
) {
/* ------------------------------ Create Queue ------------------------------ */
this.queue = this.jobsService.createQueue('test')
constructor(@inject(JobsService) private jobsService: JobsService) {
// 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,17 +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

@ -1,29 +1,28 @@
import { zod } from "sveltekit-superforms/adapters";
import { updateEmailDto } from "$lib/dtos/update-email.dto.js";
import { verifyEmailDto } from "$lib/dtos/verify-email.dto.js";
import { fail, setError, superValidate } from "sveltekit-superforms";
import { StatusCodes } from "$lib/constants/status-codes.js";
import { updateEmailFormSchema, verifyEmailFormSchema } from "./schemas.js";
export let load = async (event) => {
const authedUser = await event.locals.getAuthedUserOrThrow()
return {
authedUser,
updateEmailForm: await superValidate(authedUser, zod(updateEmailDto)),
verifyEmailForm: await superValidate(zod(verifyEmailDto))
updateEmailForm: await superValidate(authedUser, zod(updateEmailFormSchema)),
verifyEmailForm: await superValidate(zod(verifyEmailFormSchema))
};
};
export const actions = {
updateEmail: async ({ request, locals }) => {
const updateEmailForm = await superValidate(request, zod(updateEmailDto));
const updateEmailForm = await superValidate(request, zod(updateEmailFormSchema));
if (!updateEmailForm.valid) return fail(StatusCodes.BAD_REQUEST, { updateEmailForm })
const { error } = await locals.api.iam.email.$patch({ json: updateEmailForm.data }).then(locals.parseApiResponse);
if (error) return setError(updateEmailForm, 'email', error);
return { updateEmailForm }
},
verifyEmail: async ({ request, locals }) => {
const verifyEmailForm = await superValidate(request, zod(verifyEmailDto));
const verifyEmailForm = await superValidate(request, zod(verifyEmailFormSchema));
console.log(verifyEmailForm)
if (!verifyEmailForm.valid) return fail(StatusCodes.BAD_REQUEST, { verifyEmailForm })
const { error } = await locals.api.iam.email.verification.$post({ json: verifyEmailForm.data }).then(locals.parseApiResponse);

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const verifyEmailFormSchema = z.object({
token: z.string()
});
export const updateEmailFormSchema = z.object({
email: z.string().email()
});

View file

@ -1,7 +1,5 @@
<script context="module" lang="ts">
import type { SuperValidated, Infer } from 'sveltekit-superforms';
import type { updateEmailDto } from '$lib/dtos/update-email.dto';
import type { verifyEmailDto } from '$lib/dtos/verify-email.dto';
interface UpdateEmailCardProps {
updateEmailForm: SuperValidated<Infer<typeof updateEmailDto>>;
@ -16,6 +14,8 @@
import { superForm } from 'sveltekit-superforms';
import * as Dialog from '$lib/components/ui/dialog';
import PinInput from '$lib/components/pin-input.svelte';
import type { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import type { verifyEmailDto } from '$lib/server/api/dtos/verify-email.dto';
/* ---------------------------------- props --------------------------------- */
let { updateEmailForm, verifyEmailForm }: UpdateEmailCardProps = $props();

View file

@ -1,27 +1,26 @@
import { fail, redirect } from '@sveltejs/kit';
import { zod } from 'sveltekit-superforms/adapters';
import { signInEmailDto } from '$lib/dtos/signin-email.dto.js';
import { setError, superValidate } from 'sveltekit-superforms';
import { registerEmailDto } from '$lib/dtos/register-email.dto.js';
import { StatusCodes } from '$lib/constants/status-codes';
import { setError, superValidate } from 'sveltekit-superforms';
import { registerFormSchema, signInFormSchema } from './schemas';
export const load = async () => {
return {
emailRegisterForm: await superValidate(zod(registerEmailDto)),
emailSigninForm: await superValidate(zod(signInEmailDto))
emailRegisterForm: await superValidate(zod(registerFormSchema)),
emailSigninForm: await superValidate({email: 'test'}, zod(signInFormSchema))
};
};
export const actions = {
register: async ({ locals, request }) => {
const emailRegisterForm = await superValidate(request, zod(registerEmailDto));
const emailRegisterForm = await superValidate(request, zod(registerFormSchema));
if (!emailRegisterForm.valid) return fail(StatusCodes.BAD_REQUEST, { emailRegisterForm });
const { error } = await locals.api.iam.login.request.$post({ json: emailRegisterForm.data }).then(locals.parseApiResponse);
if (error) return setError(emailRegisterForm, 'email', error);
return { emailRegisterForm };
},
signin: async ({ locals, request }) => {
const emailSignInForm = await superValidate(request, zod(signInEmailDto));
const emailSignInForm = await superValidate(request, zod(signInFormSchema));
if (!emailSignInForm.valid) return fail(StatusCodes.BAD_REQUEST, { emailSignInForm });
const { error } = await locals.api.iam.login.verify.$post({ json: emailSignInForm.data }).then(locals.parseApiResponse)
if (error) return setError(emailSignInForm, 'token', error);

View file

@ -1,41 +1,19 @@
<!-- <script>
import { enhance } from '$app/forms';
</script>
<h3>Register</h3>
<form action="?/register" method="POST" use:enhance>
<label for="email">Email</label>
<input name="email" type="email" />
<button type="submit">Register</button>
</form>
<h3>Verify</h3>
<form action="?/signin" method="POST" use:enhance>
<label for="email">Email</label>
<input name="email" type="email" />
<label for="token">Token</label>
<input name="token" type="text" />
<button type="submit">Login</button>
</form> -->
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { superForm } from 'sveltekit-superforms';
import * as Form from '$lib/components/ui/form';
import { zodClient } from 'sveltekit-superforms/adapters';
import { registerEmailDto } from "$lib/dtos/register-email.dto.js";
import { signInEmailDto } from "$lib/dtos/signin-email.dto.js";
import PinInput from "$lib/components/pin-input.svelte";
import PinInput from '$lib/components/pin-input.svelte';
import { registerFormSchema, signInFormSchema } from './schemas.js';
const {data} = $props();
const { data } = $props();
let showTokenVerification = $state(false);
const emailRegisterForm = superForm(data.emailRegisterForm, {
validators: zodClient(registerEmailDto),
validators: zodClient(registerFormSchema),
resetForm: false,
onUpdated: ({ form }) => {
if (form.valid) {
@ -46,13 +24,12 @@
});
const emailSigninForm = superForm(data.emailSigninForm, {
validators: zodClient(signInEmailDto),
validators: zodClient(signInFormSchema),
resetForm: false
});
const { form: emailRegisterFormData, enhance: emailRegisterEnhance } = emailRegisterForm;
const { form: emailSigninFormData, enhance: emailSigninEnhance } = emailSigninForm;
</script>
<Card.Root class="mx-auto mt-24 max-w-sm">

View file

@ -0,0 +1,12 @@
import { z } from "zod";
export const registerFormSchema = z.object({
email: z.string().email()
});
export const signInFormSchema = z.object({
email: z.string().email(),
token: z.string()
});