diff --git a/.env b/.env index 8e1f69e..765ec2c 100644 --- a/.env +++ b/.env @@ -3,3 +3,4 @@ POSTGRES_PASSWORD=postgres POSTGRES_DB=medium POSTGRES_HOST=0.0.0.0 POSTGRES_PORT=5432 +JWT_SECRET=supersecretkey diff --git a/.vscode/settings.json b/.vscode/settings.json index 78664b2..9504263 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "editor.tabSize": 2 + "editor.tabSize": 2, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + } } diff --git a/biome.json b/biome.json index 147554b..a757e21 100644 --- a/biome.json +++ b/biome.json @@ -18,5 +18,8 @@ "rules": { "recommended": true } + }, + "files": { + "maxSize": 3145728 } } diff --git a/bun.lockb b/bun.lockb index 0105372..10f4072 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/db/config.ts b/db/config.ts index 1ed4651..17c14fd 100644 --- a/db/config.ts +++ b/db/config.ts @@ -10,7 +10,7 @@ export const dbCredentials = { export const dbCredentialsString = `postgres://${dbCredentials.user}:${dbCredentials.password}@${dbCredentials.host}:${dbCredentials.port}/${dbCredentials.database}`; export default { - out: './src/db/migrations', + out: './db/migrations', schema: '**/*.schema.ts', breakpoints: false, driver: 'pg', diff --git a/db/migrations/migrate.ts b/db/migrate.ts similarity index 69% rename from db/migrations/migrate.ts rename to db/migrate.ts index 46c3c34..6e1b7f6 100644 --- a/db/migrations/migrate.ts +++ b/db/migrate.ts @@ -1,7 +1,9 @@ +import { exit } from 'process'; import { drizzle } from 'drizzle-orm/postgres-js'; import { migrate } from 'drizzle-orm/postgres-js/migrator'; import { migrationsClient } from '@/database.providers'; await migrate(drizzle(migrationsClient), { - migrationsFolder: `${import.meta.dir}`, + migrationsFolder: `${import.meta.dir}/migrations`, }); +exit(0); diff --git a/db/migrations/0000_bored_warstar.sql b/db/migrations/0000_perpetual_blazing_skull.sql similarity index 62% rename from db/migrations/0000_bored_warstar.sql rename to db/migrations/0000_perpetual_blazing_skull.sql index b6c813c..7f0ce12 100644 --- a/db/migrations/0000_bored_warstar.sql +++ b/db/migrations/0000_perpetual_blazing_skull.sql @@ -1,10 +1,11 @@ CREATE TABLE IF NOT EXISTS "users" ( "id" serial PRIMARY KEY NOT NULL, "email" text NOT NULL, - "bio" text NOT NULL, - "image" text NOT NULL, + "bio" text, + "image" text, "password" text NOT NULL, "username" text NOT NULL, "created_at" date DEFAULT CURRENT_DATE, - "updated_at" date DEFAULT CURRENT_DATE + "updated_at" date DEFAULT CURRENT_DATE, + CONSTRAINT "users_email_unique" UNIQUE("email") ); diff --git a/db/migrations/meta/0000_snapshot.json b/db/migrations/meta/0000_snapshot.json index 354ebd1..278e255 100644 --- a/db/migrations/meta/0000_snapshot.json +++ b/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "pg", - "id": "86aed854-dd08-4bcd-8138-412a71492c24", + "id": "8ed456d0-e522-4f1a-a07e-a19b3cd900bc", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "users": { @@ -24,13 +24,13 @@ "name": "bio", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "image": { "name": "image", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "password": { "name": "password", @@ -62,7 +62,13 @@ "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {} + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } } }, "enums": {}, diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 51409b4..45d927e 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1695584965813, - "tag": "0000_bored_warstar", + "when": 1695849229878, + "tag": "0000_perpetual_blazing_skull", "breakpoints": false } ] diff --git a/db/seed.ts b/db/seed.ts index 5d17cbc..961b539 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -1,6 +1,6 @@ import { exit } from 'process'; import { db } from '@/database.providers'; -import { users } from '@/users/users.schema'; +import { users } from '@/users/users.model'; const data = { id: users.id.default, diff --git a/package.json b/package.json index bef5299..aa6da43 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "docs:preview": "vitepress preview docs", "db:up": "./scripts/create-start-container-with-env.sh", "db:generate": "bun drizzle-kit generate:pg --config=db/config.ts", - "db:migrate": "bun run db/migrations/migrate.ts", + "db:migrate": "bun run db/migrate.ts", "db:push": "bun drizzle-kit push:pg --config=db/config.ts", "db:seed": "bun run db/seed.ts", "db:studio": "bun drizzle-kit studio --config=db/config.ts", @@ -25,6 +25,7 @@ "drizzle-orm": "^0.28.6", "drizzle-typebox": "^0.1.1", "elysia": "latest", + "jose": "^4.14.6", "postgres": "^3.3.5" }, "devDependencies": { diff --git a/src/app.module.ts b/src/app.module.ts index a12f597..8ad339e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,13 +1,30 @@ import { Elysia } from 'elysia'; import { swagger } from '@elysiajs/swagger'; -import { usersPlugin } from '@users/users.plugin'; import { title, version, description } from '../package.json'; +import { usersPlugin } from '@/users/users.plugin'; +import { + AuthenticationError, + AuthorizationError, + ERROR_CODE_STATUS_MAP, +} from '@/errors'; + +// the file name is in the spirit of NestJS, where app module is the device in charge of putting together all the pieces of the app +// see: https://docs.nestjs.com/modules /** * Add all plugins to the app */ export const setupApp = () => { return new Elysia() + .error({ + AUTHENTICATION: AuthenticationError, + AUTHORIZATION: AuthorizationError, + }) + .onError(({ error, code, set }) => { + set.status = ERROR_CODE_STATUS_MAP.get(code); + const errorType = 'type' in error ? error.type : 'internal'; + return { errors: { [errorType]: error.message } }; + }) .use( swagger({ documentation: { diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..a2da26e --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,89 @@ +import * as jose from 'jose'; +import { Type } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import { UserInDb } from '@/users/users.schema'; +import { env } from '@/config'; +import { AuthenticationError } from '@/errors'; + +export const ALG = 'HS256'; + +const VerifiedJwtSchema = Type.Object({ + payload: Type.Object({ + user: Type.Object({ + id: Type.Number(), + email: Type.String(), + username: Type.String(), + }), + iat: Type.Number(), + iss: Type.String(), + aud: Type.String(), + exp: Type.Number(), + }), + protectedHeader: Type.Object({ + alg: Type.Literal(ALG), + }), +}); + +export async function generateToken(user: UserInDb) { + const encoder = new TextEncoder(); + const secret = encoder.encode(env.JWT_SECRET); + + return await new jose.SignJWT({ + user: { id: user.id, email: user.email, username: user.username }, + }) + .setProtectedHeader({ alg: ALG }) + .setIssuedAt() + .setIssuer('agnyz') + .setAudience(user.email) + .setExpirationTime('24h') + .sign(secret); +} + +export async function verifyToken(token: string) { + const encoder = new TextEncoder(); + const secret = encoder.encode(env.JWT_SECRET); + + let verifiedToken; + try { + verifiedToken = await jose.jwtVerify(token, secret, { + algorithms: [ALG], + }); + } catch (err) { + throw new AuthenticationError('Invalid token'); + } + // I'm not sure if this is a good idea, but it at least makes sure that the token is 100% correct + // Also adds typings to the token + if (!Value.Check(VerifiedJwtSchema, verifiedToken)) + throw new AuthenticationError('Invalid token'); + const userToken = Value.Cast(VerifiedJwtSchema, verifiedToken); + return userToken; +} + +export async function getUserFromHeaders(headers: Headers) { + const rawHeader = headers.get('Authorization'); + if (!rawHeader) throw new AuthenticationError('Missing authorization header'); + + const tokenParts = rawHeader?.split(' '); + const tokenType = tokenParts?.[0]; + if (tokenType !== 'Token') + throw new AuthenticationError( + "Invalid token type. Expected header format: 'Token jwt'", + ); + + const token = tokenParts?.[1]; + const userToken = await verifyToken(token); + return userToken.payload.user; +} + +export async function requireLogin({ + request: { headers }, +}: { + request: Request; +}) { + await getUserFromHeaders(headers); +} + +export async function getUserEmailFromHeader(headers: Headers) { + const user = await getUserFromHeaders(headers); + return user.email; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..8f55904 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,14 @@ +import { Type } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; + +const envSchema = Type.Object({ + POSTGRES_DB: Type.String(), + POSTGRES_USER: Type.String(), + POSTGRES_PASSWORD: Type.String(), + POSTGRES_HOST: Type.String(), + POSTGRES_PORT: Type.String(), + JWT_SECRET: Type.String(), +}); +// TODO: this is ugly, find a better way to do this +if (!Value.Check(envSchema, Bun.env)) throw new Error('Invalid env variables'); +export const env = Value.Cast(envSchema, Bun.env); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..e53dbc6 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,29 @@ +import { DEFAULT, MapWithDefault } from '@/utils/defaultmap'; + +export class AuthenticationError extends Error { + public status = 401; + public type = 'authentication'; + constructor(public message: string) { + super(message); + } +} + +export class AuthorizationError extends Error { + public status = 403; + public type = 'authorization'; + constructor(public message: string) { + super(message); + } +} + +export const ERROR_CODE_STATUS_MAP = new MapWithDefault([ + ['PARSE', 400], + ['VALIDATION', 422], + ['NOT_FOUND', 404], + ['INVALID_COOKIE_SIGNATURE', 401], + ['AUTHENTICATION', 401], + ['AUTHORIZATION', 403], + ['INTERNAL_SERVER_ERROR', 500], + ['UNKNOWN', 500], + [DEFAULT, 500], +]); diff --git a/src/main.ts b/src/main.ts index 45a1101..0b41c3b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ -import { setupApp } from '@/app.module'; import { Elysia } from 'elysia'; +import { setupApp } from '@/app.module'; const app = new Elysia({ prefix: '/api' }).use(setupApp).listen(3000); diff --git a/src/users/users.model.ts b/src/users/users.model.ts new file mode 100644 index 0000000..d679169 --- /dev/null +++ b/src/users/users.model.ts @@ -0,0 +1,13 @@ +import { sql } from 'drizzle-orm'; +import { date, pgTable, serial, text } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').unique().notNull(), + bio: text('bio'), + image: text('image'), + password: text('password').notNull(), + username: text('username').notNull(), + created_at: date('created_at').default(sql`CURRENT_DATE`), + updated_at: date('updated_at').default(sql`CURRENT_DATE`), +}); diff --git a/src/users/users.plugin.ts b/src/users/users.plugin.ts index 335374f..ae5ed0c 100644 --- a/src/users/users.plugin.ts +++ b/src/users/users.plugin.ts @@ -1,15 +1,43 @@ import { Elysia } from 'elysia'; import { setupUsers } from '@/users/users.module'; +import { + InsertUserSchema, + UserAuthSchema, + UserLoginSchema, +} from '@/users/users.schema'; +import { getUserEmailFromHeader, requireLogin } from '@/auth'; -export const usersPlugin = new Elysia().use(setupUsers).group( - '/users', - { - detail: { - tags: ['Users'], - }, - }, - (app) => +export const usersPlugin = new Elysia() + .use(setupUsers) + .group('/users', (app) => app - .post('', ({ store }) => store.usersService.findAll()) - .post('/login', ({ store }) => store.usersService.findAll()), -); + .post('', ({ body, store }) => store.usersService.createUser(body.user), { + body: InsertUserSchema, + response: UserAuthSchema, + detail: { + summary: 'Create a user', + }, + }) + .post( + '/login', + ({ body, store }) => + store.usersService.loginUser(body.user.email, body.user.password), + { + body: UserLoginSchema, + response: UserAuthSchema, + }, + ), + ) + .group('/user', (app) => + app.get( + '', + async ({ request, store }) => + store.usersService.findByEmail( + await getUserEmailFromHeader(request.headers), + ), + { + beforeHandle: requireLogin, + response: UserAuthSchema, + }, + ), + ); diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 4b225ed..37a5e7b 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -1,10 +1,35 @@ +// users.repository.ts +// in charge of database interactions + +import { eq } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import { users } from './users.schema'; +import { UserToCreate } from '@/users/users.schema'; +import { users } from '@/users/users.model'; export class UsersRepository { constructor(private readonly db: PostgresJsDatabase) {} async findAll() { - return this.db.select().from(users); + return await this.db.select().from(users); + } + + async findByEmail(email: string) { + const result = await this.db + .select() + .from(users) + .where(eq(users.email, email)); + if (result.length === 0) { + return null; + } + if (result.length > 1) { + throw new Error(`More than one user found with the same email: ${email}`); + } + return result[0]; + } + + async createUser(user: UserToCreate) { + const newUser = await this.db.insert(users).values(user).returning(); + // returning returns the inserted row in an array, so we need to get the first element + return newUser[0]; } } diff --git a/src/users/users.schema.ts b/src/users/users.schema.ts index aea35d3..b6742e7 100644 --- a/src/users/users.schema.ts +++ b/src/users/users.schema.ts @@ -1,26 +1,36 @@ import { Type } from '@sinclair/typebox'; -import { sql } from 'drizzle-orm'; -import { date, pgTable, serial, text } from 'drizzle-orm/pg-core'; import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; - -export const users = pgTable('users', { - id: serial('id').primaryKey(), - email: text('email').notNull(), - bio: text('bio').notNull(), - image: text('image').notNull(), - password: text('password').notNull(), - username: text('username').notNull(), - created_at: date('created_at').default(sql`CURRENT_DATE`), - updated_at: date('updated_at').default(sql`CURRENT_DATE`), -}); +import { users } from '@users/users.model'; // Schema for inserting a user - can be used to validate API requests const insertUserSchemaRaw = createInsertSchema(users); -export const insertUserSchema = Type.Omit(insertUserSchemaRaw, [ - 'id', - 'created_at', - 'updated_at', -]); +export const InsertUserSchema = Type.Object({ + user: Type.Omit(insertUserSchemaRaw, ['id', 'created_at', 'updated_at']), +}); + +export const UserAuthSchema = Type.Object({ + user: Type.Composite([ + Type.Omit(insertUserSchemaRaw, [ + 'id', + 'password', + 'created_at', + 'updated_at', + ]), + Type.Object({ token: Type.String() }), + ]), +}); + +export type UserToCreate = typeof users.$inferInsert; +export type UserInDb = typeof users.$inferSelect; +export type User = Omit; + +export const UserLoginSchema = Type.Object({ + user: Type.Object({ + email: Type.String(), + password: Type.String(), + }), +}); // Schema for selecting a user - can be used to validate API responses -export const selectUserSchema = createSelectSchema(users); +const selectUserSchemaRaw = createSelectSchema(users); +export const SelectUserSchema = Type.Omit(selectUserSchemaRaw, ['password']); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 86fedfb..3c22302 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,9 +1,48 @@ +// users.service.ts +// in charge of business logic - generate slug, fetch data from other services, cache something, etc. +import { NotFoundError } from 'elysia'; import { UsersRepository } from '@/users/users.repository'; +import { UserInDb, UserToCreate } from '@/users/users.schema'; +import { generateToken } from '@/auth'; +import { AuthenticationError } from '@/errors'; export class UsersService { constructor(private readonly repository: UsersRepository) {} - async findAll() { - return this.repository.findAll(); + async findByEmail(email: string) { + const user = await this.repository.findByEmail(email); + if (!user) { + throw new NotFoundError('User not found'); + } + return await this.generateUserResponse(user); + } + + async createUser(user: UserToCreate) { + user.password = await Bun.password.hash(user.password); + const newUser = await this.repository.createUser(user); + return await this.generateUserResponse(newUser); + } + + async loginUser(email: string, password: string) { + const user = await this.repository.findByEmail(email); + if (!user) { + throw new NotFoundError('User not found'); + } + if (!(await Bun.password.verify(password, user.password))) { + throw new AuthenticationError('Invalid password'); + } + return await this.generateUserResponse(user); + } + + async generateUserResponse(user: UserInDb) { + return { + user: { + email: user.email, + bio: user.bio, + image: user.image, + username: user.username, + token: await generateToken(user), + }, + }; } } diff --git a/src/utils/defaultmap.ts b/src/utils/defaultmap.ts new file mode 100644 index 0000000..c7d92fd --- /dev/null +++ b/src/utils/defaultmap.ts @@ -0,0 +1,7 @@ +export const DEFAULT = Symbol(); + +export class MapWithDefault extends Map { + get(key: K): V { + return super.get(key) ?? (super.get(DEFAULT) as V); + } +}