mirror of
https://github.com/BradNut/TofuStack
synced 2025-09-08 17:40:26 +00:00
simplifying iam service
This commit is contained in:
parent
fca1a2444a
commit
a2a3f3faf3
67 changed files with 3078 additions and 1925 deletions
|
|
@ -9,13 +9,48 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- '5432:5432'
|
- '5432:5432'
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/data
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
ports:
|
ports:
|
||||||
- '6379:6379'
|
- '6379:6379'
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
|
minio:
|
||||||
|
image: docker.io/bitnami/minio
|
||||||
|
ports:
|
||||||
|
- '9000:9000'
|
||||||
|
- '9001:9001'
|
||||||
|
networks:
|
||||||
|
- minionetwork
|
||||||
|
volumes:
|
||||||
|
- 'minio_data:/data'
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=user
|
||||||
|
- MINIO_ROOT_PASSWORD=password
|
||||||
|
- MINIO_DEFAULT_BUCKETS=dev
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit
|
||||||
|
container_name: mailpit
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- mailpit_data:/data
|
||||||
|
ports:
|
||||||
|
- 8025:8025
|
||||||
|
- 1025:1025
|
||||||
|
environment:
|
||||||
|
MP_MAX_MESSAGES: 5000
|
||||||
|
MP_DATABASE: /data/mailpit.db
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
mailpit_data:
|
||||||
|
minio_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
minionetwork:
|
||||||
|
driver: bridge
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Config } from 'drizzle-kit';
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
out: './src/lib/server/api/infrastructure/database/migrations',
|
out: './src/lib/server/api/databases/migrations',
|
||||||
schema: './src/lib/server/api/infrastructure/database/tables/*.table.ts',
|
schema: './src/lib/server/api/databases/tables/*.table.ts',
|
||||||
breakpoints: false,
|
breakpoints: false,
|
||||||
strict: true,
|
strict: true,
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
|
|
|
||||||
93
package.json
93
package.json
|
|
@ -20,76 +20,77 @@
|
||||||
"test:unit": "vitest"
|
"test:unit": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.624.0",
|
||||||
"@hono/zod-validator": "^0.2.2",
|
"@hono/zod-validator": "^0.2.2",
|
||||||
"@lucia-auth/adapter-drizzle": "^1.0.7",
|
"@lucia-auth/adapter-drizzle": "^1.1.0",
|
||||||
|
"@node-rs/argon2": "^1.8.3",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.46.0",
|
||||||
"@sveltejs/adapter-auto": "^3.2.2",
|
"@sveltejs/adapter-auto": "^3.2.2",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.5.17",
|
"@sveltejs/kit": "^2.5.20",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"@types/eslint": "^8.56.0",
|
"@types/eslint": "^9.6.0",
|
||||||
"@types/node": "^20.14.8",
|
"@types/node": "^22.1.0",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/pluralize": "^0.0.33",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
||||||
"@typescript-eslint/parser": "^7.13.1",
|
"@typescript-eslint/parser": "^8.0.1",
|
||||||
"arctic": "^1.9.1",
|
"arctic": "^1.9.2",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.20",
|
||||||
"bullmq": "^5.8.3",
|
"bullmq": "^5.12.1",
|
||||||
"dayjs": "^1.11.11",
|
"chalk": "^5.3.0",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"drizzle-kit": "^0.21.4",
|
"drizzle-kit": "^0.23.2",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.32.2",
|
||||||
"eslint": "^8.56.0",
|
"ejs": "^3.1.10",
|
||||||
|
"eslint": "^9.8.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.40.0",
|
"eslint-plugin-svelte": "^2.43.0",
|
||||||
"handlebars": "^4.7.8",
|
"glob": "^11.0.0",
|
||||||
"hono": "^4.4.7",
|
"hono": "^4.5.4",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"lucia": "^3.2.0",
|
"lucia": "^3.2.0",
|
||||||
"lucide-svelte": "^0.396.0",
|
"lucide-svelte": "^0.424.0",
|
||||||
"nodemailer": "^6.9.14",
|
"oslo": "^1.2.1",
|
||||||
"oslo": "^1.2.0",
|
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.41",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.5",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.4",
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"svelte": "5.0.0-next.164",
|
"svelte": "5.0.0-next.164",
|
||||||
"svelte-check": "^3.8.1",
|
"svelte-check": "^3.8.5",
|
||||||
"svelte-eslint-parser": "^0.36.0",
|
"svelte-dnd-action": "^0.9.49",
|
||||||
"sveltekit-superforms": "^2.15.1",
|
"svelte-eslint-parser": "^0.41.0",
|
||||||
"tailwindcss": "^3.4.4",
|
"sveltekit-search-params": "^3.0.0",
|
||||||
|
"sveltekit-superforms": "^2.16.1",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
"tslib": "^2.6.3",
|
"tslib": "^2.6.3",
|
||||||
"tsx": "^4.15.7",
|
"tsx": "^4.16.5",
|
||||||
"tsyringe": "^4.8.0",
|
"tsyringe": "^4.8.0",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.3.1",
|
"vite": "^5.3.5",
|
||||||
"vite-plugin-full-reload": "^1.1.0",
|
"vitest": "^2.0.5",
|
||||||
"vite-plugin-restart": "^0.4.0",
|
|
||||||
"vitest": "^1.6.0",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.5.4",
|
"@internationalized/date": "^3.5.5",
|
||||||
"@node-rs/argon2": "^1.8.3",
|
"bits-ui": "^0.21.13",
|
||||||
"bits-ui": "^0.21.10",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk-sv": "^0.0.17",
|
"cmdk-sv": "^0.0.18",
|
||||||
"embla-carousel-svelte": "^8.1.5",
|
"embla-carousel-svelte": "^8.1.8",
|
||||||
"formsnap": "^1.0.1",
|
"formsnap": "^1.0.1",
|
||||||
"hono-rate-limiter": "^0.3.0",
|
"hono-rate-limiter": "^0.4.0",
|
||||||
"mode-watcher": "^0.3.1",
|
"mode-watcher": "^0.4.1",
|
||||||
"paneforge": "^0.0.5",
|
"paneforge": "^0.0.5",
|
||||||
"rate-limit-redis": "^4.2.0",
|
"rate-limit-redis": "^4.2.0",
|
||||||
"resend": "^3.3.0",
|
"resend": "^3.5.0",
|
||||||
"svelte-sonner": "^0.3.24",
|
"svelte-sonner": "^0.3.27",
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwind-variants": "^0.2.1",
|
"tailwind-variants": "^0.2.1",
|
||||||
"vaul-svelte": "^0.3.1"
|
"vaul-svelte": "^0.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3941
pnpm-lock.yaml
3941
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
||||||
import * as envs from '$env/static/private';
|
|
||||||
|
|
||||||
export const config = { ...envs, isProduction: process.env.NODE_ENV === 'production' };
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { BlankSchema } from 'hono/types';
|
||||||
|
import type { HonoTypes } from '../types/hono.type';
|
||||||
|
|
||||||
|
export interface Controller {
|
||||||
|
// controller: Hono<HonoTypes, BlankSchema, '/'>;
|
||||||
|
routes(): Hono<HonoTypes, BlankSchema, '/'>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { DatabaseProvider } from "../../providers/database.provider";
|
||||||
|
|
||||||
|
export interface Repository {
|
||||||
|
trxHost(trx: DatabaseProvider): any;
|
||||||
|
}
|
||||||
14
src/lib/server/api/common/utils/repository.utils.ts
Normal file
14
src/lib/server/api/common/utils/repository.utils.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
||||||
|
export const takeFirst = <T>(values: T[]): T | null => {
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
return values[0]!;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const takeFirstOrThrow = <T>(values: T[]): T => {
|
||||||
|
if (values.length === 0)
|
||||||
|
throw new HTTPException(404, {
|
||||||
|
message: 'Resource not found'
|
||||||
|
});
|
||||||
|
return values[0]!;
|
||||||
|
};
|
||||||
29
src/lib/server/api/common/utils/table.utils.ts
Normal file
29
src/lib/server/api/common/utils/table.utils.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import { customType } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const citext = customType<{ data: string }>({
|
||||||
|
dataType() {
|
||||||
|
return 'citext';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cuid2 = customType<{ data: string }>({
|
||||||
|
dataType() {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const timestamps = {
|
||||||
|
createdAt: timestamp('created_at', {
|
||||||
|
mode: 'date',
|
||||||
|
withTimezone: true
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', {
|
||||||
|
mode: 'date',
|
||||||
|
withTimezone: true
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
};
|
||||||
3
src/lib/server/api/configs/envs.config.ts
Normal file
3
src/lib/server/api/configs/envs.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import * as envs from '$env/static/private';
|
||||||
|
|
||||||
|
export const env = { ...envs, isProduction: process.env.NODE_ENV === 'production' };
|
||||||
|
|
@ -1,58 +1,40 @@
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { setCookie } from 'hono/cookie';
|
import { setCookie } from 'hono/cookie';
|
||||||
import type { HonoTypes } from '../types';
|
|
||||||
import { inject, injectable } from 'tsyringe';
|
import { inject, injectable } from 'tsyringe';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { IamService } from '../services/iam.service';
|
import { IamService } from '../services/iam.service';
|
||||||
import { LuciaProvider } from '../providers/lucia.provider';
|
import { LuciaProvider } from '../providers/lucia.provider';
|
||||||
import { requireAuth } from '../middleware/auth.middleware';
|
|
||||||
import { limiter } from '../middleware/rate-limiter.middlware';
|
|
||||||
import { signInEmailDto } from '../../../dtos/signin-email.dto';
|
import { signInEmailDto } from '../../../dtos/signin-email.dto';
|
||||||
import { updateEmailDto } from '../../../dtos/update-email.dto';
|
import { updateEmailDto } from '../../../dtos/update-email.dto';
|
||||||
import { verifyEmailDto } from '../../../dtos/verify-email.dto';
|
import { verifyEmailDto } from '../../../dtos/verify-email.dto';
|
||||||
import { registerEmailDto } from '../../../dtos/register-email.dto';
|
import { registerEmailDto } from '../../../dtos/register-email.dto';
|
||||||
import type { Controller } from '../interfaces/controller.interface';
|
|
||||||
import { EmailVerificationsService } from '../services/email-verifications.service';
|
import { EmailVerificationsService } from '../services/email-verifications.service';
|
||||||
import { LoginRequestsService } from '../services/login-requests.service';
|
import { LoginRequestsService } from '../services/login-requests.service';
|
||||||
|
import type { HonoTypes } from '../common/types/hono.type';
|
||||||
/* -------------------------------------------------------------------------- */
|
import type { Controller } from '../common/inferfaces/controller.interface';
|
||||||
/* Controller */
|
import { limiter } from '../middlewares/rate-limiter.middlware';
|
||||||
/* -------------------------------------------------------------------------- */
|
import { requireAuth } from '../middlewares/auth.middleware';
|
||||||
/* -------------------------------------------------------------------------- */
|
import TestJob from '../jobs/test.job';
|
||||||
/* ---------------------------------- About --------------------------------- */
|
|
||||||
/*
|
|
||||||
Controllers are responsible for handling incoming requests and returning responses
|
|
||||||
to a client.
|
|
||||||
*/
|
|
||||||
/* ---------------------------------- Notes --------------------------------- */
|
|
||||||
/*
|
|
||||||
A controller should generally only handle routing and authorization through
|
|
||||||
middleware.
|
|
||||||
|
|
||||||
Any business logic should be delegated to a service. This keeps the controller
|
|
||||||
clean and easy to read.
|
|
||||||
*/
|
|
||||||
/* -------------------------------- Important ------------------------------- */
|
|
||||||
/*
|
|
||||||
Remember to register your controller in the api/index.ts file.
|
|
||||||
*/
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class IamController implements Controller {
|
export class IamController implements Controller {
|
||||||
controller = new Hono<HonoTypes>();
|
private controller = new Hono<HonoTypes>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@inject(IamService) private iamService: IamService,
|
@inject(IamService) private iamService: IamService,
|
||||||
@inject(LoginRequestsService) private loginRequestsService: LoginRequestsService,
|
@inject(LoginRequestsService) private loginRequestsService: LoginRequestsService,
|
||||||
@inject(EmailVerificationsService) private emailVerificationsService: EmailVerificationsService,
|
@inject(EmailVerificationsService) private emailVerificationsService: EmailVerificationsService,
|
||||||
@inject(LuciaProvider) private lucia: LuciaProvider
|
@inject(LuciaProvider) private lucia: LuciaProvider,
|
||||||
|
@inject(TestJob) private testJob: TestJob
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
routes() {
|
routes() {
|
||||||
return this.controller
|
return this.controller
|
||||||
.get('/user', async (c) => {
|
.get('/user', async (c) => {
|
||||||
const user = c.var.user;
|
const user = c.var.user;
|
||||||
|
console.log('uwu')
|
||||||
|
this.testJob.queue('green');
|
||||||
|
// this.testJob.worker();
|
||||||
return c.json({ user: user });
|
return c.json({ user: user });
|
||||||
})
|
})
|
||||||
.post('/login/request', zValidator('json', registerEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
.post('/login/request', zValidator('json', registerEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
||||||
|
|
@ -95,7 +77,7 @@ export class IamController implements Controller {
|
||||||
await this.emailVerificationsService.dispatchEmailVerificationRequest(c.var.user.id, json.email);
|
await this.emailVerificationsService.dispatchEmailVerificationRequest(c.var.user.id, json.email);
|
||||||
return c.json({ message: 'Verification email sent' });
|
return c.json({ message: 'Verification email sent' });
|
||||||
})
|
})
|
||||||
// this could also be named to use custom methods, aka /email:verify
|
// this could also be named to use custom methods, aka /email#verify
|
||||||
// https://cloud.google.com/apis/design/custom_methods
|
// https://cloud.google.com/apis/design/custom_methods
|
||||||
.post('/email/verification', requireAuth, zValidator('json', verifyEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
.post('/email/verification', requireAuth, zValidator('json', verifyEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
||||||
const json = c.req.valid('json');
|
const json = c.req.valid('json');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS "email_verifications" (
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT "email_verifications_user_id_unique" UNIQUE("user_id")
|
CONSTRAINT "email_verifications_user_id_unique" UNIQUE("user_id")
|
||||||
);
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE TABLE IF NOT EXISTS "login_requests" (
|
CREATE TABLE IF NOT EXISTS "login_requests" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"hashed_token" text NOT NULL,
|
"hashed_token" text NOT NULL,
|
||||||
|
|
@ -20,13 +20,13 @@ CREATE TABLE IF NOT EXISTS "login_requests" (
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT "login_requests_email_unique" UNIQUE("email")
|
CONSTRAINT "login_requests_email_unique" UNIQUE("email")
|
||||||
);
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"user_id" text NOT NULL,
|
"user_id" text NOT NULL,
|
||||||
"expires_at" timestamp with time zone NOT NULL
|
"expires_at" timestamp with time zone NOT NULL
|
||||||
);
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE TABLE IF NOT EXISTS "users" (
|
CREATE TABLE IF NOT EXISTS "users" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"avatar" text,
|
"avatar" text,
|
||||||
|
|
@ -36,13 +36,13 @@ CREATE TABLE IF NOT EXISTS "users" (
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
);
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
WHEN duplicate_object THEN null;
|
WHEN duplicate_object THEN null;
|
||||||
END $$;
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "2fdb0575-b4b3-4ebb-9ca0-73a655a7fbe7",
|
"id": "7c8066fe-53c3-4fbb-b700-34ae02d25480",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "6",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
"public.email_verifications": {
|
"public.email_verifications": {
|
||||||
|
|
@ -238,6 +238,7 @@
|
||||||
},
|
},
|
||||||
"enums": {},
|
"enums": {},
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"columns": {},
|
"columns": {},
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
13
src/lib/server/api/databases/migrations/meta/_journal.json
Normal file
13
src/lib/server/api/databases/migrations/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1722983304054,
|
||||||
|
"tag": "0000_clear_paper_doll",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
import { timestamps } from '../utils';
|
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
import { usersTable } from './users.table';
|
import { usersTable } from './users.table';
|
||||||
|
import { timestamps } from '../../common/utils/table.utils';
|
||||||
|
|
||||||
export const emailVerificationsTable = pgTable('email_verifications', {
|
export const emailVerificationsTable = pgTable('email_verifications', {
|
||||||
id: text('id')
|
id: text('id')
|
||||||
12
src/lib/server/api/databases/tables/files.table.ts
Normal file
12
src/lib/server/api/databases/tables/files.table.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { bigint, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { cuid2, timestamps } from "../../common/utils/table.utils";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
|
export const filesTable = pgTable('files', {
|
||||||
|
id: cuid2('id').primaryKey().$defaultFn(() => createId()),
|
||||||
|
key: text('key').notNull(),
|
||||||
|
size: bigint('size', { mode: 'bigint' }).notNull(),
|
||||||
|
contentType: text('content_type').notNull(),
|
||||||
|
...timestamps
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { timestamps } from '../utils';
|
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import { timestamps } from '../../common/utils/table.utils';
|
||||||
|
|
||||||
export const loginRequestsTable = pgTable('login_requests', {
|
export const loginRequestsTable = pgTable('login_requests', {
|
||||||
id: text('id')
|
id: text('id')
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { cuid2 } from '../utils';
|
import { cuid2 } from '../../common/utils/table.utils';
|
||||||
import { usersTable } from './users.table';
|
import { usersTable } from './users.table';
|
||||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';;
|
||||||
import { citext, timestamps } from '../utils';
|
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { sessionsTable } from './sessions.table';
|
import { sessionsTable } from './sessions.table';
|
||||||
import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
|
import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
|
||||||
import { emailVerificationsTable } from './email-verifications.table';
|
import { emailVerificationsTable } from './email-verifications.table';
|
||||||
|
import { citext, cuid2, timestamps } from '../../common/utils/table.utils';
|
||||||
|
import { filesTable } from './files.table';
|
||||||
|
|
||||||
|
|
||||||
export const usersTable = pgTable('users', {
|
export const usersTable = pgTable('users', {
|
||||||
id: text('id')
|
id: text('id')
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => createId()),
|
.$defaultFn(() => createId()),
|
||||||
avatar: text('avatar'),
|
avatarId: cuid2('avatar').references(() => filesTable.id),
|
||||||
email: citext('email').notNull().unique(),
|
email: citext('email').notNull().unique(),
|
||||||
verified: boolean('verified').notNull().default(false),
|
verified: boolean('verified').notNull().default(false),
|
||||||
...timestamps
|
...timestamps
|
||||||
|
|
@ -17,6 +19,10 @@ export const usersTable = pgTable('users', {
|
||||||
|
|
||||||
export const usersRelations = relations(usersTable, ({ many, one }) => ({
|
export const usersRelations = relations(usersTable, ({ many, one }) => ({
|
||||||
sessions: many(sessionsTable),
|
sessions: many(sessionsTable),
|
||||||
|
avatar: one(filesTable, {
|
||||||
|
fields: [usersTable.avatarId],
|
||||||
|
references: [filesTable.id]
|
||||||
|
}),
|
||||||
emailVerifications: one(emailVerificationsTable, {
|
emailVerifications: one(emailVerificationsTable, {
|
||||||
fields: [usersTable.id],
|
fields: [usersTable.id],
|
||||||
references: [emailVerificationsTable.userId]
|
references: [emailVerificationsTable.userId]
|
||||||
7
src/lib/server/api/dtos/register-email.dto.ts
Normal file
7
src/lib/server/api/dtos/register-email.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const registerEmailDto = z.object({
|
||||||
|
email: z.string().email()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RegisterEmailDto = z.infer<typeof registerEmailDto>;
|
||||||
8
src/lib/server/api/dtos/signin-email.dto.ts
Normal file
8
src/lib/server/api/dtos/signin-email.dto.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const signInEmailDto = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
token: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SignInEmailDto = z.infer<typeof signInEmailDto>;
|
||||||
6
src/lib/server/api/dtos/update-email.dto.ts
Normal file
6
src/lib/server/api/dtos/update-email.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const updateEmailDto = z.object({
|
||||||
|
email: z.string().email()
|
||||||
|
});
|
||||||
|
export type UpdateEmailDto = z.infer<typeof updateEmailDto>;
|
||||||
6
src/lib/server/api/dtos/verify-email.dto.ts
Normal file
6
src/lib/server/api/dtos/verify-email.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const verifyEmailDto = z.object({
|
||||||
|
token: z.string()
|
||||||
|
});
|
||||||
|
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Email } from "../interfaces/email.interface";
|
import type { Email } from "../common/inferfaces/email.interface"
|
||||||
|
|
||||||
export class EmailChangeNoticeEmail implements Email {
|
export class EmailChangeNoticeEmail implements Email {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Email } from "../interfaces/email.interface";
|
import type { Email } from "../common/inferfaces/email.interface"
|
||||||
|
|
||||||
export class LoginVerificationEmail implements Email {
|
export class LoginVerificationEmail implements Email {
|
||||||
constructor(private readonly token: string) { }
|
constructor(private readonly token: string) { }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Email } from '../interfaces/email.interface';
|
import type { Email } from "../common/inferfaces/email.interface";
|
||||||
|
|
||||||
export class WelcomeEmail implements Email {
|
export class WelcomeEmail implements Email {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,15 @@
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import './providers';
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { hc } from 'hono/client';
|
import { hc } from 'hono/client';
|
||||||
import { container } from 'tsyringe';
|
import { container } from 'tsyringe';
|
||||||
import { validateAuthSession, verifyOrigin } from './middleware/auth.middleware';
|
|
||||||
import { IamController } from './controllers/iam.controller';
|
import { IamController } from './controllers/iam.controller';
|
||||||
import { config } from './common/config';
|
import { env } from './configs/envs.config';
|
||||||
|
import { validateAuthSession, verifyOrigin } from './middlewares/auth.middleware';
|
||||||
/* -------------------------------------------------------------------------- */
|
// import { TestJob } from './jobs/test.job';
|
||||||
/* Client Request */
|
import { glob, globSync } from 'glob';
|
||||||
/* ------------------------------------ ▲ ----------------------------------- */
|
import path from 'path';
|
||||||
/* ------------------------------------ | ----------------------------------- */
|
|
||||||
/* ------------------------------------ ▼ ----------------------------------- */
|
|
||||||
/* Controller */
|
|
||||||
/* ---------------------------- (Request Routing) --------------------------- */
|
|
||||||
/* ------------------------------------ ▲ ----------------------------------- */
|
|
||||||
/* ------------------------------------ | ----------------------------------- */
|
|
||||||
/* ------------------------------------ ▼ ----------------------------------- */
|
|
||||||
/* Service */
|
|
||||||
/* ---------------------------- (Business logic) ---------------------------- */
|
|
||||||
/* ------------------------------------ ▲ ----------------------------------- */
|
|
||||||
/* ------------------------------------ | ----------------------------------- */
|
|
||||||
/* ------------------------------------ ▼ ----------------------------------- */
|
|
||||||
/* Repository */
|
|
||||||
/* ----------------------------- (Data storage) ----------------------------- */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
|
console.log('API SERVER STARTED');
|
||||||
/* ----------------------------------- Api ---------------------------------- */
|
/* ----------------------------------- Api ---------------------------------- */
|
||||||
const app = new Hono().basePath('/api');
|
const app = new Hono().basePath('/api');
|
||||||
|
|
||||||
|
|
@ -39,7 +23,18 @@ const routes = app
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Exports */
|
/* Exports */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
export const rpc = hc<typeof routes>(config.ORIGIN);
|
export const rpc = hc<typeof routes>(env.ORIGIN);
|
||||||
export type ApiClient = typeof rpc;
|
export type ApiClient = typeof rpc;
|
||||||
export type ApiRoutes = typeof routes;
|
export type ApiRoutes = typeof routes;
|
||||||
export { app };
|
export { app };
|
||||||
|
|
||||||
|
async function resolveJobs() {
|
||||||
|
const jobFiles = globSync('**/*.job.*');
|
||||||
|
|
||||||
|
for (const file of jobFiles) {
|
||||||
|
const module = await import(path.resolve(file));
|
||||||
|
container.resolve(module.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveJobs();
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { Lucia } from 'lucia';
|
|
||||||
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
|
|
||||||
import { Discord } from 'arctic';
|
|
||||||
import { sessionsTable, usersTable } from '../database/tables';
|
|
||||||
import { db } from '../database';
|
|
||||||
import { config } from '../../common/config';
|
|
||||||
|
|
||||||
const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable);
|
|
||||||
|
|
||||||
export const lucia = new Lucia(adapter, {
|
|
||||||
sessionCookie: {
|
|
||||||
attributes: {
|
|
||||||
// set to `true` when using HTTPS
|
|
||||||
secure: config.isProduction
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getUserAttributes: (attributes) => {
|
|
||||||
return {
|
|
||||||
// attributes has the type of DatabaseUserAttributes
|
|
||||||
...attributes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const discord = new Discord(
|
|
||||||
config.DISCORD_CLIENT_ID!,
|
|
||||||
config.DISCORD_CLIENT_SECRET!,
|
|
||||||
`${config.ORIGIN}/api/iam/discord/callback`
|
|
||||||
);
|
|
||||||
|
|
||||||
interface DatabaseUserAttributes {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string | null;
|
|
||||||
verified: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'lucia' {
|
|
||||||
interface Register {
|
|
||||||
Lucia: typeof lucia;
|
|
||||||
DatabaseUserAttributes: DatabaseUserAttributes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
import { config } from '../../common/config';
|
|
||||||
import * as schema from './tables';
|
|
||||||
|
|
||||||
export const client = postgres(config.DATABASE_URL!, { max: 1 });
|
|
||||||
export const db = drizzle(client, { schema });
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"version": "6",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1719512747861,
|
|
||||||
"tag": "0000_nostalgic_skrulls",
|
|
||||||
"breakpoints": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { HTTPException } from 'hono/http-exception';
|
|
||||||
import { timestamp } from 'drizzle-orm/pg-core';
|
|
||||||
import { customType } from 'drizzle-orm/pg-core';
|
|
||||||
|
|
||||||
export const citext = customType<{ data: string }>({
|
|
||||||
dataType() {
|
|
||||||
return 'citext';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const cuid2 = customType<{ data: string }>({
|
|
||||||
dataType() {
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const takeFirst = <T>(values: T[]): T | null => {
|
|
||||||
if (values.length === 0) return null;
|
|
||||||
return values[0]!;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const takeFirstOrThrow = <T>(values: T[]): T => {
|
|
||||||
if (values.length === 0)
|
|
||||||
throw new HTTPException(404, {
|
|
||||||
message: 'Resource not found'
|
|
||||||
});
|
|
||||||
return values[0]!;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const timestamps = {
|
|
||||||
createdAt: timestamp('created_at', {
|
|
||||||
mode: 'date',
|
|
||||||
withTimezone: true
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp('updated_at', {
|
|
||||||
mode: 'date',
|
|
||||||
withTimezone: true
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.defaultNow()
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<html lang='en'>
|
|
||||||
<head>
|
|
||||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
|
||||||
<title>Email Change Request</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p class='title'>Email address change notice </p>
|
|
||||||
<p>
|
|
||||||
An update to your email address has been requested. If this is unexpected or you did not perform this action, please login and secure your account.</p>
|
|
||||||
</body>
|
|
||||||
<style>
|
|
||||||
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700; margin-top: 8px; }
|
|
||||||
.token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
|
|
||||||
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
|
|
||||||
.token-subtext { font-size: 12px; margin-top: 0px; }
|
|
||||||
</style>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<html lang='en'>
|
|
||||||
<head>
|
|
||||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
|
||||||
<title>Message</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p class='title'>Verify your email address</p>
|
|
||||||
<p>
|
|
||||||
Thanks for using example.com. We want to make sure it's really you. Please enter the following
|
|
||||||
verification code when prompted. If you don't have an exmaple.com an account, you can ignore
|
|
||||||
this message.</p>
|
|
||||||
<div class='center'>
|
|
||||||
<p class="token-title">Verification Code</p>
|
|
||||||
<p class='token-text'>{{token}}</p>
|
|
||||||
<p class='token-subtext'>(This code is valid for 15 minutes)</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
<style>
|
|
||||||
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700; margin-top: 8px; }
|
|
||||||
.token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
|
|
||||||
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
|
|
||||||
.token-subtext { font-size: 12px; margin-top: 0px; }
|
|
||||||
</style>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<html lang='en'>
|
|
||||||
<head>
|
|
||||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
|
||||||
<title>Message</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p class='title'>Welcome to Example</p>
|
|
||||||
<p>
|
|
||||||
Thanks for using example.com. We want to make sure it's really you. Please enter the following
|
|
||||||
verification code when prompted. If you don't have an exmaple.com an account, you can ignore
|
|
||||||
this message.</p>
|
|
||||||
</body>
|
|
||||||
<style>
|
|
||||||
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700; margin-top: 8px; }
|
|
||||||
.token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
|
|
||||||
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
|
|
||||||
.token-subtext { font-size: 12px; margin-top: 0px; }
|
|
||||||
</style>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { Hono } from 'hono';
|
|
||||||
import type { HonoTypes } from '../types';
|
|
||||||
import type { BlankSchema } from 'hono/types';
|
|
||||||
|
|
||||||
export interface Controller {
|
|
||||||
controller: Hono<HonoTypes, BlankSchema, '/'>;
|
|
||||||
routes(): any;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import type { DatabaseProvider } from '../providers';
|
|
||||||
|
|
||||||
export interface Repository {
|
|
||||||
trxHost(trx: DatabaseProvider): any;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { MiddlewareHandler } from 'hono';
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import { createMiddleware } from 'hono/factory';
|
import { createMiddleware } from 'hono/factory';
|
||||||
import type { HonoTypes } from '../types';
|
|
||||||
import { lucia } from '../infrastructure/auth/lucia';
|
|
||||||
import { verifyRequestOrigin } from 'lucia';
|
import { verifyRequestOrigin } from 'lucia';
|
||||||
import type { Session, User } from 'lucia';
|
import type { Session, User } from 'lucia';
|
||||||
import { Unauthorized } from '../common/errors';
|
import { Unauthorized } from '../common/exceptions';
|
||||||
|
import type { HonoTypes } from '../common/types/hono.type';
|
||||||
|
import { lucia } from '../packages/lucia';
|
||||||
|
|
||||||
export const verifyOrigin: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => {
|
export const verifyOrigin: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => {
|
||||||
if (c.req.method === "GET") {
|
if (c.req.method === "GET") {
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { rateLimiter } from "hono-rate-limiter";
|
import { rateLimiter } from "hono-rate-limiter";
|
||||||
import { RedisStore } from 'rate-limit-redis'
|
import { RedisStore } from 'rate-limit-redis'
|
||||||
import RedisClient from 'ioredis'
|
import RedisClient from 'ioredis'
|
||||||
import type { HonoTypes } from "../types";
|
import { env } from "../configs/envs.config";
|
||||||
import { config } from "../common/config";
|
import type { HonoTypes } from "../common/types/hono.type";
|
||||||
|
|
||||||
const client = new RedisClient(config.REDIS_URL)
|
const client = new RedisClient(env.REDIS_URL)
|
||||||
|
|
||||||
export function limiter({ limit, minutes, key = "" }: {
|
export function limiter({ limit, minutes, key = "" }: {
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { Scrypt } from "oslo/password";
|
|
||||||
|
|
||||||
|
|
||||||
export async function hash(value: string) {
|
|
||||||
const scrypt = new Scrypt()
|
|
||||||
return scrypt.hash(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verify(hashedValue: string, value: string) {
|
|
||||||
return new Scrypt().verify(hashedValue, value);
|
|
||||||
}
|
|
||||||
7
src/lib/server/api/packages/drizzle.ts
Normal file
7
src/lib/server/api/packages/drizzle.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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 });
|
||||||
23
src/lib/server/api/packages/lucia.ts
Normal file
23
src/lib/server/api/packages/lucia.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { container } from 'tsyringe';
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
12
src/lib/server/api/packages/s3.ts
Normal file
12
src/lib/server/api/packages/s3.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { container } from 'tsyringe';
|
import { container } from 'tsyringe';
|
||||||
import { db } from '../infrastructure/database';
|
import { db } from '../packages/drizzle';
|
||||||
|
|
||||||
// Symbol
|
|
||||||
export const DatabaseProvider = Symbol('DATABASE_TOKEN');
|
export const DatabaseProvider = Symbol('DATABASE_TOKEN');
|
||||||
|
|
||||||
// Type
|
|
||||||
export type DatabaseProvider = typeof db;
|
export type DatabaseProvider = typeof db;
|
||||||
|
|
||||||
// Register
|
|
||||||
container.register<DatabaseProvider>(DatabaseProvider, { useValue: db });
|
container.register<DatabaseProvider>(DatabaseProvider, { useValue: db });
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './database.provider';
|
|
||||||
export * from './lucia.provider';
|
|
||||||
export * from './redis.provider';
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { container } from 'tsyringe';
|
import { container } from 'tsyringe';
|
||||||
import { lucia } from '../infrastructure/auth/lucia';
|
import { lucia } from '../packages/lucia';
|
||||||
|
|
||||||
// Symbol
|
|
||||||
export const LuciaProvider = Symbol('LUCIA_PROVIDER');
|
export const LuciaProvider = Symbol('LUCIA_PROVIDER');
|
||||||
|
|
||||||
// Type
|
|
||||||
export type LuciaProvider = typeof lucia;
|
export type LuciaProvider = typeof lucia;
|
||||||
|
|
||||||
// Register
|
|
||||||
container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });
|
container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import { container } from 'tsyringe';
|
import { container } from 'tsyringe';
|
||||||
|
import { env } from '../configs/envs.config';
|
||||||
import RedisClient from 'ioredis'
|
import RedisClient from 'ioredis'
|
||||||
import { config } from '../common/config';
|
|
||||||
|
|
||||||
// Symbol
|
|
||||||
export const RedisProvider = Symbol('REDIS_TOKEN');
|
export const RedisProvider = Symbol('REDIS_TOKEN');
|
||||||
|
|
||||||
// Type
|
|
||||||
export type RedisProvider = RedisClient;
|
export type RedisProvider = RedisClient;
|
||||||
|
|
||||||
// Register
|
|
||||||
container.register<RedisProvider>(RedisProvider, {
|
container.register<RedisProvider>(RedisProvider, {
|
||||||
useValue: new RedisClient(config.REDIS_URL)
|
useValue: new RedisClient(env.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: null
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
|
||||||
9
src/lib/server/api/providers/s3.provider.ts
Normal file
9
src/lib/server/api/providers/s3.provider.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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
|
||||||
|
});
|
||||||
0
src/lib/server/api/queues.ts
Normal file
0
src/lib/server/api/queues.ts
Normal file
|
|
@ -1,9 +1,10 @@
|
||||||
import { inject, injectable } from "tsyringe";
|
import { inject, injectable } from "tsyringe";
|
||||||
import { DatabaseProvider } from "../providers";
|
|
||||||
import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm";
|
import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm";
|
||||||
import type { Repository } from "../interfaces/repository.interface";
|
import { emailVerificationsTable } from "../databases/tables";
|
||||||
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils";
|
import type { Repository } from "../common/inferfaces/repository.interface";
|
||||||
import { emailVerificationsTable } from "../infrastructure/database/tables/email-verifications.table";
|
import { DatabaseProvider } from "../providers/database.provider";
|
||||||
|
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
|
||||||
|
|
||||||
|
|
||||||
export type CreateEmailVerification = Pick<InferInsertModel<typeof emailVerificationsTable>, 'requestedEmail' | 'hashedToken' | 'userId' | 'expiresAt'>;
|
export type CreateEmailVerification = Pick<InferInsertModel<typeof emailVerificationsTable>, 'requestedEmail' | 'hashedToken' | 'userId' | 'expiresAt'>;
|
||||||
|
|
||||||
|
|
|
||||||
41
src/lib/server/api/repositories/files.repository.ts
Normal file
41
src/lib/server/api/repositories/files.repository.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
export class FilesRepository {
|
||||||
|
constructor(
|
||||||
|
@inject(StorageService) private readonly storageService: StorageService,
|
||||||
|
@inject(DatabaseProvider) private readonly db: DatabaseProvider) { }
|
||||||
|
|
||||||
|
async create(file: File, db = this.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) {
|
||||||
|
return db.select().from(filesTable).where(eq(filesTable.id, id)).then(takeFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneByIdOrThrow(id: string, db = this.db) {
|
||||||
|
return db.select().from(filesTable).where(eq(filesTable.id, id)).then(takeFirstOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, file: File, db = this.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))
|
||||||
|
|
||||||
|
// remove old file
|
||||||
|
const oldAsset = await this.findOneByIdOrThrow(id)
|
||||||
|
await this.storageService.delete(oldAsset.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, db = this.db) {
|
||||||
|
const asset = await this.findOneByIdOrThrow(id)
|
||||||
|
await this.storageService.delete(asset.key)
|
||||||
|
await db.delete(filesTable).where(eq(filesTable.id, id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { inject, injectable } from "tsyringe";
|
import { inject, injectable } from "tsyringe";
|
||||||
import { DatabaseProvider } from "../providers";
|
|
||||||
import type { Repository } from "../interfaces/repository.interface";
|
|
||||||
import { and, eq, gte, type InferInsertModel } from "drizzle-orm";
|
import { and, eq, gte, type InferInsertModel } from "drizzle-orm";
|
||||||
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils";
|
import { loginRequestsTable } from "../databases/tables";
|
||||||
import { loginRequestsTable } from "../infrastructure/database/tables/login-requests.table";
|
import type { Repository } from "../common/inferfaces/repository.interface";
|
||||||
|
import { DatabaseProvider } from "../providers/database.provider";
|
||||||
|
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
|
||||||
|
|
||||||
|
|
||||||
export type CreateLoginRequest = Pick<InferInsertModel<typeof loginRequestsTable>, 'email' | 'expiresAt' | 'hashedToken'>;
|
export type CreateLoginRequest = Pick<InferInsertModel<typeof loginRequestsTable>, 'email' | 'expiresAt' | 'hashedToken'>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,9 @@
|
||||||
import { inject, injectable } from 'tsyringe';
|
import { inject, injectable } from 'tsyringe';
|
||||||
import type { Repository } from '../interfaces/repository.interface';
|
import { usersTable } from '../databases/tables';
|
||||||
import { DatabaseProvider } from '../providers';
|
|
||||||
import { eq, type InferInsertModel } from 'drizzle-orm';
|
import { eq, type InferInsertModel } from 'drizzle-orm';
|
||||||
import { usersTable } from '../infrastructure/database/tables/users.table';
|
import { DatabaseProvider } from '../providers/database.provider';
|
||||||
import { takeFirstOrThrow } from '../infrastructure/database/utils';
|
import { takeFirstOrThrow } from '../common/utils/repository.utils';
|
||||||
|
import type { Repository } from '../common/inferfaces/repository.interface';
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 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 CreateUser = InferInsertModel<typeof usersTable>;
|
||||||
export type UpdateUser = Partial<CreateUser>;
|
export type UpdateUser = Partial<CreateUser>;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { inject, injectable } from 'tsyringe';
|
import { inject, injectable } from 'tsyringe';
|
||||||
import { BadRequest } from '../common/errors';
|
|
||||||
import { DatabaseProvider } from '../providers';
|
|
||||||
import { MailerService } from './mailer.service';
|
import { MailerService } from './mailer.service';
|
||||||
import { TokensService } from './tokens.service';
|
import { TokensService } from './tokens.service';
|
||||||
import { UsersRepository } from '../repositories/users.repository';
|
import { UsersRepository } from '../repositories/users.repository';
|
||||||
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
|
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
|
||||||
|
import { DatabaseProvider } from '../providers/database.provider';
|
||||||
|
import { EmailChangeNoticeEmail } from '../emails/email-change-notice.email';
|
||||||
|
import { LoginVerificationEmail } from '../emails/login-verification.email';
|
||||||
|
import { BadRequest } from '../common/exceptions';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class EmailVerificationsService {
|
export class EmailVerificationsService {
|
||||||
|
|
@ -28,18 +30,16 @@ export class EmailVerificationsService {
|
||||||
|
|
||||||
// A confirmation-required email message to the proposed new address, instructing the user to
|
// A confirmation-required email message to the proposed new address, instructing the user to
|
||||||
// confirm the change and providing a link for unexpected situations
|
// confirm the change and providing a link for unexpected situations
|
||||||
this.mailerService.sendEmailVerificationToken({
|
this.mailerService.send({
|
||||||
to: requestedEmail,
|
to: requestedEmail,
|
||||||
props: {
|
email: new LoginVerificationEmail(token)
|
||||||
token
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// A notification-only email message to the current address, alerting the user to the impending change and
|
// A notification-only email message to the current address, alerting the user to the impending change and
|
||||||
// providing a link for an unexpected situation.
|
// providing a link for an unexpected situation.
|
||||||
this.mailerService.sendEmailChangeNotification({
|
this.mailerService.send({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
props: null
|
email: new EmailChangeNoticeEmail()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,45 @@
|
||||||
|
import { scrypt } from "node:crypto";
|
||||||
|
import { decodeHex, encodeHex } from "oslo/encoding";
|
||||||
|
import { constantTimeEqual } from "oslo/crypto";
|
||||||
import { injectable } from "tsyringe";
|
import { injectable } from "tsyringe";
|
||||||
import { Scrypt } 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.
|
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class HashingService {
|
export class HashingService {
|
||||||
private readonly hasher = new Scrypt();
|
N;
|
||||||
// private readonly hasher = new Argon2id(); // argon2id hasher
|
r;
|
||||||
|
p;
|
||||||
|
dkLen;
|
||||||
|
|
||||||
async hash(data: string) {
|
constructor() {
|
||||||
return this.hasher.hash(data);
|
this.N = 16384;
|
||||||
|
this.r = 16;
|
||||||
|
this.p = 1;
|
||||||
|
this.dkLen = 64;
|
||||||
|
}
|
||||||
|
async hash(password: string) {
|
||||||
|
const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16)));
|
||||||
|
const key = await this.generateKey(password, salt);
|
||||||
|
return `${salt}:${encodeHex(key)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(hash: string, data: string) {
|
async verify(hash: string, password: string) {
|
||||||
return this.hasher.verify(hash, data)
|
const [salt, key] = hash.split(":");
|
||||||
|
const targetKey = await this.generateKey(password, salt);
|
||||||
|
return constantTimeEqual(targetKey, decodeHex(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateKey(password: string, salt: string): Promise<Buffer> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
scrypt(password.normalize("NFKC"), salt, this.dkLen, {
|
||||||
|
N: this.N,
|
||||||
|
p: this.p,
|
||||||
|
r: this.r,
|
||||||
|
maxmem: 128 * this.N * this.r * 2
|
||||||
|
}, (err, buff) => {
|
||||||
|
if (err)
|
||||||
|
return reject(err);
|
||||||
|
return resolve(buff);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,30 +1,76 @@
|
||||||
import { inject, injectable } from 'tsyringe';
|
import { inject, injectable } from 'tsyringe';
|
||||||
|
import { MailerService } from './mailer.service';
|
||||||
|
import { TokensService } from './tokens.service';
|
||||||
import { LuciaProvider } from '../providers/lucia.provider';
|
import { LuciaProvider } from '../providers/lucia.provider';
|
||||||
|
import { UsersRepository } from '../repositories/users.repository';
|
||||||
/* -------------------------------------------------------------------------- */
|
import type { SignInEmailDto } from '../dtos/signin-email.dto';
|
||||||
/* Service */
|
import type { RegisterEmailDto } from '../dtos/register-email.dto';
|
||||||
/* -------------------------------------------------------------------------- */
|
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
|
||||||
/* -------------------------------------------------------------------------- */
|
import { LoginVerificationEmail } from '../emails/login-verification.email';
|
||||||
/* ---------------------------------- About --------------------------------- */
|
import { DatabaseProvider } from '../providers/database.provider';
|
||||||
/*
|
import { BadRequest } from '../common/exceptions';
|
||||||
Services are responsible for handling business logic and data manipulation.
|
import { WelcomeEmail } from '../emails/welcome.email';
|
||||||
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.
|
|
||||||
*/
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class IamService {
|
export class IamService {
|
||||||
constructor(
|
constructor(
|
||||||
@inject(LuciaProvider) private readonly lucia: LuciaProvider,
|
@inject(LuciaProvider) private readonly lucia: LuciaProvider,
|
||||||
|
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
|
||||||
|
@inject(TokensService) private readonly tokensService: TokensService,
|
||||||
|
@inject(MailerService) private readonly mailerService: MailerService,
|
||||||
|
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
|
||||||
|
@inject(LoginRequestsRepository) private readonly loginRequestsRepository: LoginRequestsRepository,
|
||||||
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
async createLoginRequest(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.send({ email: new LoginVerificationEmail(token), to: data.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyLoginRequest(data: SignInEmailDto) {
|
||||||
|
const validLoginRequest = await this.getValidLoginRequest(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 })
|
||||||
|
await this.mailerService.send({ email: new WelcomeEmail(), to: newUser.email });
|
||||||
|
// 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 getValidLoginRequest(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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async logout(sessionId: string) {
|
async logout(sessionId: string) {
|
||||||
return this.lucia.invalidateSession(sessionId);
|
return this.lucia.invalidateSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/lib/server/api/services/jobs.service.ts
Normal file
17
src/lib/server/api/services/jobs.service.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { inject, injectable } from "tsyringe";
|
||||||
|
import { Queue, Worker, type Processor } from 'bullmq';
|
||||||
|
import { RedisProvider } from "../providers/redis.provider";
|
||||||
|
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class JobsService {
|
||||||
|
constructor(@inject(RedisProvider) private readonly redis: RedisProvider, name: string) { }
|
||||||
|
|
||||||
|
createQueue(name: string) {
|
||||||
|
return new Queue(name, { connection: this.redis })
|
||||||
|
}
|
||||||
|
|
||||||
|
createWorker(name: string, prcoessor: Processor) {
|
||||||
|
return new Worker(name, prcoessor, { connection: this.redis })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { inject, injectable } from 'tsyringe';
|
import { inject, injectable } from 'tsyringe';
|
||||||
import { BadRequest } from '../common/errors';
|
|
||||||
import { DatabaseProvider } from '../providers';
|
|
||||||
import { MailerService } from './mailer.service';
|
import { MailerService } from './mailer.service';
|
||||||
import { TokensService } from './tokens.service';
|
import { TokensService } from './tokens.service';
|
||||||
import { LuciaProvider } from '../providers/lucia.provider';
|
import { LuciaProvider } from '../providers/lucia.provider';
|
||||||
import { UsersRepository } from '../repositories/users.repository';
|
import { UsersRepository } from '../repositories/users.repository';
|
||||||
import type { SignInEmailDto } from '../../../dtos/signin-email.dto';
|
import type { SignInEmailDto } from '../dtos/signin-email.dto';
|
||||||
import type { RegisterEmailDto } from '../../../dtos/register-email.dto';
|
import type { RegisterEmailDto } from '../dtos/register-email.dto';
|
||||||
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
|
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';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class LoginRequestsService {
|
export class LoginRequestsService {
|
||||||
|
|
@ -26,10 +28,7 @@ export class LoginRequestsService {
|
||||||
// save the login request to the database - ensuring we save the hashedToken
|
// save the login request to the database - ensuring we save the hashedToken
|
||||||
await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
|
await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
|
||||||
// send the login request email
|
// send the login request email
|
||||||
await this.mailerService.sendLoginRequest({
|
await this.mailerService.send({ email: new LoginVerificationEmail(token), to: data.email });
|
||||||
to: data.email,
|
|
||||||
props: { token: token }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(data: SignInEmailDto) {
|
async verify(data: SignInEmailDto) {
|
||||||
|
|
@ -48,8 +47,8 @@ export class LoginRequestsService {
|
||||||
|
|
||||||
// Create a new user and send a welcome email - or other onboarding process
|
// Create a new user and send a welcome email - or other onboarding process
|
||||||
private async handleNewUserRegistration(email: string) {
|
private async handleNewUserRegistration(email: string) {
|
||||||
const newUser = await this.usersRepository.create({ email, verified: true, avatar: null })
|
const newUser = await this.usersRepository.create({ email, verified: true })
|
||||||
this.mailerService.sendWelcome({ to: email, props: null });
|
await this.mailerService.send({ email: new WelcomeEmail(), to: newUser.email });
|
||||||
// TODO: add whatever onboarding process or extra data you need here
|
// TODO: add whatever onboarding process or extra data you need here
|
||||||
return newUser
|
return newUser
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import nodemailer from 'nodemailer';
|
|
||||||
import { injectable } from 'tsyringe';
|
import { injectable } from 'tsyringe';
|
||||||
import type { Email } from '../interfaces/email.interface';
|
import { env } from '../configs/envs.config';
|
||||||
import { config } from '../common/config';
|
import type { Email } from '../common/inferfaces/email.interface';
|
||||||
|
|
||||||
|
|
||||||
type SendProps = {
|
type SendProps = {
|
||||||
to: string | string[];
|
to: string | string[];
|
||||||
|
|
@ -11,34 +9,35 @@ type SendProps = {
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class MailerService {
|
export class MailerService {
|
||||||
private async sendDev({ to, email }: SendProps) {
|
|
||||||
const message = await nodemailer.createTransport({
|
async send(data: SendProps) {
|
||||||
host: 'smtp.ethereal.email',
|
const mailer = env.isProduction ? this.sendProd : this.sendDev;
|
||||||
port: 587,
|
await mailer(data);
|
||||||
secure: false, // Use `true` for port 465, `false` for all other ports
|
|
||||||
auth: {
|
|
||||||
user: 'adella.hoppe@ethereal.email',
|
|
||||||
pass: 'dshNQZYhATsdJ3ENke'
|
|
||||||
}
|
}
|
||||||
}).sendMail({
|
|
||||||
from: '"Example" <example@ethereal.email>',
|
private async sendDev({ to, email }: SendProps) {
|
||||||
bcc: to,
|
const options = {
|
||||||
subject: email.subject(),
|
method: 'POST',
|
||||||
text: email.html(),
|
headers: {
|
||||||
html: email.html()
|
'Content-Type': 'application/json'
|
||||||
});
|
},
|
||||||
console.log(nodemailer.getTestMessageUrl(message));
|
body: JSON.stringify({
|
||||||
|
Attachments: [],
|
||||||
|
From: { Email: "noreply@tofustack.com", Name: "TofuStack" },
|
||||||
|
HTML: email.html(),
|
||||||
|
Subject: email.subject(),
|
||||||
|
Text: email.html(),
|
||||||
|
To: Array.isArray(to) ? to.map(to => ({ Email: to, Name: to })) : [{ Email: to, Name: to }],
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:8025/api/v1/send', options)
|
||||||
|
const data = await response.json()
|
||||||
|
console.log(`http://localhost:8025/view/${data.ID}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendProd({ to, email }: SendProps) {
|
private async sendProd({ to, email }: SendProps) {
|
||||||
// CONFIGURE MAILER
|
// CONFIGURE MAILER
|
||||||
}
|
}
|
||||||
|
|
||||||
async send({ to, email }: SendProps) {
|
|
||||||
if (config.isProduction) {
|
|
||||||
await this.sendProd({ to, email });
|
|
||||||
} else {
|
|
||||||
await this.sendDev({ to, email });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
src/lib/server/api/services/storage.service.ts
Normal file
33
src/lib/server/api/services/storage.service.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { PutObjectCommand, DeleteObjectCommand } 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';
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class StorageService {
|
||||||
|
constructor(@inject(S3ClientProvider) private readonly s3Client: S3ClientProvider) { }
|
||||||
|
|
||||||
|
async upload(file: File) {
|
||||||
|
const key = createId();
|
||||||
|
const uploadCommand = new PutObjectCommand({
|
||||||
|
Bucket: env.STORAGE_BUCKET_NAME,
|
||||||
|
ACL: 'public-read',
|
||||||
|
Key: key,
|
||||||
|
ContentType: file.type,
|
||||||
|
Body: new Uint8Array(await file.arrayBuffer())
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.s3Client.send(uploadCommand);
|
||||||
|
return { ...response, size: file.size, name: file.name, type: file.type, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string) {
|
||||||
|
const deleteCommand = new DeleteObjectCommand({
|
||||||
|
Bucket: env.STORAGE_BUCKET_NAME,
|
||||||
|
Key: key
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.s3Client.send(deleteCommand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { TokensService } from '../services/tokens.service';
|
import { TokensService } from '../services/tokens.service';
|
||||||
import { MailerService } from '../services/mailer.service';
|
import { MailerService } from '../services/mailer.service';
|
||||||
import { UsersRepository } from '../repositories/users.repository';
|
import { UsersRepository } from '../repositories/users.repository';
|
||||||
import { DatabaseProvider, LuciaProvider } from '../providers';
|
|
||||||
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
|
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
|
||||||
import { PgDatabase } from 'drizzle-orm/pg-core';
|
import { PgDatabase } from 'drizzle-orm/pg-core';
|
||||||
import { container } from 'tsyringe';
|
import { container } from 'tsyringe';
|
||||||
|
import { LuciaProvider } from '../providers/lucia.provider';
|
||||||
|
import { DatabaseProvider } from '../providers/database.provider';
|
||||||
|
|
||||||
describe('LoginRequestService', () => {
|
describe('LoginRequestService', () => {
|
||||||
let service: LoginRequestsService;
|
let service: LoginRequestsService;
|
||||||
|
|
@ -29,7 +30,6 @@ describe('LoginRequestService', () => {
|
||||||
.resolve(LoginRequestsService);
|
.resolve(LoginRequestsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.resetAllMocks()
|
vi.resetAllMocks()
|
||||||
})
|
})
|
||||||
|
|
@ -50,9 +50,9 @@ describe('LoginRequestService', () => {
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
} satisfies Awaited<ReturnType<typeof loginRequestsRepository.create>>)
|
} satisfies Awaited<ReturnType<typeof loginRequestsRepository.create>>)
|
||||||
|
|
||||||
mailerService.sendLoginRequest = vi.fn().mockResolvedValue(null)
|
mailerService.send = vi.fn().mockResolvedValue(null)
|
||||||
|
|
||||||
const spy_mailerService_sendLoginRequest = vi.spyOn(mailerService, 'sendLoginRequest')
|
const spy_mailerService_sendLoginRequest = vi.spyOn(mailerService, 'send')
|
||||||
const spy_tokensService_generateTokenWithExpiryAndHash = vi.spyOn(tokensService, 'generateTokenWithExpiryAndHash')
|
const spy_tokensService_generateTokenWithExpiryAndHash = vi.spyOn(tokensService, 'generateTokenWithExpiryAndHash')
|
||||||
const spy_loginRequestsRepository_create = vi.spyOn(loginRequestsRepository, 'create')
|
const spy_loginRequestsRepository_create = vi.spyOn(loginRequestsRepository, 'create')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"status": "failed"
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue