mirror of
https://github.com/BradNut/TofuStack
synced 2025-09-08 17:40:26 +00:00
Merge pull request #5 from Rykuno/feature/project-restructure
Simplification/restructure
This commit is contained in:
commit
a286d60ef5
70 changed files with 3173 additions and 2064 deletions
|
|
@ -9,13 +9,48 @@ services:
|
|||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- postgres_data:/data
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- 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:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
mailpit_data:
|
||||
minio_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
minionetwork:
|
||||
driver: bridge
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
out: './src/lib/server/api/infrastructure/database/migrations',
|
||||
schema: './src/lib/server/api/infrastructure/database/tables/*.table.ts',
|
||||
out: './src/lib/server/api/databases/migrations',
|
||||
schema: './src/lib/server/api/databases/tables/*.table.ts',
|
||||
breakpoints: false,
|
||||
strict: true,
|
||||
dialect: 'postgresql',
|
||||
|
|
|
|||
93
package.json
93
package.json
|
|
@ -20,76 +20,77 @@
|
|||
"test:unit": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.624.0",
|
||||
"@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",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@playwright/test": "^1.46.0",
|
||||
"@sveltejs/adapter-auto": "^3.2.2",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.5.17",
|
||||
"@sveltejs/kit": "^2.5.20",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/eslint": "^8.56.0",
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"arctic": "^1.9.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"bullmq": "^5.8.3",
|
||||
"dayjs": "^1.11.11",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/node": "^22.1.0",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
||||
"@typescript-eslint/parser": "^8.0.1",
|
||||
"arctic": "^1.9.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bullmq": "^5.12.1",
|
||||
"chalk": "^5.3.0",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.21.4",
|
||||
"drizzle-orm": "^0.30.10",
|
||||
"eslint": "^8.56.0",
|
||||
"drizzle-kit": "^0.23.2",
|
||||
"drizzle-orm": "^0.32.2",
|
||||
"ejs": "^3.1.10",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.40.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"hono": "^4.4.7",
|
||||
"eslint-plugin-svelte": "^2.43.0",
|
||||
"glob": "^11.0.0",
|
||||
"hono": "^4.5.4",
|
||||
"ioredis": "^5.4.1",
|
||||
"lucia": "^3.2.0",
|
||||
"lucide-svelte": "^0.396.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"oslo": "^1.2.0",
|
||||
"lucide-svelte": "^0.424.0",
|
||||
"oslo": "^1.2.1",
|
||||
"pg": "^8.12.0",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss": "^8.4.41",
|
||||
"postgres": "^3.4.4",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.6.4",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"svelte": "5.0.0-next.164",
|
||||
"svelte-check": "^3.8.1",
|
||||
"svelte-eslint-parser": "^0.36.0",
|
||||
"sveltekit-superforms": "^2.15.1",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"svelte-check": "^3.8.5",
|
||||
"svelte-dnd-action": "^0.9.49",
|
||||
"svelte-eslint-parser": "^0.41.0",
|
||||
"sveltekit-search-params": "^3.0.0",
|
||||
"sveltekit-superforms": "^2.16.1",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"tslib": "^2.6.3",
|
||||
"tsx": "^4.15.7",
|
||||
"tsx": "^4.16.5",
|
||||
"tsyringe": "^4.8.0",
|
||||
"typescript": "^5.5.2",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-full-reload": "^1.1.0",
|
||||
"vite-plugin-restart": "^0.4.0",
|
||||
"vitest": "^1.6.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5",
|
||||
"vitest": "^2.0.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.5.4",
|
||||
"@node-rs/argon2": "^1.8.3",
|
||||
"bits-ui": "^0.21.10",
|
||||
"@internationalized/date": "^3.5.5",
|
||||
"bits-ui": "^0.21.13",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.17",
|
||||
"embla-carousel-svelte": "^8.1.5",
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"embla-carousel-svelte": "^8.1.8",
|
||||
"formsnap": "^1.0.1",
|
||||
"hono-rate-limiter": "^0.3.0",
|
||||
"mode-watcher": "^0.3.1",
|
||||
"hono-rate-limiter": "^0.4.0",
|
||||
"mode-watcher": "^0.4.1",
|
||||
"paneforge": "^0.0.5",
|
||||
"rate-limit-redis": "^4.2.0",
|
||||
"resend": "^3.3.0",
|
||||
"svelte-sonner": "^0.3.24",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"resend": "^3.5.0",
|
||||
"svelte-sonner": "^0.3.27",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"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
|
|
@ -11,7 +11,7 @@ services:
|
|||
envVars:
|
||||
- key: DATABASE_URL
|
||||
fromDatabase:
|
||||
name: tarostack
|
||||
name: tofustack
|
||||
property: connectionString
|
||||
- key: REDIS_URL
|
||||
fromService:
|
||||
|
|
@ -29,5 +29,5 @@ services:
|
|||
|
||||
databases:
|
||||
- name: db
|
||||
databaseName: tarostack
|
||||
databaseName: tofustack
|
||||
ipAllowList: []
|
||||
|
|
|
|||
11
src/lib/server/api/common/classes/controller.class.ts
Normal file
11
src/lib/server/api/common/classes/controller.class.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Hono } from "hono";
|
||||
import type { HonoTypes } from "../types/hono.type";
|
||||
import type { BlankSchema, Env, Schema } from "hono/types";
|
||||
|
||||
export abstract class Controler {
|
||||
protected readonly controller: Hono<HonoTypes, BlankSchema, '/'>;
|
||||
constructor() {
|
||||
this.controller = new Hono();
|
||||
}
|
||||
abstract routes(): Hono<HonoTypes, BlankSchema, '/'>;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import * as envs from '$env/static/private';
|
||||
|
||||
export const config = { ...envs, isProduction: process.env.NODE_ENV === 'production' };
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { BlankSchema } from 'hono/types';
|
||||
import type { HonoTypes } from '../types/hono.type';
|
||||
|
||||
// export interface Controller {
|
||||
// routes()
|
||||
// }
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { DatabaseProvider } from "../../providers/database.provider";
|
||||
|
||||
export interface Repository {
|
||||
trxHost(trx: DatabaseProvider): any;
|
||||
}
|
||||
|
|
@ -2,13 +2,13 @@ import type { Promisify, RateLimitInfo } from 'hono-rate-limiter';
|
|||
import type { Session, User } from 'lucia';
|
||||
|
||||
export type HonoTypes = {
|
||||
Variables: {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
rateLimit: RateLimitInfo;
|
||||
Variables: {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
rateLimit: RateLimitInfo;
|
||||
rateLimitStore: {
|
||||
getKey?: (key: string) => Promisify<RateLimitInfo | undefined>;
|
||||
resetKey: (key: string) => Promisify<void>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
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,53 +1,25 @@
|
|||
import { Hono } from 'hono';
|
||||
import { Hono, type Schema } from 'hono';
|
||||
import { setCookie } from 'hono/cookie';
|
||||
import type { HonoTypes } from '../types';
|
||||
import { inject, injectable } from 'tsyringe';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { IamService } from '../services/iam.service';
|
||||
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 { updateEmailDto } from '../../../dtos/update-email.dto';
|
||||
import { verifyEmailDto } from '../../../dtos/verify-email.dto';
|
||||
import { registerEmailDto } from '../../../dtos/register-email.dto';
|
||||
import type { Controller } from '../interfaces/controller.interface';
|
||||
import { EmailVerificationsService } from '../services/email-verifications.service';
|
||||
import { LoginRequestsService } from '../services/login-requests.service';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Controller */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ---------------------------------- 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.
|
||||
*/
|
||||
/* -------------------------------------------------------------------------- */
|
||||
import { limiter } from '../middlewares/rate-limiter.middlware';
|
||||
import { requireAuth } from '../middlewares/auth.middleware';
|
||||
import { Controler } from '../common/classes/controller.class';
|
||||
|
||||
@injectable()
|
||||
export class IamController implements Controller {
|
||||
controller = new Hono<HonoTypes>();
|
||||
|
||||
export class IamController extends Controler {
|
||||
constructor(
|
||||
@inject(IamService) private iamService: IamService,
|
||||
@inject(LoginRequestsService) private loginRequestsService: LoginRequestsService,
|
||||
@inject(EmailVerificationsService) private emailVerificationsService: EmailVerificationsService,
|
||||
@inject(LuciaProvider) private lucia: LuciaProvider
|
||||
) { }
|
||||
@inject(LuciaProvider) private lucia: LuciaProvider,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
routes() {
|
||||
return this.controller
|
||||
|
|
@ -57,12 +29,12 @@ export class IamController implements Controller {
|
|||
})
|
||||
.post('/login/request', zValidator('json', registerEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
await this.loginRequestsService.create({ email });
|
||||
await this.iamService.createLoginRequest({ email });
|
||||
return c.json({ message: 'Verification email sent' });
|
||||
})
|
||||
.post('/login/verify', zValidator('json', signInEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
||||
const { email, token } = c.req.valid('json');
|
||||
const session = await this.loginRequestsService.verify({ email, token });
|
||||
const session = await this.iamService.verifyLoginRequest({ email, token });
|
||||
const sessionCookie = this.lucia.createSessionCookie(session.id);
|
||||
setCookie(c, sessionCookie.name, sessionCookie.value, {
|
||||
path: sessionCookie.attributes.path,
|
||||
|
|
@ -92,14 +64,14 @@ export class IamController implements Controller {
|
|||
})
|
||||
.patch('/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
||||
const json = c.req.valid('json');
|
||||
await this.emailVerificationsService.dispatchEmailVerificationRequest(c.var.user.id, json.email);
|
||||
await this.iamService.dispatchEmailVerificationRequest(c.var.user.id, json.email);
|
||||
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
|
||||
.post('/email/verification', requireAuth, zValidator('json', verifyEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
|
||||
const json = c.req.valid('json');
|
||||
await this.emailVerificationsService.processEmailVerificationRequest(c.var.user.id, json.token);
|
||||
await this.iamService.processEmailVerificationRequest(c.var.user.id, json.token);
|
||||
return c.json({ message: 'Verified and updated' });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS "email_verifications" (
|
|||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_verifications_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "login_requests" (
|
||||
"id" text PRIMARY KEY 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,
|
||||
CONSTRAINT "login_requests_email_unique" UNIQUE("email")
|
||||
);
|
||||
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"avatar" text,
|
||||
|
|
@ -36,13 +36,13 @@ CREATE TABLE IF NOT EXISTS "users" (
|
|||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
|
||||
--> statement-breakpoint
|
||||
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;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
--> statement-breakpoint
|
||||
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;
|
||||
EXCEPTION
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "2fdb0575-b4b3-4ebb-9ca0-73a655a7fbe7",
|
||||
"id": "7c8066fe-53c3-4fbb-b700-34ae02d25480",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "6",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.email_verifications": {
|
||||
|
|
@ -238,6 +238,7 @@
|
|||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"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 { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { timestamps } from '../utils';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { usersTable } from './users.table';
|
||||
import { timestamps } from '../../common/utils/table.utils';
|
||||
|
||||
export const emailVerificationsTable = pgTable('email_verifications', {
|
||||
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 { createId } from '@paralleldrive/cuid2';
|
||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { timestamps } from '../../common/utils/table.utils';
|
||||
|
||||
export const loginRequestsTable = pgTable('login_requests', {
|
||||
id: text('id')
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { cuid2 } from '../utils';
|
||||
import { cuid2 } from '../../common/utils/table.utils';
|
||||
import { usersTable } from './users.table';
|
||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
|
|
@ -1,15 +1,17 @@
|
|||
import { relations } from 'drizzle-orm';
|
||||
import { citext, timestamps } from '../utils';
|
||||
import { relations } from 'drizzle-orm';;
|
||||
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 { filesTable } from './files.table';
|
||||
|
||||
|
||||
export const usersTable = pgTable('users', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
avatar: text('avatar'),
|
||||
avatarId: cuid2('avatar').references(() => filesTable.id),
|
||||
email: citext('email').notNull().unique(),
|
||||
verified: boolean('verified').notNull().default(false),
|
||||
...timestamps
|
||||
|
|
@ -17,6 +19,10 @@ export const usersTable = pgTable('users', {
|
|||
|
||||
export const usersRelations = relations(usersTable, ({ many, one }) => ({
|
||||
sessions: many(sessionsTable),
|
||||
avatar: one(filesTable, {
|
||||
fields: [usersTable.avatarId],
|
||||
references: [filesTable.id]
|
||||
}),
|
||||
emailVerifications: one(emailVerificationsTable, {
|
||||
fields: [usersTable.id],
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
constructor() { }
|
||||
|
|
|
|||
|
|
@ -1,45 +1,41 @@
|
|||
import 'reflect-metadata';
|
||||
import './providers';
|
||||
import { Hono } from 'hono';
|
||||
import { hc } from 'hono/client';
|
||||
import { container } from 'tsyringe';
|
||||
import { validateAuthSession, verifyOrigin } from './middleware/auth.middleware';
|
||||
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 { AuthCleanupJobs } from './jobs/auth-cleanup.job';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Client Request */
|
||||
/* ------------------------------------ ▲ ----------------------------------- */
|
||||
/* ------------------------------------ | ----------------------------------- */
|
||||
/* ------------------------------------ ▼ ----------------------------------- */
|
||||
/* Controller */
|
||||
/* ---------------------------- (Request Routing) --------------------------- */
|
||||
/* ------------------------------------ ▲ ----------------------------------- */
|
||||
/* ------------------------------------ | ----------------------------------- */
|
||||
/* ------------------------------------ ▼ ----------------------------------- */
|
||||
/* Service */
|
||||
/* ---------------------------- (Business logic) ---------------------------- */
|
||||
/* ------------------------------------ ▲ ----------------------------------- */
|
||||
/* ------------------------------------ | ----------------------------------- */
|
||||
/* ------------------------------------ ▼ ----------------------------------- */
|
||||
/* Repository */
|
||||
/* ----------------------------- (Data storage) ----------------------------- */
|
||||
/* App */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
export const app = new Hono().basePath('/api');
|
||||
|
||||
/* ----------------------------------- Api ---------------------------------- */
|
||||
const app = new Hono().basePath('/api');
|
||||
|
||||
/* --------------------------- Global Middlewares --------------------------- */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Global Middlewares */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
app.use(verifyOrigin).use(validateAuthSession);
|
||||
|
||||
/* --------------------------------- Routes --------------------------------- */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Routes */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
const routes = app
|
||||
.route('/iam', container.resolve(IamController).routes())
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Cron Jobs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
container.resolve(AuthCleanupJobs).deleteStaleEmailVerificationRequests();
|
||||
container.resolve(AuthCleanupJobs).deleteStaleLoginRequests();
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Exports */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
export const rpc = hc<typeof routes>(config.ORIGIN);
|
||||
const rpc = hc<typeof routes>(env.ORIGIN);
|
||||
export type ApiClient = typeof rpc;
|
||||
export type ApiRoutes = typeof routes;
|
||||
export { app };
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
44
src/lib/server/api/jobs/auth-cleanup.job.ts
Normal file
44
src/lib/server/api/jobs/auth-cleanup.job.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { inject, injectable } from "tsyringe";
|
||||
import { JobsService } from "../services/jobs.service";
|
||||
|
||||
@injectable()
|
||||
export class AuthCleanupJobs {
|
||||
private queue;
|
||||
|
||||
constructor(
|
||||
@inject(JobsService) private jobsService: JobsService,
|
||||
) {
|
||||
/* ------------------------------ Create Queue ------------------------------ */
|
||||
this.queue = this.jobsService.createQueue('test')
|
||||
|
||||
/* ---------------------------- Register Workers ---------------------------- */
|
||||
this.worker();
|
||||
}
|
||||
|
||||
async deleteStaleEmailVerificationRequests() {
|
||||
this.queue.add('delete_stale_email_verifiactions', null, {
|
||||
repeat: {
|
||||
pattern: '0 0 * * 0' // Runs once a week at midnight on Sunday
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async deleteStaleLoginRequests() {
|
||||
this.queue.add('delete_stale_login_requests', null, {
|
||||
repeat: {
|
||||
pattern: '0 0 * * 0' // Runs once a week at midnight on Sunday
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async worker() {
|
||||
return this.jobsService.createWorker(this.queue.name, async (job) => {
|
||||
if (job.name === "delete_stale_email_verifiactions") {
|
||||
// delete stale email verifications
|
||||
}
|
||||
if (job.name === "delete_stale_login_requests") {
|
||||
// delete stale email verifications
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import type { HonoTypes } from '../types';
|
||||
import { lucia } from '../infrastructure/auth/lucia';
|
||||
import { verifyRequestOrigin } 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) => {
|
||||
if (c.req.method === "GET") {
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { rateLimiter } from "hono-rate-limiter";
|
||||
import { RedisStore } from 'rate-limit-redis'
|
||||
import RedisClient from 'ioredis'
|
||||
import type { HonoTypes } from "../types";
|
||||
import { config } from "../common/config";
|
||||
import { env } from "../configs/envs.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 = "" }: {
|
||||
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 });
|
||||
22
src/lib/server/api/packages/lucia.ts
Normal file
22
src/lib/server/api/packages/lucia.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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 { db } from '../infrastructure/database';
|
||||
import { db } from '../packages/drizzle';
|
||||
|
||||
// Symbol
|
||||
export const DatabaseProvider = Symbol('DATABASE_TOKEN');
|
||||
|
||||
// Type
|
||||
export type DatabaseProvider = typeof db;
|
||||
|
||||
// Register
|
||||
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 { lucia } from '../infrastructure/auth/lucia';
|
||||
import { lucia } from '../packages/lucia';
|
||||
|
||||
// Symbol
|
||||
export const LuciaProvider = Symbol('LUCIA_PROVIDER');
|
||||
|
||||
// Type
|
||||
export type LuciaProvider = typeof lucia;
|
||||
|
||||
// Register
|
||||
container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { container } from 'tsyringe';
|
||||
import { env } from '../configs/envs.config';
|
||||
import RedisClient from 'ioredis'
|
||||
import { config } from '../common/config';
|
||||
|
||||
// Symbol
|
||||
export const RedisProvider = Symbol('REDIS_TOKEN');
|
||||
|
||||
// Type
|
||||
export type RedisProvider = RedisClient;
|
||||
|
||||
// Register
|
||||
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 { DatabaseProvider } from "../providers";
|
||||
import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm";
|
||||
import type { Repository } from "../interfaces/repository.interface";
|
||||
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils";
|
||||
import { emailVerificationsTable } from "../infrastructure/database/tables/email-verifications.table";
|
||||
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";
|
||||
|
||||
|
||||
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 { DatabaseProvider } from "../providers";
|
||||
import type { Repository } from "../interfaces/repository.interface";
|
||||
import { and, eq, gte, type InferInsertModel } from "drizzle-orm";
|
||||
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils";
|
||||
import { loginRequestsTable } from "../infrastructure/database/tables/login-requests.table";
|
||||
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";
|
||||
|
||||
|
||||
export type CreateLoginRequest = Pick<InferInsertModel<typeof loginRequestsTable>, 'email' | 'expiresAt' | 'hashedToken'>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,9 @@
|
|||
import { inject, injectable } from 'tsyringe';
|
||||
import type { Repository } from '../interfaces/repository.interface';
|
||||
import { DatabaseProvider } from '../providers';
|
||||
import { usersTable } from '../databases/tables';
|
||||
import { eq, type InferInsertModel } from 'drizzle-orm';
|
||||
import { usersTable } from '../infrastructure/database/tables/users.table';
|
||||
import { takeFirstOrThrow } from '../infrastructure/database/utils';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 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.
|
||||
*/
|
||||
import { DatabaseProvider } from '../providers/database.provider';
|
||||
import { takeFirstOrThrow } from '../common/utils/repository.utils';
|
||||
import type { Repository } from '../common/inferfaces/repository.interface';
|
||||
|
||||
export type CreateUser = InferInsertModel<typeof usersTable>;
|
||||
export type UpdateUser = Partial<CreateUser>;
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { inject, injectable } from 'tsyringe';
|
||||
import { BadRequest } from '../common/errors';
|
||||
import { DatabaseProvider } from '../providers';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { TokensService } from './tokens.service';
|
||||
import { UsersRepository } from '../repositories/users.repository';
|
||||
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
|
||||
|
||||
@injectable()
|
||||
export class EmailVerificationsService {
|
||||
constructor(
|
||||
@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(EmailVerificationsRepository) private readonly emailVerificationsRepository: EmailVerificationsRepository,
|
||||
) { }
|
||||
|
||||
// These steps follow the process outlined in OWASP's "Changing A User's Email Address" guide.
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address
|
||||
async dispatchEmailVerificationRequest(userId: string, requestedEmail: string) {
|
||||
// generate a token and expiry
|
||||
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm')
|
||||
const user = await this.usersRepository.findOneByIdOrThrow(userId)
|
||||
|
||||
// create a new email verification record
|
||||
await this.emailVerificationsRepository.create({ requestedEmail, userId, hashedToken, expiresAt: expiry })
|
||||
|
||||
// A confirmation-required email message to the proposed new address, instructing the user to
|
||||
// confirm the change and providing a link for unexpected situations
|
||||
this.mailerService.sendEmailVerificationToken({
|
||||
to: requestedEmail,
|
||||
props: {
|
||||
token
|
||||
}
|
||||
})
|
||||
|
||||
// A notification-only email message to the current address, alerting the user to the impending change and
|
||||
// providing a link for an unexpected situation.
|
||||
this.mailerService.sendEmailChangeNotification({
|
||||
to: user.email,
|
||||
props: null
|
||||
})
|
||||
}
|
||||
|
||||
async processEmailVerificationRequest(userId: string, token: string) {
|
||||
const validRecord = await this.findAndBurnEmailVerificationToken(userId, token)
|
||||
if (!validRecord) throw BadRequest('Invalid token');
|
||||
await this.usersRepository.update(userId, { email: validRecord.requestedEmail, verified: true });
|
||||
}
|
||||
|
||||
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
|
||||
return this.db.transaction(async (trx) => {
|
||||
// find a valid record
|
||||
const emailVerificationRecord = await this.emailVerificationsRepository.trxHost(trx).findValidRecord(userId);
|
||||
if (!emailVerificationRecord) return null;
|
||||
|
||||
// check if the token is valid
|
||||
const isValidRecord = await this.tokensService.verifyHashedToken(emailVerificationRecord.hashedToken, token);
|
||||
if (!isValidRecord) return null
|
||||
|
||||
// burn the token if it is valid
|
||||
await this.emailVerificationsRepository.trxHost(trx).deleteById(emailVerificationRecord.id)
|
||||
return emailVerificationRecord
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { 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()
|
||||
export class HashingService {
|
||||
private readonly hasher = new Scrypt();
|
||||
// private readonly hasher = new Argon2id(); // argon2id hasher
|
||||
N;
|
||||
r;
|
||||
p;
|
||||
dkLen;
|
||||
|
||||
async hash(data: string) {
|
||||
return this.hasher.hash(data);
|
||||
constructor() {
|
||||
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) {
|
||||
return this.hasher.verify(hash, data)
|
||||
async verify(hash: string, password: string) {
|
||||
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,31 +1,128 @@
|
|||
import { inject, injectable } from 'tsyringe';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { TokensService } from './tokens.service';
|
||||
import { LuciaProvider } from '../providers/lucia.provider';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Service */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ---------------------------------- About --------------------------------- */
|
||||
/*
|
||||
Services are responsible for handling business logic and data manipulation.
|
||||
They genreally call on repositories or other services to complete a use-case.
|
||||
*/
|
||||
/* ---------------------------------- Notes --------------------------------- */
|
||||
/*
|
||||
Services should be kept as clean and simple as possible.
|
||||
|
||||
Create private functions to handle complex logic and keep the public methods as
|
||||
simple as possible. This makes the service easier to read, test and understand.
|
||||
*/
|
||||
/* -------------------------------------------------------------------------- */
|
||||
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';
|
||||
|
||||
@injectable()
|
||||
export class IamService {
|
||||
constructor(
|
||||
@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,
|
||||
@inject(EmailVerificationsRepository) private readonly emailVerificationsRepository: EmailVerificationsRepository,
|
||||
) { }
|
||||
|
||||
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, {});
|
||||
}
|
||||
|
||||
// These steps follow the process outlined in OWASP's "Changing A User's Email Address" guide.
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address
|
||||
async dispatchEmailVerificationRequest(userId: string, requestedEmail: string) {
|
||||
// generate a token and expiry
|
||||
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm')
|
||||
const user = await this.usersRepository.findOneByIdOrThrow(userId)
|
||||
|
||||
// create a new email verification record
|
||||
await this.emailVerificationsRepository.create({ requestedEmail, userId, hashedToken, expiresAt: expiry })
|
||||
|
||||
// A confirmation-required email message to the proposed new address, instructing the user to
|
||||
// confirm the change and providing a link for unexpected situations
|
||||
this.mailerService.send({
|
||||
to: requestedEmail,
|
||||
email: new LoginVerificationEmail(token)
|
||||
})
|
||||
|
||||
// A notification-only email message to the current address, alerting the user to the impending change and
|
||||
// providing a link for an unexpected situation.
|
||||
this.mailerService.send({
|
||||
to: user.email,
|
||||
email: new EmailChangeNoticeEmail()
|
||||
})
|
||||
}
|
||||
|
||||
async processEmailVerificationRequest(userId: string, token: string) {
|
||||
const validRecord = await this.findAndBurnEmailVerificationToken(userId, token)
|
||||
if (!validRecord) throw BadRequest('Invalid token');
|
||||
await this.usersRepository.update(userId, { email: validRecord.requestedEmail, verified: true });
|
||||
}
|
||||
|
||||
async logout(sessionId: string) {
|
||||
return this.lucia.invalidateSession(sessionId);
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
|
||||
return this.db.transaction(async (trx) => {
|
||||
// find a valid record
|
||||
const emailVerificationRecord = await this.emailVerificationsRepository.trxHost(trx).findValidRecord(userId);
|
||||
if (!emailVerificationRecord) return null;
|
||||
|
||||
// check if the token is valid
|
||||
const isValidRecord = await this.tokensService.verifyHashedToken(emailVerificationRecord.hashedToken, token);
|
||||
if (!isValidRecord) return null
|
||||
|
||||
// burn the token if it is valid
|
||||
await this.emailVerificationsRepository.trxHost(trx).deleteById(emailVerificationRecord.id)
|
||||
return emailVerificationRecord
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
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) {
|
||||
}
|
||||
|
||||
createQueue(name: string) {
|
||||
return new Queue(name, { connection: this.redis })
|
||||
}
|
||||
|
||||
createWorker(name: string, prcoessor: Processor) {
|
||||
return new Worker(name, prcoessor, { connection: this.redis })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { inject, injectable } from 'tsyringe';
|
||||
import { BadRequest } from '../common/errors';
|
||||
import { DatabaseProvider } from '../providers';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { TokensService } from './tokens.service';
|
||||
import { LuciaProvider } from '../providers/lucia.provider';
|
||||
import { UsersRepository } from '../repositories/users.repository';
|
||||
import type { SignInEmailDto } from '../../../dtos/signin-email.dto';
|
||||
import type { RegisterEmailDto } from '../../../dtos/register-email.dto';
|
||||
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
|
||||
|
||||
@injectable()
|
||||
export class LoginRequestsService {
|
||||
constructor(
|
||||
@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 create(data: RegisterEmailDto) {
|
||||
// generate a token, expiry date, and hash
|
||||
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm');
|
||||
// save the login request to the database - ensuring we save the hashedToken
|
||||
await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
|
||||
// send the login request email
|
||||
await this.mailerService.sendLoginRequest({
|
||||
to: data.email,
|
||||
props: { token: token }
|
||||
});
|
||||
}
|
||||
|
||||
async verify(data: SignInEmailDto) {
|
||||
const validLoginRequest = await this.fetchValidRequest(data.email, data.token);
|
||||
if (!validLoginRequest) throw BadRequest('Invalid token');
|
||||
|
||||
let existingUser = await this.usersRepository.findOneByEmail(data.email);
|
||||
|
||||
if (!existingUser) {
|
||||
const newUser = await this.handleNewUserRegistration(data.email);
|
||||
return this.lucia.createSession(newUser.id, {});
|
||||
}
|
||||
|
||||
return this.lucia.createSession(existingUser.id, {});
|
||||
}
|
||||
|
||||
// Create a new user and send a welcome email - or other onboarding process
|
||||
private async handleNewUserRegistration(email: string) {
|
||||
const newUser = await this.usersRepository.create({ email, verified: true, avatar: null })
|
||||
this.mailerService.sendWelcome({ to: email, props: null });
|
||||
// TODO: add whatever onboarding process or extra data you need here
|
||||
return newUser
|
||||
}
|
||||
|
||||
// Fetch a valid request from the database, verify the token and burn the request if it is valid
|
||||
private async fetchValidRequest(email: string, token: string) {
|
||||
return await this.db.transaction(async (trx) => {
|
||||
// fetch the login request
|
||||
const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email)
|
||||
if (!loginRequest) return null;
|
||||
|
||||
// check if the token is valid
|
||||
const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token);
|
||||
if (!isValidRequest) return null
|
||||
|
||||
// if the token is valid, burn the request
|
||||
await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id);
|
||||
return loginRequest
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import { injectable } from 'tsyringe';
|
||||
import type { Email } from '../interfaces/email.interface';
|
||||
import { config } from '../common/config';
|
||||
|
||||
import { env } from '../configs/envs.config';
|
||||
import type { Email } from '../common/inferfaces/email.interface';
|
||||
|
||||
type SendProps = {
|
||||
to: string | string[];
|
||||
|
|
@ -11,34 +9,35 @@ type SendProps = {
|
|||
|
||||
@injectable()
|
||||
export class MailerService {
|
||||
|
||||
async send(data: SendProps) {
|
||||
const mailer = env.isProduction ? this.sendProd : this.sendDev;
|
||||
await mailer(data);
|
||||
}
|
||||
|
||||
private async sendDev({ to, email }: SendProps) {
|
||||
const message = await nodemailer.createTransport({
|
||||
host: 'smtp.ethereal.email',
|
||||
port: 587,
|
||||
secure: false, // Use `true` for port 465, `false` for all other ports
|
||||
auth: {
|
||||
user: 'adella.hoppe@ethereal.email',
|
||||
pass: 'dshNQZYhATsdJ3ENke'
|
||||
}
|
||||
}).sendMail({
|
||||
from: '"Example" <example@ethereal.email>',
|
||||
bcc: to,
|
||||
subject: email.subject(),
|
||||
text: email.html(),
|
||||
html: email.html()
|
||||
});
|
||||
console.log(nodemailer.getTestMessageUrl(message));
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
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) {
|
||||
// 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 { MailerService } from '../services/mailer.service';
|
||||
import { UsersRepository } from '../repositories/users.repository';
|
||||
import { DatabaseProvider, LuciaProvider } from '../providers';
|
||||
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';
|
||||
|
||||
describe('LoginRequestService', () => {
|
||||
let service: LoginRequestsService;
|
||||
|
|
@ -29,7 +30,6 @@ describe('LoginRequestService', () => {
|
|||
.resolve(LoginRequestsService);
|
||||
});
|
||||
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
|
@ -50,9 +50,9 @@ describe('LoginRequestService', () => {
|
|||
updatedAt: new Date()
|
||||
} 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_loginRequestsRepository_create = vi.spyOn(loginRequestsRepository, 'create')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"status": "failed"
|
||||
}
|
||||
Loading…
Reference in a new issue