diff --git a/.env.example b/.env.example index 6ec97bf..94ad5aa 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ DATABASE_PASSWORD='postgres' DATABASE_HOST='localhost' DATABASE_PORT=5432 DATABASE_DB='postgres' + +PORT=5173 +ENV=dev +SIGNING_SECRET="" ENCRYPTION_KEY="" REDIS_URL='redis://127.0.0.1:6379/0' diff --git a/package.json b/package.json index 733cd8a..17b7eaa 100644 --- a/package.json +++ b/package.json @@ -1,143 +1,142 @@ { - "name": "boredgame", - "version": "0.0.5", - "private": "true", - "scripts": { - "db:push": "drizzle-kit push", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx src/lib/server/api/databases/postgres/migrate.ts", - "db:seed": "tsx src/lib/server/api/databases/postgres/seed.ts", - "db:studio": "drizzle-kit studio --verbose", - "dev": "NODE_OPTIONS=\"--inspect\" vite dev --host", - "build": "vite build", - "package": "svelte-kit package", - "preview": "vite preview", - "test:e2e": "playwright test", - "test:ui": "svelte-kit sync && playwright test --ui", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "initialize": "pnpm install && docker compose up --no-recreate -d && pnpm db:migrate && pnpm db:seed", - "lint": "prettier --plugin-search-dir . --check . && eslint .", - "format": "prettier --plugin-search-dir . --write .", - "site:update": "pnpm update -i -L", - "test:unit": "vitest" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@faker-js/faker": "^8.4.1", - "@melt-ui/pp": "^0.3.2", - "@melt-ui/svelte": "^0.83.0", - "@playwright/test": "^1.49.0", - "@sveltejs/adapter-auto": "^3.3.1", - "@sveltejs/enhanced-img": "^0.3.10", - "@sveltejs/kit": "^2.8.2", - "@sveltejs/vite-plugin-svelte": "4.0.0-next.7", - "@types/cookie": "^0.6.0", - "@types/node": "^20.17.7", - "@types/pg": "^8.11.10", - "@types/qrcode": "^1.5.5", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "arctic": "^1.9.2", - "autoprefixer": "^10.4.20", - "bits-ui": "^0.21.16", - "drizzle-kit": "^0.27.2", - "formsnap": "^1.0.1", - "just-clone": "^6.2.0", - "just-debounce-it": "^3.2.0", - "lucia": "3.2.0", - "lucide-svelte": "^0.408.0", - "mode-watcher": "^0.4.1", - "nodemailer": "^6.9.16", - "postcss": "^8.4.49", - "postcss-import": "^16.1.0", - "postcss-load-config": "^5.1.0", - "postcss-preset-env": "^9.6.0", - "prettier": "^3.3.3", - "prettier-plugin-svelte": "^3.3.2", - "svelte": "5.0.0-next.175", - "svelte-check": "^3.8.6", - "svelte-headless-table": "^0.18.3", - "svelte-meta-tags": "^3.1.4", - "svelte-preprocess": "^6.0.3", - "svelte-sequential-preprocessor": "^2.0.2", - "svelte-sonner": "^0.3.28", - "sveltekit-flash-message": "^2.4.4", - "sveltekit-superforms": "^2.20.1", - "tailwindcss": "^3.4.15", - "ts-node": "^10.9.2", - "tslib": "^2.8.1", - "tsx": "^4.19.2", - "typescript": "^5.7.2", - "vite": "^5.4.11", - "vitest": "^1.6.0", - "zod": "^3.23.8" - }, - "type": "module", - "dependencies": { - "@fontsource/fira-mono": "^5.1.0", - "@hono/swagger-ui": "^0.4.1", - "@hono/zod-openapi": "^0.15.3", - "@hono/zod-validator": "^0.2.2", - "@iconify-icons/line-md": "^1.2.30", - "@iconify-icons/mdi": "^1.2.48", - "@inlang/paraglide-sveltekit": "^0.11.1", - "@internationalized/date": "^3.6.0", - "@lucia-auth/adapter-drizzle": "^1.1.0", - "@lukeed/uuid": "^2.0.1", - "@needle-di/core": "^0.8.4", - "@neondatabase/serverless": "^0.9.5", - "@node-rs/argon2": "^1.8.3", - "@oslojs/binary": "^1.0.0", - "@oslojs/crypto": "^1.0.1", - "@oslojs/encoding": "^1.1.0", - "@oslojs/jwt": "^0.2.0", - "@oslojs/oauth2": "^0.5.0", - "@oslojs/otp": "^1.0.0", - "@oslojs/webauthn": "^1.0.0", - "@paralleldrive/cuid2": "^2.2.2", - "@scalar/hono-api-reference": "^0.5.161", - "@sveltejs/adapter-node": "^5.2.9", - "@sveltejs/adapter-vercel": "^5.4.8", - "@types/feather-icons": "^4.29.4", - "boardgamegeekclient": "^1.9.1", - "bullmq": "^5.29.1", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "cookie": "^1.0.2", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.7", - "drizzle-orm": "^0.36.4", - "drizzle-zod": "^0.5.1", - "feather-icons": "^4.29.2", - "handlebars": "^4.7.8", - "hono": "^4.6.11", - "hono-pino": "^0.7.0", - "hono-rate-limiter": "^0.4.0", - "hono-zod-openapi": "^0.5.0", - "html-entities": "^2.5.2", - "iconify-icon": "^2.1.0", - "ioredis": "^5.4.1", - "just-capitalize": "^3.2.0", - "just-kebab-case": "^4.2.0", - "loader": "^2.1.1", - "nanoid": "^5.0.8", - "open-props": "^1.7.7", - "oslo": "^1.2.1", - "pg": "^8.13.1", - "pino": "^9.5.0", - "pino-pretty": "^11.3.0", - "postgres": "^3.4.5", - "qrcode": "^1.5.4", - "radix-svelte": "^0.9.0", - "rate-limit-redis": "^4.2.0", - "reflect-metadata": "^0.2.2", - "stoker": "^1.3.0", - "svelte-lazy-loader": "^1.0.0", - "tailwind-merge": "^2.5.5", - "tailwind-variants": "^0.2.1", - "tailwindcss-animate": "^1.0.7", - "tsyringe": "^4.8.0", - "zod-to-json-schema": "^3.23.5" - } + "name": "boredgame", + "version": "0.0.5", + "private": "true", + "scripts": { + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/lib/server/api/databases/postgres/migrate.ts", + "db:seed": "tsx src/lib/server/api/databases/postgres/seed.ts", + "db:studio": "drizzle-kit studio --verbose", + "dev": "NODE_OPTIONS=\"--inspect\" vite dev --host", + "build": "vite build", + "package": "svelte-kit package", + "preview": "vite preview", + "test:e2e": "playwright test", + "test:ui": "svelte-kit sync && playwright test --ui", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "initialize": "pnpm install && docker compose up --no-recreate -d && pnpm db:migrate && pnpm db:seed", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write .", + "site:update": "pnpm update -i -L", + "test:unit": "vitest" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@faker-js/faker": "^8.4.1", + "@melt-ui/pp": "^0.3.2", + "@melt-ui/svelte": "^0.83.0", + "@playwright/test": "^1.49.0", + "@sveltejs/adapter-auto": "^3.3.1", + "@sveltejs/enhanced-img": "^0.3.10", + "@sveltejs/kit": "^2.8.5", + "@sveltejs/vite-plugin-svelte": "4.0.0-next.7", + "@types/cookie": "^0.6.0", + "@types/node": "^20.17.8", + "@types/pg": "^8.11.10", + "@types/qrcode": "^1.5.5", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "arctic": "^2.3.0", + "autoprefixer": "^10.4.20", + "bits-ui": "^0.21.16", + "drizzle-kit": "^0.27.2", + "formsnap": "^1.0.1", + "just-clone": "^6.2.0", + "just-debounce-it": "^3.2.0", + "lucide-svelte": "^0.408.0", + "mode-watcher": "^0.4.1", + "nodemailer": "^6.9.16", + "postcss": "^8.4.49", + "postcss-import": "^16.1.0", + "postcss-load-config": "^5.1.0", + "postcss-preset-env": "^9.6.0", + "prettier": "^3.4.1", + "prettier-plugin-svelte": "^3.3.2", + "svelte": "5.0.0-next.175", + "svelte-check": "^3.8.6", + "svelte-headless-table": "^0.18.3", + "svelte-meta-tags": "^3.1.4", + "svelte-preprocess": "^6.0.3", + "svelte-sequential-preprocessor": "^2.0.2", + "svelte-sonner": "^0.3.28", + "sveltekit-flash-message": "^2.4.4", + "sveltekit-superforms": "^2.20.1", + "tailwindcss": "^3.4.15", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^1.6.0", + "zod": "^3.23.8" + }, + "type": "module", + "dependencies": { + "@fontsource/fira-mono": "^5.1.0", + "@hono/swagger-ui": "^0.4.1", + "@hono/zod-openapi": "^0.15.3", + "@hono/zod-validator": "^0.2.2", + "@iconify-icons/line-md": "^1.2.30", + "@iconify-icons/mdi": "^1.2.48", + "@inlang/paraglide-sveltekit": "^0.11.1", + "@internationalized/date": "^3.6.0", + "@lucia-auth/adapter-drizzle": "^1.1.0", + "@lukeed/uuid": "^2.0.1", + "@needle-di/core": "^0.8.4", + "@neondatabase/serverless": "^0.9.5", + "@node-rs/argon2": "^1.8.3", + "@oslojs/binary": "^1.0.0", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "@oslojs/jwt": "^0.2.0", + "@oslojs/oauth2": "^0.5.0", + "@oslojs/otp": "^1.0.0", + "@oslojs/webauthn": "^1.0.0", + "@paralleldrive/cuid2": "^2.2.2", + "@scalar/hono-api-reference": "^0.5.162", + "@sveltejs/adapter-node": "^5.2.9", + "@sveltejs/adapter-vercel": "^5.4.8", + "@types/feather-icons": "^4.29.4", + "argon2": "^0.41.1", + "boardgamegeekclient": "^1.9.1", + "bullmq": "^5.29.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cookie": "^1.0.2", + "dayjs": "^1.11.13", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.7", + "drizzle-orm": "^0.36.4", + "drizzle-zod": "^0.5.1", + "feather-icons": "^4.29.2", + "handlebars": "^4.7.8", + "hono": "^4.6.12", + "hono-pino": "^0.7.0", + "hono-rate-limiter": "^0.4.0", + "hono-zod-openapi": "^0.5.0", + "html-entities": "^2.5.2", + "iconify-icon": "^2.1.0", + "ioredis": "^5.4.1", + "just-capitalize": "^3.2.0", + "just-kebab-case": "^4.2.0", + "loader": "^2.1.1", + "nanoid": "^5.0.9", + "open-props": "^1.7.7", + "pg": "^8.13.1", + "pino": "^9.5.0", + "pino-pretty": "^11.3.0", + "postgres": "^3.4.5", + "qrcode": "^1.5.4", + "radix-svelte": "^0.9.0", + "rate-limit-redis": "^4.2.0", + "reflect-metadata": "^0.2.2", + "stoker": "^1.4.2", + "svelte-lazy-loader": "^1.0.0", + "tailwind-merge": "^2.5.5", + "tailwind-variants": "^0.2.1", + "tailwindcss-animate": "^1.0.7", + "zod-to-json-schema": "^3.23.5" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d20ead..5c3f34e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,13 +13,13 @@ importers: version: 5.1.0 '@hono/swagger-ui': specifier: ^0.4.1 - version: 0.4.1(hono@4.6.11) + version: 0.4.1(hono@4.6.12) '@hono/zod-openapi': specifier: ^0.15.3 - version: 0.15.3(hono@4.6.11)(zod@3.23.8) + version: 0.15.3(hono@4.6.12)(zod@3.23.8) '@hono/zod-validator': specifier: ^0.2.2 - version: 0.2.2(hono@4.6.11)(zod@3.23.8) + version: 0.2.2(hono@4.6.12)(zod@3.23.8) '@iconify-icons/line-md': specifier: ^1.2.30 version: 1.2.30 @@ -28,7 +28,7 @@ importers: version: 1.2.48 '@inlang/paraglide-sveltekit': specifier: ^0.11.1 - version: 0.11.5(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))) + version: 0.11.5(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))) '@internationalized/date': specifier: ^3.6.0 version: 3.6.0 @@ -72,17 +72,20 @@ importers: specifier: ^2.2.2 version: 2.2.2 '@scalar/hono-api-reference': - specifier: ^0.5.161 - version: 0.5.161(hono@4.6.11) + specifier: ^0.5.162 + version: 0.5.162(hono@4.6.12) '@sveltejs/adapter-node': specifier: ^5.2.9 - version: 5.2.9(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))) + version: 5.2.9(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))) '@sveltejs/adapter-vercel': specifier: ^5.4.8 - version: 5.4.8(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))) + version: 5.4.8(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))) '@types/feather-icons': specifier: ^4.29.4 version: 4.29.4 + argon2: + specifier: ^0.41.1 + version: 0.41.1 boardgamegeekclient: specifier: ^1.9.1 version: 1.9.1 @@ -90,14 +93,17 @@ importers: specifier: ^5.29.1 version: 5.29.1 class-variance-authority: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.7.1 + version: 0.7.1 clsx: specifier: ^2.1.1 version: 2.1.1 cookie: specifier: ^1.0.2 version: 1.0.2 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -117,17 +123,17 @@ importers: specifier: ^4.7.8 version: 4.7.8 hono: - specifier: ^4.6.11 - version: 4.6.11 + specifier: ^4.6.12 + version: 4.6.12 hono-pino: specifier: ^0.7.0 - version: 0.7.0(hono@4.6.11)(pino@9.5.0) + version: 0.7.0(hono@4.6.12)(pino@9.5.0) hono-rate-limiter: specifier: ^0.4.0 - version: 0.4.0(hono@4.6.11) + version: 0.4.0(hono@4.6.12) hono-zod-openapi: specifier: ^0.5.0 - version: 0.5.0(hono@4.6.11)(zod@3.23.8) + version: 0.5.0(hono@4.6.12)(zod@3.23.8) html-entities: specifier: ^2.5.2 version: 2.5.2 @@ -147,14 +153,11 @@ importers: specifier: ^2.1.1 version: 2.1.1 nanoid: - specifier: ^5.0.8 - version: 5.0.8 + specifier: ^5.0.9 + version: 5.0.9 open-props: specifier: ^1.7.7 version: 1.7.7 - oslo: - specifier: ^1.2.1 - version: 1.2.1 pg: specifier: ^8.13.1 version: 8.13.1 @@ -180,8 +183,8 @@ importers: specifier: ^0.2.2 version: 0.2.2 stoker: - specifier: ^1.3.0 - version: 1.3.0(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.11)(zod@3.23.8))(hono@4.6.11)(openapi3-ts@4.4.0) + specifier: ^1.4.2 + version: 1.4.2(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.12)(zod@3.23.8))(hono@4.6.12)(openapi3-ts@4.4.0) svelte-lazy-loader: specifier: ^1.0.0 version: 1.0.0 @@ -190,13 +193,10 @@ importers: version: 2.5.5 tailwind-variants: specifier: ^0.2.1 - version: 0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))) + version: 0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))) - tsyringe: - specifier: ^4.8.0 - version: 4.8.0 + version: 1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))) zod-to-json-schema: specifier: ^3.23.5 version: 3.23.5(zod@3.23.8) @@ -218,22 +218,22 @@ importers: version: 1.49.0 '@sveltejs/adapter-auto': specifier: ^3.3.1 - version: 3.3.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))) + version: 3.3.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))) '@sveltejs/enhanced-img': specifier: ^0.3.10 - version: 0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + version: 0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) '@sveltejs/kit': - specifier: ^2.8.2 - version: 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + specifier: ^2.8.5 + version: 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) '@sveltejs/vite-plugin-svelte': specifier: 4.0.0-next.7 - version: 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + version: 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) '@types/cookie': specifier: ^0.6.0 version: 0.6.0 '@types/node': - specifier: ^20.17.7 - version: 20.17.7 + specifier: ^20.17.8 + version: 20.17.8 '@types/pg': specifier: ^8.11.10 version: 8.11.10 @@ -247,8 +247,8 @@ importers: specifier: ^7.18.0 version: 7.18.0(eslint@8.57.1)(typescript@5.7.2) arctic: - specifier: ^1.9.2 - version: 1.9.2 + specifier: ^2.3.0 + version: 2.3.0 autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) @@ -260,16 +260,13 @@ importers: version: 0.27.2 formsnap: specifier: ^1.0.1 - version: 1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)) + version: 1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)) just-clone: specifier: ^6.2.0 version: 6.2.0 just-debounce-it: specifier: ^3.2.0 version: 3.2.0 - lucia: - specifier: 3.2.0 - version: 3.2.0 lucide-svelte: specifier: ^0.408.0 version: 0.408.0(svelte@5.0.0-next.175) @@ -292,11 +289,11 @@ importers: specifier: ^9.6.0 version: 9.6.0(postcss@8.4.49) prettier: - specifier: ^3.3.3 - version: 3.3.3 + specifier: ^3.4.1 + version: 3.4.1 prettier-plugin-svelte: specifier: ^3.3.2 - version: 3.3.2(prettier@3.3.3)(svelte@5.0.0-next.175) + version: 3.3.2(prettier@3.4.1)(svelte@5.0.0-next.175) svelte: specifier: 5.0.0-next.175 version: 5.0.0-next.175 @@ -320,16 +317,16 @@ importers: version: 0.3.28(svelte@5.0.0-next.175) sveltekit-flash-message: specifier: ^2.4.4 - version: 2.4.4(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175) + version: 2.4.4(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175) sveltekit-superforms: specifier: ^2.20.1 - version: 2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2) + version: 2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2) tailwindcss: specifier: ^3.4.15 - version: 3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)) + version: 3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.17.7)(typescript@5.7.2) + version: 10.9.2(@types/node@20.17.8)(typescript@5.7.2) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -341,10 +338,10 @@ importers: version: 5.7.2 vite: specifier: ^5.4.11 - version: 5.4.11(@types/node@20.17.7) + version: 5.4.11(@types/node@20.17.8) vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.17.7) + version: 1.6.0(@types/node@20.17.8) zod: specifier: ^3.23.8 version: 3.23.8 @@ -2211,6 +2208,10 @@ packages: '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2347,8 +2348,8 @@ packages: cpu: [x64] os: [win32] - '@scalar/hono-api-reference@0.5.161': - resolution: {integrity: sha512-n30VVZrl9z/IcRbyNZZLGx4RMUUH1Zk12ryR1zPG/tdkzNG7ODP6D+rhasn5cSaCg/0BvdCcHEaANEwVJUEjqw==} + '@scalar/hono-api-reference@0.5.162': + resolution: {integrity: sha512-WoC6lLXLYSB6OxDuybtYe+5/EdU0M33DPsknjRsOImp+L6Qn64OZvKwyf7033rEHWvqqpwvEB9r+2zZ4F6KhoQ==} engines: {node: '>=18'} peerDependencies: hono: ^4.0.0 @@ -2357,8 +2358,8 @@ packages: resolution: {integrity: sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw==} engines: {node: '>=18'} - '@scalar/types@0.0.21': - resolution: {integrity: sha512-HR8KeV5zjdVDQdfs7ONhpIRbAzrMS1KIu2sbNgVXtFSaj+1/6WN5gM/yY1DKoaC3oyvpqwd/HyL9rLTqrOkrRw==} + '@scalar/types@0.0.22': + resolution: {integrity: sha512-+S1flivP58p2uiHM4dU5ZaAb20wbVcP0nV39KWoVjijvHDx1HWtAGg+PaDXRCRj2zM4QzBeg4olaso20Tm26fQ==} engines: {node: '>=18'} '@sideway/address@4.1.5': @@ -2400,8 +2401,8 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 vite: '>= 5.0.0' - '@sveltejs/kit@2.8.2': - resolution: {integrity: sha512-c9My0AnojYtaa96XDAcxcMUdMd3iIhWfrj6BLNtOFz55lMtA/Jima54ZLcYcvfMqei3c86fGRXYa2aIHO+vzFg==} + '@sveltejs/kit@2.8.5': + resolution: {integrity: sha512-5ry1jPd4r9knsphDK2eTYUFPhFZMqF0PHFfa8MdMQCqWaKwLSXdFMU/Vevih1I7C1/VNB5MvTuFl1kXu5vx8UA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -2466,8 +2467,8 @@ packages: '@types/jsonwebtoken@9.0.7': resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} - '@types/node@20.17.7': - resolution: {integrity: sha512-sZXXnpBFMKbao30dUAvzKbdwA2JM1fwUtVEq/kxKuPI5mMwZiRElCpTXb0Biq/LMEVpXDZL5G5V0RPnxKeyaYg==} + '@types/node@20.17.8': + resolution: {integrity: sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA==} '@types/pg@8.11.10': resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} @@ -2564,8 +2565,8 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@unhead/schema@1.11.11': - resolution: {integrity: sha512-xSGsWHPBYcMV/ckQeImbrVu6ddeRnrdDCgXUKv3xIjGBY+ob/96V80lGX8FKWh8GwdFSwhblISObKlDAt5K9ZQ==} + '@unhead/schema@1.11.13': + resolution: {integrity: sha512-fIpQx6GCpl99l4qJXsPqkXxO7suMccuLADbhaMSkeXnVEi4ZIle+l+Ri0z+GHAEpJj17FMaQdO5n9FMSOMUxkw==} '@vercel/nft@0.27.4': resolution: {integrity: sha512-Rioz3LJkEKicKCi9BSyc1RXZ5R6GmXosFMeBSThh6msWSOiArKhb7c75MiWwZEgPL7x0/l3TAfH/l0cxKNuUFA==} @@ -2680,8 +2681,8 @@ packages: aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} - arctic@1.9.2: - resolution: {integrity: sha512-VTnGpYx+ypboJdNrWnK17WeD7zN/xSCHnpecd5QYsBfVZde/5i+7DJ1wrf/ioSDMiEjagXmyNWAE3V2C9f1hNg==} + arctic@2.3.0: + resolution: {integrity: sha512-ImueY1iKm044nMVxQGsLvzSFLrLsqCIpsvohZprK2l8o3ypXjoSKiMBlxBBdoFpAG0iC78cJ6J/vyLpvdLQlkw==} are-we-there-yet@2.0.0: resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} @@ -2694,6 +2695,10 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argon2@0.41.1: + resolution: {integrity: sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==} + engines: {node: '>=16.17.0'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2860,8 +2865,8 @@ packages: class-validator@0.14.1: resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} - class-variance-authority@0.7.0: - resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -2876,10 +2881,6 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - clsx@2.0.0: - resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} - engines: {node: '>=6'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3662,8 +3663,8 @@ packages: hono: ^4.6.10 zod: ^3.21.4 - hono@4.6.11: - resolution: {integrity: sha512-f0LwJQFKdUUrCUAVowxSvNCjyzI7ZLt8XWYU/EApyeq5FfOvHFarBaE5rjU9HTNFk4RI0FkdB2edb3p/7xZjzQ==} + hono@4.6.12: + resolution: {integrity: sha512-eHtf4kSDNw6VVrdbd5IQi16r22m3s7mWPLd7xOMhg1a/Yyb1A0qpUFq8xYMX4FMuDe1nTKeMX5rTx7Nmw+a+Ag==} engines: {node: '>=16.9.0'} hookable@5.5.3: @@ -4091,13 +4092,13 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + nanoid@3.3.8: + resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.0.8: - resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==} + nanoid@5.0.9: + resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==} engines: {node: ^18 || >=20} hasBin: true @@ -4114,6 +4115,10 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.2.2: + resolution: {integrity: sha512-9emqXAKhVoNrQ792nLI/wpzPpJ/bj/YXxW0CvAau1+RdGBcCRF1Dmz7719zgVsQNrzHl9Tzn3ImZ4qWFarWL0A==} + engines: {node: ^18 || ^20 || >= 21} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -4210,9 +4215,6 @@ packages: oslo@1.2.0: resolution: {integrity: sha512-OoFX6rDsNcOQVAD2gQD/z03u4vEjWZLzJtwkmgfRF+KpQUXwdgEXErD7zNhyowmHwHefP+PM9Pw13pgpHMRlzw==} - oslo@1.2.1: - resolution: {integrity: sha512-HfIhB5ruTdQv0XX2XlncWQiJ5SIHZ7NHZhVyHth0CSZ/xzge00etRyYy/3wp/Dsu+PkxMC+6+B2lS/GcKoewkA==} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -4657,8 +4659,8 @@ packages: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - prettier@3.3.3: - resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + prettier@3.4.1: + resolution: {integrity: sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==} engines: {node: '>=14'} hasBin: true @@ -4948,11 +4950,11 @@ packages: std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} - stoker@1.3.0: - resolution: {integrity: sha512-ywRokjO8jKb65z6qJVJbzilQJzcoly8/bwyodp6sZC0ZQmn/zfDCOkjcw6hhNKWE8eSVGanZUIXEPab5MTxnmA==} + stoker@1.4.2: + resolution: {integrity: sha512-zna86ZzC3fnMOIkuO+1vRMfcRw7SpC/7yafRb0u8DwDVig2pPh6POVnGB7t2A5t/rMvyr7hE7tjXTPvW8bhJKg==} peerDependencies: '@asteasolutions/zod-to-openapi': ^7.0.0 - '@hono/zod-openapi': ^0.16.0 + '@hono/zod-openapi': '>=0.16.0' hono: ^4.0.0 openapi3-ts: ^4.4.0 peerDependenciesMeta: @@ -5263,9 +5265,6 @@ packages: '@swc/wasm': optional: true - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.4.0: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} @@ -5280,10 +5279,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tsyringe@4.8.0: - resolution: {integrity: sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==} - engines: {node: '>= 6.0.0'} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6328,25 +6323,25 @@ snapshots: '@hapi/hoek': 9.3.0 optional: true - '@hono/swagger-ui@0.4.1(hono@4.6.11)': + '@hono/swagger-ui@0.4.1(hono@4.6.12)': dependencies: - hono: 4.6.11 + hono: 4.6.12 - '@hono/zod-openapi@0.15.3(hono@4.6.11)(zod@3.23.8)': + '@hono/zod-openapi@0.15.3(hono@4.6.12)(zod@3.23.8)': dependencies: '@asteasolutions/zod-to-openapi': 7.1.2(zod@3.23.8) - '@hono/zod-validator': 0.2.2(hono@4.6.11)(zod@3.23.8) - hono: 4.6.11 + '@hono/zod-validator': 0.2.2(hono@4.6.12)(zod@3.23.8) + hono: 4.6.12 zod: 3.23.8 - '@hono/zod-validator@0.2.2(hono@4.6.11)(zod@3.23.8)': + '@hono/zod-validator@0.2.2(hono@4.6.12)(zod@3.23.8)': dependencies: - hono: 4.6.11 + hono: 4.6.12 zod: 3.23.8 - '@hono/zod-validator@0.4.1(hono@4.6.11)(zod@3.23.8)': + '@hono/zod-validator@0.4.1(hono@4.6.12)(zod@3.23.8)': dependencies: - hono: 4.6.11 + hono: 4.6.12 zod: 3.23.8 '@humanwhocodes/config-array@0.13.0': @@ -6490,12 +6485,12 @@ snapshots: - babel-plugin-macros - debug - '@inlang/paraglide-sveltekit@0.11.5(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))': + '@inlang/paraglide-sveltekit@0.11.5(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))': dependencies: '@inlang/paraglide-js': 1.11.3 '@inlang/paraglide-vite': 1.2.76 '@lix-js/client': 2.2.1 - '@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) commander: 12.1.0 dedent: 1.5.1 devalue: 4.3.3 @@ -6671,7 +6666,7 @@ snapshots: '@internationalized/date': 3.6.0 dequal: 2.0.3 focus-trap: 7.6.0 - nanoid: 5.0.8 + nanoid: 5.0.9 svelte: 5.0.0-next.175 '@melt-ui/svelte@0.83.0(svelte@5.0.0-next.175)': @@ -6681,7 +6676,7 @@ snapshots: '@internationalized/date': 3.6.0 dequal: 2.0.3 focus-trap: 7.6.0 - nanoid: 5.0.8 + nanoid: 5.0.9 svelte: 5.0.0-next.175 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': @@ -7128,6 +7123,8 @@ snapshots: dependencies: '@noble/hashes': 1.5.0 + '@phc/format@1.0.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -7229,17 +7226,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.24.0': optional: true - '@scalar/hono-api-reference@0.5.161(hono@4.6.11)': + '@scalar/hono-api-reference@0.5.162(hono@4.6.12)': dependencies: - '@scalar/types': 0.0.21 - hono: 4.6.11 + '@scalar/types': 0.0.22 + hono: 4.6.12 '@scalar/openapi-types@0.1.5': {} - '@scalar/types@0.0.21': + '@scalar/types@0.0.22': dependencies: '@scalar/openapi-types': 0.1.5 - '@unhead/schema': 1.11.11 + '@unhead/schema': 1.11.13 '@sideway/address@4.1.5': dependencies: @@ -7259,41 +7256,41 @@ snapshots: '@sinclair/typebox@0.32.35': optional: true - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))': dependencies: - '@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) import-meta-resolve: 4.1.0 - '@sveltejs/adapter-node@5.2.9(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))': + '@sveltejs/adapter-node@5.2.9(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))': dependencies: '@rollup/plugin-commonjs': 28.0.1(rollup@4.24.0) '@rollup/plugin-json': 6.1.0(rollup@4.24.0) '@rollup/plugin-node-resolve': 15.3.0(rollup@4.24.0) - '@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) rollup: 4.24.0 - '@sveltejs/adapter-vercel@5.4.8(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))': + '@sveltejs/adapter-vercel@5.4.8(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))': dependencies: - '@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) '@vercel/nft': 0.27.4 esbuild: 0.21.5 transitivePeerDependencies: - encoding - supports-color - '@sveltejs/enhanced-img@0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))': + '@sveltejs/enhanced-img@0.3.10(rollup@4.24.0)(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))': dependencies: magic-string: 0.30.11 svelte: 5.0.0-next.175 svelte-parse-markup: 0.1.5(svelte@5.0.0-next.175) - vite: 5.4.11(@types/node@20.17.7) + vite: 5.4.11(@types/node@20.17.8) vite-imagetools: 7.0.4(rollup@4.24.0) transitivePeerDependencies: - rollup - '@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))': + '@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))': dependencies: - '@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) '@types/cookie': 0.6.0 cookie: 0.6.0 devalue: 5.1.1 @@ -7307,27 +7304,27 @@ snapshots: sirv: 3.0.0 svelte: 5.0.0-next.175 tiny-glob: 0.2.9 - vite: 5.4.11(@types/node@20.17.7) + vite: 5.4.11(@types/node@20.17.8) - '@sveltejs/vite-plugin-svelte-inspector@3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))': + '@sveltejs/vite-plugin-svelte-inspector@3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))': dependencies: - '@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/vite-plugin-svelte': 4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) debug: 4.3.7 svelte: 5.0.0-next.175 - vite: 5.4.11(@types/node@20.17.7) + vite: 5.4.11(@types/node@20.17.8) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7))': + '@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/vite-plugin-svelte-inspector': 3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) debug: 4.3.7 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.11 svelte: 5.0.0-next.175 - vite: 5.4.11(@types/node@20.17.7) - vitefu: 1.0.2(vite@5.4.11(@types/node@20.17.7)) + vite: 5.4.11(@types/node@20.17.8) + vitefu: 1.0.2(vite@5.4.11(@types/node@20.17.8)) transitivePeerDependencies: - supports-color @@ -7368,21 +7365,21 @@ snapshots: '@types/jsonwebtoken@9.0.7': dependencies: - '@types/node': 20.17.7 + '@types/node': 20.17.8 - '@types/node@20.17.7': + '@types/node@20.17.8': dependencies: undici-types: 6.19.8 '@types/pg@8.11.10': dependencies: - '@types/node': 20.17.7 + '@types/node': 20.17.8 pg-protocol: 1.7.0 pg-types: 4.0.2 '@types/pg@8.11.6': dependencies: - '@types/node': 20.17.7 + '@types/node': 20.17.8 pg-protocol: 1.7.0 pg-types: 4.0.2 @@ -7390,7 +7387,7 @@ snapshots: '@types/qrcode@1.5.5': dependencies: - '@types/node': 20.17.7 + '@types/node': 20.17.8 '@types/resolve@1.20.2': {} @@ -7494,7 +7491,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@unhead/schema@1.11.11': + '@unhead/schema@1.11.13': dependencies: hookable: 5.5.3 zhead: 2.2.4 @@ -7633,9 +7630,11 @@ snapshots: aproba@2.0.0: {} - arctic@1.9.2: + arctic@2.3.0: dependencies: - oslo: 1.2.0 + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 are-we-there-yet@2.0.0: dependencies: @@ -7646,6 +7645,12 @@ snapshots: arg@5.0.2: {} + argon2@0.41.1: + dependencies: + '@phc/format': 1.0.0 + node-addon-api: 8.2.2 + node-gyp-build: 4.8.2 + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -7706,7 +7711,7 @@ snapshots: dependencies: '@internationalized/date': 3.6.0 '@melt-ui/svelte': 0.76.2(svelte@5.0.0-next.175) - nanoid: 5.0.8 + nanoid: 5.0.9 svelte: 5.0.0-next.175 boardgamegeekclient@1.9.1: @@ -7844,9 +7849,9 @@ snapshots: validator: 13.12.0 optional: true - class-variance-authority@0.7.0: + class-variance-authority@0.7.1: dependencies: - clsx: 2.0.0 + clsx: 2.1.1 classnames@2.5.1: {} @@ -7860,8 +7865,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - clsx@2.0.0: {} - clsx@2.1.1: {} cluster-key-slot@1.1.2: {} @@ -7977,8 +7980,7 @@ snapshots: dateformat@4.6.3: {} - dayjs@1.11.13: - optional: true + dayjs@1.11.13: {} debug@2.6.9: dependencies: @@ -8500,11 +8502,11 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 - formsnap@1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)): + formsnap@1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2)): dependencies: - nanoid: 5.0.8 + nanoid: 5.0.9 svelte: 5.0.0-next.175 - sveltekit-superforms: 2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2) + sveltekit-superforms: 2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2) forwarded@0.2.0: {} @@ -8639,24 +8641,24 @@ snapshots: help-me@5.0.0: {} - hono-pino@0.7.0(hono@4.6.11)(pino@9.5.0): + hono-pino@0.7.0(hono@4.6.12)(pino@9.5.0): dependencies: defu: 6.1.4 - hono: 4.6.11 + hono: 4.6.12 pino: 9.5.0 - hono-rate-limiter@0.4.0(hono@4.6.11): + hono-rate-limiter@0.4.0(hono@4.6.12): dependencies: - hono: 4.6.11 + hono: 4.6.12 - hono-zod-openapi@0.5.0(hono@4.6.11)(zod@3.23.8): + hono-zod-openapi@0.5.0(hono@4.6.12)(zod@3.23.8): dependencies: - '@hono/zod-validator': 0.4.1(hono@4.6.11)(zod@3.23.8) - hono: 4.6.11 + '@hono/zod-validator': 0.4.1(hono@4.6.12)(zod@3.23.8) + hono: 4.6.12 zod: 3.23.8 zod-openapi: 4.0.0(zod@3.23.8) - hono@4.6.11: {} + hono@4.6.12: {} hookable@5.5.3: {} @@ -9056,9 +9058,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.7: {} + nanoid@3.3.8: {} - nanoid@5.0.8: {} + nanoid@5.0.9: {} natural-compare@1.4.0: {} @@ -9068,6 +9070,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@8.2.2: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -9160,11 +9164,6 @@ snapshots: '@node-rs/argon2': 1.7.0 '@node-rs/bcrypt': 1.9.0 - oslo@1.2.1: - dependencies: - '@node-rs/argon2': 1.7.0 - '@node-rs/bcrypt': 1.9.0 - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -9460,13 +9459,13 @@ snapshots: '@csstools/utilities': 1.0.0(postcss@8.4.49) postcss: 8.4.49 - postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)): + postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)): dependencies: lilconfig: 3.1.2 yaml: 2.5.1 optionalDependencies: postcss: 8.4.49 - ts-node: 10.9.2(@types/node@20.17.7)(typescript@5.7.2) + ts-node: 10.9.2(@types/node@20.17.8)(typescript@5.7.2) postcss-load-config@5.1.0(jiti@1.21.6)(postcss@8.4.49)(tsx@4.19.2): dependencies: @@ -9600,7 +9599,7 @@ snapshots: postcss@8.4.49: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.8 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9637,12 +9636,12 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-svelte@3.3.2(prettier@3.3.3)(svelte@5.0.0-next.175): + prettier-plugin-svelte@3.3.2(prettier@3.4.1)(svelte@5.0.0-next.175): dependencies: - prettier: 3.3.3 + prettier: 3.4.1 svelte: 5.0.0-next.175 - prettier@3.3.3: {} + prettier@3.4.1: {} pretty-format@29.7.0: dependencies: @@ -9965,13 +9964,13 @@ snapshots: std-env@3.7.0: {} - stoker@1.3.0(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.11)(zod@3.23.8))(hono@4.6.11)(openapi3-ts@4.4.0): + stoker@1.4.2(@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8))(@hono/zod-openapi@0.15.3(hono@4.6.12)(zod@3.23.8))(hono@4.6.12)(openapi3-ts@4.4.0): dependencies: '@asteasolutions/zod-to-openapi': 7.1.2(zod@3.23.8) - hono: 4.6.11 + hono: 4.6.12 openapi3-ts: 4.4.0 optionalDependencies: - '@hono/zod-openapi': 0.15.3(hono@4.6.11)(zod@3.23.8) + '@hono/zod-openapi': 0.15.3(hono@4.6.12)(zod@3.23.8) string-width@4.2.3: dependencies: @@ -10146,14 +10145,14 @@ snapshots: magic-string: 0.30.11 zimmerframe: 1.1.2 - sveltekit-flash-message@2.4.4(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175): + sveltekit-flash-message@2.4.4(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175): dependencies: - '@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) svelte: 5.0.0-next.175 - sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2): + sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.7.2): dependencies: - '@sveltejs/kit': 2.8.2(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.7)) + '@sveltejs/kit': 2.8.5(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.8)) devalue: 5.1.1 just-clone: 6.2.0 memoize-weak: 1.0.2 @@ -10184,16 +10183,16 @@ snapshots: tailwind-merge@2.5.5: {} - tailwind-variants@0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))): + tailwind-variants@0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))): dependencies: tailwind-merge: 2.5.5 - tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)) + tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2))): + tailwindcss-animate@1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2))): dependencies: - tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)) + tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)) - tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)): + tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -10212,7 +10211,7 @@ snapshots: postcss: 8.4.49 postcss-import: 15.1.0(postcss@8.4.49) postcss-js: 4.0.1(postcss@8.4.49) - postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2)) + postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2)) postcss-nested: 6.2.0(postcss@8.4.49) postcss-selector-parser: 6.1.2 resolve: 1.22.8 @@ -10283,14 +10282,14 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@types/node@20.17.7)(typescript@5.7.2): + ts-node@10.9.2(@types/node@20.17.8)(typescript@5.7.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.17.7 + '@types/node': 20.17.8 acorn: 8.12.1 acorn-walk: 8.3.4 arg: 4.1.3 @@ -10301,8 +10300,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - tslib@1.14.1: {} - tslib@2.4.0: optional: true @@ -10317,10 +10314,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tsyringe@4.8.0: - dependencies: - tslib: 1.14.1 - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -10403,13 +10396,13 @@ snapshots: transitivePeerDependencies: - rollup - vite-node@1.6.0(@types/node@20.17.7): + vite-node@1.6.0(@types/node@20.17.8): dependencies: cac: 6.7.14 debug: 4.3.7 pathe: 1.1.2 picocolors: 1.1.0 - vite: 5.4.11(@types/node@20.17.7) + vite: 5.4.11(@types/node@20.17.8) transitivePeerDependencies: - '@types/node' - less @@ -10421,20 +10414,20 @@ snapshots: - supports-color - terser - vite@5.4.11(@types/node@20.17.7): + vite@5.4.11(@types/node@20.17.8): dependencies: esbuild: 0.21.5 postcss: 8.4.49 rollup: 4.24.0 optionalDependencies: - '@types/node': 20.17.7 + '@types/node': 20.17.8 fsevents: 2.3.3 - vitefu@1.0.2(vite@5.4.11(@types/node@20.17.7)): + vitefu@1.0.2(vite@5.4.11(@types/node@20.17.8)): optionalDependencies: - vite: 5.4.11(@types/node@20.17.7) + vite: 5.4.11(@types/node@20.17.8) - vitest@1.6.0(@types/node@20.17.7): + vitest@1.6.0(@types/node@20.17.8): dependencies: '@vitest/expect': 1.6.0 '@vitest/runner': 1.6.0 @@ -10453,11 +10446,11 @@ snapshots: strip-literal: 2.1.0 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.11(@types/node@20.17.7) - vite-node: 1.6.0(@types/node@20.17.7) + vite: 5.4.11(@types/node@20.17.8) + vite-node: 1.6.0(@types/node@20.17.8) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.17.7 + '@types/node': 20.17.8 transitivePeerDependencies: - less - lightningcss diff --git a/src/app.d.ts b/src/app.d.ts index f38c290..0a3ca82 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,6 @@ import type { ApiClient } from '$lib/server/api'; import type { Users } from '$lib/server/api/databases/postgres/tables'; -import type { Session } from '$lib/server/api/services/sessions.service'; +import type { Session } from '$lib/server/api/iam/sessions/sessions.service'; import type { parseApiResponse } from '$lib/utils/api'; // See https://svelte.dev/docs/kit/types#app.d.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c3be7fa..0d552a5 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,46 +1,45 @@ -import "reflect-metadata"; -import { StatusCodes } from "$lib/constants/status-codes"; -import type { ApiRoutes } from "$lib/server/api"; -import { parseApiResponse } from "$lib/utils/api"; -import { type Handle, redirect } from "@sveltejs/kit"; -import { sequence } from "@sveltejs/kit/hooks"; -import { hc } from "hono/client"; -import { i18n } from "$lib/i18n"; +import { i18n } from '$lib/i18n'; +import type { ApiRoutes } from '$lib/server/api'; +import { parseApiResponse } from '$lib/utils/api'; +import { StatusCodes } from '$lib/utils/status-codes'; +import { type Handle, redirect } from '@sveltejs/kit'; +import { sequence } from '@sveltejs/kit/hooks'; +import { hc } from 'hono/client'; const handleParaglide: Handle = i18n.handle(); const apiClient: Handle = async ({ event, resolve }) => { - /* ------------------------------ Register api ------------------------------ */ - const { api } = hc("/", { - fetch: event.fetch, - headers: { - "x-forwarded-for": event.url.host.includes("sveltekit-prerender") ? "127.0.0.1" : event.getClientAddress(), - host: event.request.headers.get("host") || "", - }, - }); + /* ------------------------------ Register api ------------------------------ */ + const { api } = hc('/', { + fetch: event.fetch, + headers: { + 'x-forwarded-for': event.url.host.includes('sveltekit-prerender') ? '127.0.0.1' : event.getClientAddress(), + host: event.request.headers.get('host') || '', + }, + }); - /* ----------------------------- Auth functions ----------------------------- */ - async function getAuthedUser() { - const { data } = await api.me.$get().then(parseApiResponse); - return { user: data?.user, session: data?.session }; - } + /* ----------------------------- Auth functions ----------------------------- */ + async function getAuthedUser() { + const { data } = await api.me.$get().then(parseApiResponse); + return { user: data?.user, session: data?.session }; + } - async function getAuthedUserOrThrow() { - const { data } = await api.me.$get().then(parseApiResponse); - if (!data || !data.user) { - throw redirect(StatusCodes.TEMPORARY_REDIRECT, "/"); - } - return data?.user; - } + async function getAuthedUserOrThrow() { + const { data } = await api.me.$get().then(parseApiResponse); + if (!data || !data.user) { + throw redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); + } + return data?.user; + } - /* ------------------------------ Set contexts ------------------------------ */ - event.locals.api = api; - event.locals.parseApiResponse = parseApiResponse; - event.locals.getAuthedUser = getAuthedUser; - event.locals.getAuthedUserOrThrow = getAuthedUserOrThrow; + /* ------------------------------ Set contexts ------------------------------ */ + event.locals.api = api; + event.locals.parseApiResponse = parseApiResponse; + event.locals.getAuthedUser = getAuthedUser; + event.locals.getAuthedUserOrThrow = getAuthedUserOrThrow; - /* ----------------------------- Return response ---------------------------- */ - return await resolve(event); + /* ----------------------------- Return response ---------------------------- */ + return await resolve(event); }; export const handle: Handle = sequence(apiClient, handleParaglide); diff --git a/src/lib/constants/status-codes.ts b/src/lib/constants/status-codes.ts deleted file mode 100644 index 1bcf271..0000000 --- a/src/lib/constants/status-codes.ts +++ /dev/null @@ -1,357 +0,0 @@ -// Taken from https://github.com/prettymuchbryce/http-status-codes/blob/master/src/status-codes.ts -export enum StatusCodes { - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.1 - * - * This interim response indicates that everything so far is OK and that the client should continue with the request or ignore it if it is already finished. - */ - CONTINUE = 100, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.2 - * - * This code is sent in response to an Upgrade request header by the client, and indicates the protocol the server is switching too. - */ - SWITCHING_PROTOCOLS = 101, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.1 - * - * This code indicates that the server has received and is processing the request, but no response is available yet. - */ - PROCESSING = 102, - /** - * Official Documentation @ https://www.rfc-editor.org/rfc/rfc8297#page-3 - * - * This code indicates to the client that the server is likely to send a final response with the header fields included in the informational response. - */ - EARLY_HINTS = 103, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.1 - * - * The request has succeeded. The meaning of a success varies depending on the HTTP method: - * GET: The resource has been fetched and is transmitted in the message body. - * HEAD: The entity headers are in the message body. - * POST: The resource describing the result of the action is transmitted in the message body. - * TRACE: The message body contains the request message as received by the server - */ - OK = 200, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.2 - * - * The request has succeeded and a new resource has been created as a result of it. This is typically the response sent after a PUT request. - */ - CREATED = 201, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.3 - * - * The request has been received but not yet acted upon. It is non-committal, meaning that there is no way in HTTP to later send an asynchronous response indicating the outcome of processing the request. It is intended for cases where another process or server handles the request, or for batch processing. - */ - ACCEPTED = 202, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.4 - * - * This response code means returned meta-information set is not exact set as available from the origin server, but collected from a local or a third party copy. Except this condition, 200 OK response should be preferred instead of this response. - */ - NON_AUTHORITATIVE_INFORMATION = 203, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.5 - * - * There is no content to send for this request, but the headers may be useful. The user-agent may update its cached headers for this resource with the new ones. - */ - NO_CONTENT = 204, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.6 - * - * This response code is sent after accomplishing request to tell user agent reset document view which sent this request. - */ - RESET_CONTENT = 205, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.1 - * - * This response code is used because of range header sent by the client to separate download into multiple streams. - */ - PARTIAL_CONTENT = 206, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.2 - * - * A Multi-Status response conveys information about multiple resources in situations where multiple status codes might be appropriate. - */ - MULTI_STATUS = 207, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.1 - * - * The request has more than one possible responses. User-agent or user should choose one of them. There is no standardized way to choose one of the responses. - */ - MULTIPLE_CHOICES = 300, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.2 - * - * This response code means that URI of requested resource has been changed. Probably, new URI would be given in the response. - */ - MOVED_PERMANENTLY = 301, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.3 - * - * This response code means that URI of requested resource has been changed temporarily. New changes in the URI might be made in the future. Therefore, this same URI should be used by the client in future requests. - */ - MOVED_TEMPORARILY = 302, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.4 - * - * Server sent this response to directing client to get requested resource to another URI with an GET request. - */ - SEE_OTHER = 303, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.1 - * - * This is used for caching purposes. It is telling to client that response has not been modified. So, client can continue to use same cached version of response. - */ - NOT_MODIFIED = 304, - /** - * @deprecated - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.6 - * - * Was defined in a previous version of the HTTP specification to indicate that a requested response must be accessed by a proxy. It has been deprecated due to security concerns regarding in-band configuration of a proxy. - */ - USE_PROXY = 305, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.7 - * - * Server sent this response to directing client to get requested resource to another URI with same method that used prior request. This has the same semantic than the 302 Found HTTP response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the second request. - */ - TEMPORARY_REDIRECT = 307, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7538#section-3 - * - * This means that the resource is now permanently located at another URI, specified by the Location: HTTP Response header. This has the same semantics as the 301 Moved Permanently HTTP response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the second request. - */ - PERMANENT_REDIRECT = 308, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.1 - * - * This response means that server could not understand the request due to invalid syntax. - */ - BAD_REQUEST = 400, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.1 - * - * Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response. - */ - UNAUTHORIZED = 401, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.2 - * - * This response code is reserved for future use. Initial aim for creating this code was using it for digital payment systems however this is not used currently. - */ - PAYMENT_REQUIRED = 402, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.3 - * - * The client does not have access rights to the content, i.e. they are unauthorized, so server is rejecting to give proper response. Unlike 401, the client's identity is known to the server. - */ - FORBIDDEN = 403, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.4 - * - * The server can not find requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 to hide the existence of a resource from an unauthorized client. This response code is probably the most famous one due to its frequent occurence on the web. - */ - NOT_FOUND = 404, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.5 - * - * The request method is known by the server but has been disabled and cannot be used. For example, an API may forbid DELETE-ing a resource. The two mandatory methods, GET and HEAD, must never be disabled and should not return this error code. - */ - METHOD_NOT_ALLOWED = 405, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.6 - * - * This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content following the criteria given by the user agent. - */ - NOT_ACCEPTABLE = 406, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.2 - * - * This is similar to 401 but authentication is needed to be done by a proxy. - */ - PROXY_AUTHENTICATION_REQUIRED = 407, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.7 - * - * This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection mechanisms to speed up surfing. Also note that some servers merely shut down the connection without sending this message. - */ - REQUEST_TIMEOUT = 408, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.8 - * - * This response is sent when a request conflicts with the current state of the server. - */ - CONFLICT = 409, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.9 - * - * This response would be sent when the requested content has been permenantly deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code. - */ - GONE = 410, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.10 - * - * The server rejected the request because the Content-Length header field is not defined and the server requires it. - */ - LENGTH_REQUIRED = 411, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.2 - * - * The client has indicated preconditions in its headers which the server does not meet. - */ - PRECONDITION_FAILED = 412, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.11 - * - * Request entity is larger than limits defined by server; the server might close the connection or return an Retry-After header field. - */ - REQUEST_TOO_LONG = 413, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.12 - * - * The URI requested by the client is longer than the server is willing to interpret. - */ - REQUEST_URI_TOO_LONG = 414, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.13 - * - * The media format of the requested data is not supported by the server, so the server is rejecting the request. - */ - UNSUPPORTED_MEDIA_TYPE = 415, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.4 - * - * The range specified by the Range header field in the request can't be fulfilled; it's possible that the range is outside the size of the target URI's data. - */ - REQUESTED_RANGE_NOT_SATISFIABLE = 416, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.14 - * - * This response code means the expectation indicated by the Expect request header field can't be met by the server. - */ - EXPECTATION_FAILED = 417, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2324#section-2.3.2 - * - * Any attempt to brew coffee with a teapot should result in the error code "418 I'm a teapot". The resulting entity body MAY be short and stout. - */ - IM_A_TEAPOT = 418, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6 - * - * The 507 (Insufficient Storage) status code means the method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request. This condition is considered to be temporary. If the request which received this status code was the result of a user action, the request MUST NOT be repeated until it is requested by a separate user action. - */ - INSUFFICIENT_SPACE_ON_RESOURCE = 419, - /** - * @deprecated - * Official Documentation @ https://tools.ietf.org/rfcdiff?difftype=--hwdiff&url2=draft-ietf-webdav-protocol-06.txt - * - * A deprecated response used by the Spring Framework when a method has failed. - */ - METHOD_FAILURE = 420, - /** - * Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7540#section-9.1.2 - * - * Defined in the specification of HTTP/2 to indicate that a server is not able to produce a response for the combination of scheme and authority that are included in the request URI. - */ - MISDIRECTED_REQUEST = 421, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.3 - * - * The request was well-formed but was unable to be followed due to semantic errors. - */ - UNPROCESSABLE_ENTITY = 422, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.4 - * - * The resource that is being accessed is locked. - */ - LOCKED = 423, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.5 - * - * The request failed due to failure of a previous request. - */ - FAILED_DEPENDENCY = 424, - /** - * Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.15 - * - * The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. - */ - UPGRADE_REQUIRED = 426, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-3 - * - * The origin server requires the request to be conditional. Intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict. - */ - PRECONDITION_REQUIRED = 428, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-4 - * - * The user has sent too many requests in a given amount of time ("rate limiting"). - */ - TOO_MANY_REQUESTS = 429, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-5 - * - * The server is unwilling to process the request because its header fields are too large. The request MAY be resubmitted after reducing the size of the request header fields. - */ - REQUEST_HEADER_FIELDS_TOO_LARGE = 431, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7725 - * - * The user-agent requested a resource that cannot legally be provided, such as a web page censored by a government. - */ - UNAVAILABLE_FOR_LEGAL_REASONS = 451, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.1 - * - * The server encountered an unexpected condition that prevented it from fulfilling the request. - */ - INTERNAL_SERVER_ERROR = 500, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2 - * - * The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that must not return this code) are GET and HEAD. - */ - NOT_IMPLEMENTED = 501, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.3 - * - * This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response. - */ - BAD_GATEWAY = 502, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.4 - * - * The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This responses should be used for temporary conditions and the Retry-After: HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached. - */ - SERVICE_UNAVAILABLE = 503, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.5 - * - * This error response is given when the server is acting as a gateway and cannot get a response in time. - */ - GATEWAY_TIMEOUT = 504, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.6 - * - * The HTTP version used in the request is not supported by the server. - */ - HTTP_VERSION_NOT_SUPPORTED = 505, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6 - * - * The server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process. - */ - INSUFFICIENT_STORAGE = 507, - /** - * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-6 - * - * The 511 status code indicates that the client needs to authenticate to gain network access. - */ - NETWORK_AUTHENTICATION_REQUIRED = 511 -} \ No newline at end of file diff --git a/src/lib/server/api/application.controller.ts b/src/lib/server/api/application.controller.ts new file mode 100644 index 0000000..ff70454 --- /dev/null +++ b/src/lib/server/api/application.controller.ts @@ -0,0 +1,74 @@ +import { CollectionController } from '$lib/server/api/collections/collection.controller'; +import { LoginController } from '$lib/server/api/login/login.controller'; +import { MfaController } from '$lib/server/api/mfa/mfa.controller'; +import { OAuthController } from '$lib/server/api/oauth/oauth.controller'; +import { SignupController } from '$lib/server/api/signup/signup.controller'; +import { WishlistController } from '$lib/server/api/wishlists/wishlist.controller'; +import { inject, injectable } from '@needle-di/core'; +import { contextStorage } from 'hono/context-storage'; +import { requestId } from 'hono/request-id'; +import { notFound, onError, serveEmojiFavicon } from 'stoker/middlewares'; +import { RootController } from './common/factories/controllers.factory'; +import { browserSessions } from './common/middleware/browser-session.middleware'; +import { pinoLogger } from './common/middleware/pino-logger.middleware'; +import { rateLimit } from './common/middleware/rate-limit.middleware'; +import { sessionManagement } from './common/middleware/session-management.middleware'; +import { generateId } from './common/utils/crypto'; +import configureOpenAPI from './configure-open-api'; +import { IamController } from './iam/iam.controller'; +import { UsersController } from './users/users.controller'; + +@injectable() +export class ApplicationController extends RootController { + constructor( + private loginController = inject(LoginController), + private oAuthController = inject(OAuthController), + private signupController = inject(SignupController), + private mfaController = inject(MfaController), + private iamController = inject(IamController), + private usersController = inject(UsersController), + private wishlistController = inject(WishlistController), + private collectionController = inject(CollectionController), + ) { + super(); + } + + routes() { + return this.controller + .get('/', (c) => { + return c.json({ status: 'ok' }); + }) + .get('/healthz', (c) => { + return c.json({ message: 'Server is healthy' }); + }) + .get('/rate-limit', rateLimit({ limit: 3, minutes: 1 }), (c) => { + return c.json({ message: 'Test!' }); + }); + } + + registerControllers() { + const app = this.controller; + app.onError(onError); + app.notFound(notFound); + app + .basePath('/api') + .use(requestId({ generator: () => generateId() })) + .use(contextStorage()) + .use(browserSessions) + .use(sessionManagement) + .use(serveEmojiFavicon('📝')) + .use(pinoLogger()) + .route('/', this.routes()) + .route('/iam', this.iamController.routes()) + .route('/users', this.usersController.routes()) + .route('/login', this.loginController.routes()) + .route('/oauth', this.oAuthController.routes()) + .route('/signup', this.signupController.routes()) + .route('/wishlists', this.wishlistController.routes()) + .route('/collections', this.collectionController.routes()) + .route('/mfa', this.mfaController.routes()); + + configureOpenAPI(app); + return app; + } +} diff --git a/src/lib/server/api/application.module.ts b/src/lib/server/api/application.module.ts new file mode 100644 index 0000000..0d9d887 --- /dev/null +++ b/src/lib/server/api/application.module.ts @@ -0,0 +1,42 @@ +import { inject, injectable } from '@needle-di/core'; +import { ApplicationController } from './application.controller'; +import { ConfigService } from './common/configs/config.service'; +// import { StorageService } from './storage/storage.service'; + +@injectable() +export class ApplicationModule { + constructor( + private applicationController = inject(ApplicationController), + private configService = inject(ConfigService), + // private storageService = inject(StorageService), + ) {} + + async app() { + return this.applicationController.registerControllers(); + } + + async start() { + const app = this.app(); + await this.onApplicationStartup(); + + // register shutdown hooks + process.on('SIGINT', this.onApplicationShutdown); + process.on('SIGTERM', this.onApplicationShutdown); + + console.log(`Api started on port ${this.configService.envs.PORT}`); + return app; + } + + private async onApplicationStartup() { + console.log('Application startup...'); + // validate configs + this.configService.validateEnvs(); + // configure storage service + // await this.storageService.configure(); + } + + private onApplicationShutdown() { + console.log('Shutting down...'); + process.exit(); + } +} diff --git a/src/lib/server/api/controllers/collection.controller.ts b/src/lib/server/api/collections/collection.controller.ts similarity index 79% rename from src/lib/server/api/controllers/collection.controller.ts rename to src/lib/server/api/collections/collection.controller.ts index 9cb0282..fa08472 100644 --- a/src/lib/server/api/controllers/collection.controller.ts +++ b/src/lib/server/api/collections/collection.controller.ts @@ -1,10 +1,10 @@ -import { StatusCodes } from '$lib/constants/status-codes'; +import { allCollections, getCollectionByCUID, numberOfCollections } from '$lib/server/api/collections/collection.routes'; +import { CollectionsService } from '$lib/server/api/collections/collections.service'; +import { requireFullAuth } from '$lib/server/api/common/middleware/require-auth.middleware'; import { Controller } from '$lib/server/api/common/types/controller'; -import { allCollections, getCollectionByCUID, numberOfCollections } from '$lib/server/api/controllers/collection.routes'; -import { CollectionsService } from '$lib/server/api/services/collections.service'; +import { StatusCodes } from '$lib/utils/status-codes'; +import { inject, injectable } from '@needle-di/core'; import { openApi } from 'hono-zod-openapi'; -import { injectable, inject } from '@needle-di/core'; -import { requireFullAuth } from '../middleware/require-auth.middleware'; @injectable() export class CollectionController extends Controller { diff --git a/src/lib/server/api/collections/collection.routes.ts b/src/lib/server/api/collections/collection.routes.ts new file mode 100644 index 0000000..669cf3a --- /dev/null +++ b/src/lib/server/api/collections/collection.routes.ts @@ -0,0 +1,68 @@ +import cuidParamsSchema from '$lib/server/api/common/openapi/cuidParamsSchema'; +import { unauthorizedSchema } from '$lib/server/api/common/utils/exceptions'; +import { StatusCodes } from '$lib/utils/status-codes'; +import { z } from '@hono/zod-openapi'; +import { createErrorSchema } from 'stoker/openapi/schemas'; +import { taggedAuthRoute } from '../common/openapi/create-auth-route'; +import { selectCollectionSchema } from '../databases/postgres/tables'; + +const tag = 'Collection'; + +export const allCollections = taggedAuthRoute(tag, { + responses: { + [StatusCodes.OK]: { + description: 'User profile', + schema: selectCollectionSchema, + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const numberOfCollections = taggedAuthRoute(tag, { + responses: { + [StatusCodes.OK]: { + description: 'User profile', + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const getCollectionByCUID = taggedAuthRoute(tag, { + request: { + param: { + schema: cuidParamsSchema, + example: { cuid: 'z6uiuc9qz82xjf5dexc5kr2d' }, + }, + }, + responses: { + [StatusCodes.OK]: { + description: 'User profile', + schema: selectCollectionSchema, + mediaType: 'application/json', + }, + [StatusCodes.NOT_FOUND]: { + description: 'The collection does not exist', + schema: z.object({ message: z.string() }).openapi({ + example: { + message: 'The collection does not exist', + }, + }), + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); diff --git a/src/lib/server/api/collections/collections.repository.ts b/src/lib/server/api/collections/collections.repository.ts new file mode 100644 index 0000000..ffb10d8 --- /dev/null +++ b/src/lib/server/api/collections/collections.repository.ts @@ -0,0 +1,83 @@ +import { takeFirstOrThrow } from '$lib/server/api/common/utils/repository'; +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, eq } from 'drizzle-orm'; +import { collections } from '../databases/postgres/tables'; + +export type CreateCollection = InferInsertModel; +export type UpdateCollection = Partial; + +@injectable() +export class CollectionsRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async findAll(db = this.drizzle.db) { + return db.query.collections.findMany(); + } + + async findOneById(id: string, db = this.drizzle.db) { + return db.query.collections.findFirst({ + where: eq(collections.id, id), + columns: { + cuid: true, + name: true, + }, + }); + } + + async findOneByCuid(cuid: string, db = this.drizzle.db) { + return db.query.collections.findFirst({ + where: eq(collections.cuid, cuid), + columns: { + cuid: true, + name: true, + }, + }); + } + + async findOneByUserId(userId: string, db = this.drizzle.db) { + return db.query.collections.findFirst({ + where: eq(collections.user_id, userId), + columns: { + cuid: true, + name: true, + }, + }); + } + + async findAllByUserId(userId: string, db = this.drizzle.db) { + return db.query.collections.findMany({ + where: eq(collections.user_id, userId), + columns: { + cuid: true, + name: true, + createdAt: true, + }, + }); + } + + async findAllByUserIdWithDetails(userId: string, db = this.drizzle.db) { + return db.query.collections.findMany({ + where: eq(collections.user_id, userId), + columns: { + cuid: true, + name: true, + }, + with: { + collection_items: { + columns: { + cuid: true, + }, + }, + }, + }); + } + + async create(data: CreateCollection, db = this.drizzle.db) { + return db.insert(collections).values(data).returning().then(takeFirstOrThrow); + } + + async update(id: string, data: UpdateCollection, db = this.drizzle.db) { + return db.update(collections).set(data).where(eq(collections.id, id)).returning().then(takeFirstOrThrow); + } +} diff --git a/src/lib/server/api/collections/collections.service.ts b/src/lib/server/api/collections/collections.service.ts new file mode 100644 index 0000000..e1c443b --- /dev/null +++ b/src/lib/server/api/collections/collections.service.ts @@ -0,0 +1,50 @@ +import type { db } from '$lib/server/api/packages/drizzle'; +import { generateRandomAnimalName } from '$lib/utils/randomDataUtil'; +import { inject, injectable } from '@needle-di/core'; +import { CollectionsRepository } from './collections.repository'; + +@injectable() +export class CollectionsService { + constructor(private collectionsRepository = inject(CollectionsRepository)) {} + + async findOneByUserId(userId: string) { + return this.collectionsRepository.findOneByUserId(userId); + } + + async findAllByUserId(userId: string) { + return this.collectionsRepository.findAllByUserId(userId); + } + + async findAllByUserIdWithDetails(userId: string) { + return this.collectionsRepository.findAllByUserIdWithDetails(userId); + } + + async findOneById(id: string) { + return this.collectionsRepository.findOneById(id); + } + + async findOneByCuid(cuid: string) { + return this.collectionsRepository.findOneByCuid(cuid); + } + + async createEmptyNoName(userId: string, trx: Parameters[0]>[0] | null = null) { + return this.createEmpty(userId, null, trx); + } + + async createEmpty(userId: string, name: string | null, trx: Parameters[0]>[0] | null = null) { + if (!trx) { + return this.collectionsRepository.create({ + user_id: userId, + name: name ?? generateRandomAnimalName(), + }); + } + + return this.collectionsRepository.create( + { + user_id: userId, + name: name ?? generateRandomAnimalName(), + }, + trx, + ); + } +} diff --git a/src/lib/server/api/common/configs/config.service.ts b/src/lib/server/api/common/configs/config.service.ts new file mode 100644 index 0000000..3a0a579 --- /dev/null +++ b/src/lib/server/api/common/configs/config.service.ts @@ -0,0 +1,32 @@ +import * as envs from '$env/static/private'; +import { injectable } from '@needle-di/core'; +import { z } from 'zod'; +import { type EnvsDto, envsDto } from './dtos/env.dto'; + +@injectable() +export class ConfigService { + envs: EnvsDto; + + constructor() { + // biome-ignore lint/style/noNonNullAssertion: + this.envs = this.parseEnvs()!; + } + + private parseEnvs() { + return envsDto.parse(envs); + } + + validateEnvs() { + try { + return envsDto.parse(envs); + } catch (err) { + if (err instanceof z.ZodError) { + const { fieldErrors } = err.flatten(); + const errorMessage = Object.entries(fieldErrors) + .map(([field, errors]) => (errors ? `${field}: ${errors.join(', ')}` : field)) + .join('\n '); + throw new Error(`Missing environment variables:\n ${errorMessage}`); + } + } + } +} diff --git a/src/lib/server/api/common/configs/dtos/env.dto.ts b/src/lib/server/api/common/configs/dtos/env.dto.ts new file mode 100644 index 0000000..2035915 --- /dev/null +++ b/src/lib/server/api/common/configs/dtos/env.dto.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +const stringBoolean = z.coerce + .string() + .transform((val) => { + return val === 'true'; + }) + .default('false'); + +export const envsDto = z.object({ + DATABASE_USER: z.string(), + DATABASE_PASSWORD: z.string(), + DATABASE_HOST: z.string(), + DATABASE_PORT: z.coerce.number(), + DATABASE_DB: z.string(), + DB_MIGRATING: stringBoolean, + DB_SEEDING: stringBoolean, + DOMAIN: z.string(), + ENCRYPTION_KEY: z.string(), + ENV: z.enum(['dev', 'prod']), + GITHUB_CLIENT_ID: z.string(), + GITHUB_CLIENT_SECRET: z.string(), + GOOGLE_CLIENT_ID: z.string(), + GOOGLE_CLIENT_SECRET: z.string(), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + NODE_ENV: z.string().default('development'), + ORIGIN: z.string(), + PORT: z.number({ coerce: true }).default(5173), + PUBLIC_SITE_NAME: z.string(), + PUBLIC_SITE_URL: z.string(), + PUBLIC_UMAMI_DO_NOT_TRACK: z.string().default('true'), + PUBLIC_UMAMI_ID: z.string(), + PUBLIC_UMAMI_URL: z.string(), + REDIS_URL: z.string(), + SIGNING_SECRET: z.string(), + TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000), +}); + +export type EnvsDto = z.infer; diff --git a/src/lib/server/api/common/create-app.ts b/src/lib/server/api/common/create-app.ts index e6bf29f..a6b19d6 100644 --- a/src/lib/server/api/common/create-app.ts +++ b/src/lib/server/api/common/create-app.ts @@ -1,39 +1,39 @@ -import env from "$lib/server/api/common/env"; -import type { AppBindings } from "$lib/server/api/common/types/hono"; -import { validateAuthSession, verifyOrigin } from "$lib/server/api/middleware/auth.middleware"; -import { pinoLogger } from "$lib/server/api/middleware/pino-logger.middleware"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { requestId } from "hono/request-id"; -import { notFound, onError, serveEmojiFavicon } from "stoker/middlewares"; -import { generateId } from "./utils/crypto"; +import env from '$lib/server/api/common/env'; +import { validateAuthSession, verifyOrigin } from '$lib/server/api/common/middleware/auth.middleware'; +import { pinoLogger } from '$lib/server/api/common/middleware/pino-logger.middleware'; +import type { AppBindings } from '$lib/server/api/common/types/hono'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { requestId } from 'hono/request-id'; +import { notFound, onError, serveEmojiFavicon } from 'stoker/middlewares'; +import { generateId } from './utils/crypto'; export function createRouter() { - return new Hono({ - strict: false, - }).basePath("/api"); + return new Hono({ + strict: false, + }).basePath('/api'); } export default function createApp() { - const app = createRouter(); + const app = createRouter(); - app.use(verifyOrigin).use(validateAuthSession); - app.use(serveEmojiFavicon("📝")); - app.use(requestId({ generator: () => generateId() })).use(pinoLogger()); + app.use(verifyOrigin).use(validateAuthSession); + app.use(serveEmojiFavicon('📝')); + app.use(requestId({ generator: () => generateId() })).use(pinoLogger()); - app.notFound(notFound); - app.onError(onError); + app.notFound(notFound); + app.onError(onError); - app.use( - "/*", - cors({ - origin: [env.ORIGIN], + app.use( + '/*', + cors({ + origin: [env.ORIGIN], - allowMethods: ["POST"], - allowHeaders: ["Content-Type"], - // credentials: true, // If you need to send cookies or HTTP authentication - }), - ); + allowMethods: ['POST'], + allowHeaders: ['Content-Type'], + // credentials: true, // If you need to send cookies or HTTP authentication + }), + ); - return app; + return app; } diff --git a/src/lib/server/api/common/env.ts b/src/lib/server/api/common/env.ts index d8df40b..59a7b17 100644 --- a/src/lib/server/api/common/env.ts +++ b/src/lib/server/api/common/env.ts @@ -1,6 +1,6 @@ import { config } from 'dotenv'; import { expand } from 'dotenv-expand'; -import { z, type ZodError } from 'zod'; +import { type ZodError, z } from 'zod'; expand(config()); @@ -37,9 +37,9 @@ const EnvSchema = z.object({ TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000), }); -export type env = z.infer; +export type EnvsDto = z.infer; -let env: env; +let env: EnvsDto; try { env = EnvSchema.parse(process.env); diff --git a/src/lib/server/api/common/factories/controllers.factory.ts b/src/lib/server/api/common/factories/controllers.factory.ts new file mode 100644 index 0000000..d8d0217 --- /dev/null +++ b/src/lib/server/api/common/factories/controllers.factory.ts @@ -0,0 +1,13 @@ +import { createHono } from '../utils/hono'; + +export abstract class Controller { + protected readonly controller: ReturnType; + constructor() { + this.controller = createHono(); + } + abstract routes(): ReturnType; +} + +export abstract class RootController extends Controller { + abstract registerControllers(): ReturnType; +} diff --git a/src/lib/server/api/common/factories/drizzle-repository.factory.ts b/src/lib/server/api/common/factories/drizzle-repository.factory.ts new file mode 100644 index 0000000..6318210 --- /dev/null +++ b/src/lib/server/api/common/factories/drizzle-repository.factory.ts @@ -0,0 +1,9 @@ +import { Container } from '@needle-di/core'; +import { DrizzleService } from '../../databases/postgres/drizzle.service'; + +export abstract class DrizzleRepository { + protected readonly drizzle: DrizzleService; + constructor() { + this.drizzle = new Container().get(DrizzleService); + } +} diff --git a/src/lib/server/api/common/factories/redis-repository.factory.ts b/src/lib/server/api/common/factories/redis-repository.factory.ts new file mode 100644 index 0000000..85a404b --- /dev/null +++ b/src/lib/server/api/common/factories/redis-repository.factory.ts @@ -0,0 +1,7 @@ +import { Container } from '@needle-di/core'; +import { RedisService } from '../../databases/redis/redis.service'; + +export abstract class RedisRepository { + protected readonly redis = new Container().get(RedisService); + readonly prefix: T | string = ''; +} diff --git a/src/lib/server/api/common/middleware/auth.middleware.ts b/src/lib/server/api/common/middleware/auth.middleware.ts new file mode 100644 index 0000000..7035384 --- /dev/null +++ b/src/lib/server/api/common/middleware/auth.middleware.ts @@ -0,0 +1,47 @@ +import { Unauthorized } from '$lib/server/api/common/utils/exceptions'; +import type { SessionDto } from '$lib/server/api/iam/sessions/dtos/session.dto'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; +import { Container } from '@needle-di/core'; +import type { MiddlewareHandler } from 'hono'; +import { createMiddleware } from 'hono/factory'; + +/* ---------------------------------- Types --------------------------------- */ +type AuthStates = 'session' | 'none'; +type AuthedReturnType = typeof authed; +type UnauthedReturnType = typeof unauthed; + +/* ------------------- Overloaded function implementation ------------------- */ +// we have to overload the implementation to provide the correct return type +export function authState(state: 'session'): AuthedReturnType; +export function authState(state: 'none'): UnauthedReturnType; +export function authState(state: AuthStates): AuthedReturnType | UnauthedReturnType { + if (state === 'session') return authed; + return unauthed; +} + +// resolve dependencies from the container +const sessionService = new Container().get(SessionsService); + +/* ------------------------------ Require Auth ------------------------------ */ +const authed: MiddlewareHandler<{ + Variables: { + session: SessionDto; + }; +}> = createMiddleware(async (c, next) => { + if (!c.var.session) { + throw Unauthorized('You must be logged in to access this resource'); + } + return next(); +}); + +/* ---------------------------- Require Unauthed ---------------------------- */ +const unauthed: MiddlewareHandler<{ + Variables: { + session: null; + }; +}> = createMiddleware(async (c, next) => { + if (c.var.session) { + throw Unauthorized('You must be logged out to access this resource'); + } + return next(); +}); diff --git a/src/lib/server/api/common/middleware/browser-session.middleware.ts b/src/lib/server/api/common/middleware/browser-session.middleware.ts new file mode 100644 index 0000000..64b8eed --- /dev/null +++ b/src/lib/server/api/common/middleware/browser-session.middleware.ts @@ -0,0 +1,27 @@ +import { Container } from '@needle-di/core'; +import type { MiddlewareHandler } from 'hono'; +import { getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { createMiddleware } from 'hono/factory'; +import { ConfigService } from '../configs/config.service'; +import { generateId } from '../utils/crypto'; + +export const browserSessions: MiddlewareHandler = createMiddleware(async (c, next) => { + const BROWSER_SESSION_COOKIE_NAME = '_id'; + const container = new Container(); + const configService = container.get(ConfigService); + const browserSessionCookie = await getSignedCookie(c, configService.envs.SIGNING_SECRET, BROWSER_SESSION_COOKIE_NAME); + let browserSessionId = browserSessionCookie; + + if (!browserSessionCookie) { + browserSessionId = generateId(); + setSignedCookie(c, BROWSER_SESSION_COOKIE_NAME, browserSessionId, configService.envs.SIGNING_SECRET, { + httpOnly: true, + sameSite: 'lax', + secure: configService.envs.ENV === 'prod', + path: '/', + }); + } + + c.set('browserSessionId', browserSessionId); + return next(); +}); diff --git a/src/lib/server/api/middleware/pino-logger.middleware.ts b/src/lib/server/api/common/middleware/pino-logger.middleware.ts similarity index 100% rename from src/lib/server/api/middleware/pino-logger.middleware.ts rename to src/lib/server/api/common/middleware/pino-logger.middleware.ts diff --git a/src/lib/server/api/common/middleware/rate-limit.middleware.ts b/src/lib/server/api/common/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..2ab1a6e --- /dev/null +++ b/src/lib/server/api/common/middleware/rate-limit.middleware.ts @@ -0,0 +1,35 @@ +import { Container } from '@needle-di/core'; +import type { Context } from 'hono'; +import { rateLimiter } from 'hono-rate-limiter'; +import { RedisStore } from 'rate-limit-redis'; +import { RedisService } from '../../databases/redis/redis.service'; +import type { HonoEnv } from '../utils/hono'; + +const container = new Container(); +const client = container.get(RedisService).redis; + +export function rateLimit({ + limit, + minutes, + key = '', +}: { + limit: number; + minutes: number; + key?: string; +}) { + return rateLimiter({ + windowMs: minutes * 60 * 1000, // every x minutes + limit, // Limit each IP to 100 requests per `window` (here, per 15 minutes). + standardHeaders: 'draft-6', // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header + keyGenerator: (c: Context) => { + const clientKey = c.var.session?.userId || c.req.header('x-forwarded-for'); + const pathKey = key || c.req.routePath; + return `${clientKey}_${pathKey}`; + }, // Method to generate custom identifiers for clients. + // Redis store configuration + store: new RedisStore({ + // @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis + sendCommand: (...args: string[]) => client.call(...args), + }) as never, + }); +} diff --git a/src/lib/server/api/middleware/require-auth.middleware.ts b/src/lib/server/api/common/middleware/require-auth.middleware.ts similarity index 69% rename from src/lib/server/api/middleware/require-auth.middleware.ts rename to src/lib/server/api/common/middleware/require-auth.middleware.ts index ee3993d..9839470 100644 --- a/src/lib/server/api/middleware/require-auth.middleware.ts +++ b/src/lib/server/api/common/middleware/require-auth.middleware.ts @@ -1,4 +1,4 @@ -import { Unauthorized } from '$lib/server/api/common/exceptions'; +import { Unauthorized } from '$lib/server/api/common/utils/exceptions'; import type { Sessions, Users } from '$lib/server/api/databases/postgres/tables'; import type { MiddlewareHandler } from 'hono'; import { createMiddleware } from 'hono/factory'; @@ -17,14 +17,14 @@ export const requireFullAuth: MiddlewareHandler<{ }); export const requireTempAuth: MiddlewareHandler<{ - Variables: { - session: Sessions; - user: Users; - }; + Variables: { + session: Sessions; + user: Users; + }; }> = createMiddleware(async (c, next) => { - const session = c.var.session; - if (!session) { - throw Unauthorized('You must be logged in to access this resource'); - } - return next(); -}); \ No newline at end of file + const session = c.var.session; + if (!session) { + throw Unauthorized('You must be logged in to access this resource'); + } + return next(); +}); diff --git a/src/lib/server/api/common/middleware/session-management.middleware.ts b/src/lib/server/api/common/middleware/session-management.middleware.ts new file mode 100644 index 0000000..31fd867 --- /dev/null +++ b/src/lib/server/api/common/middleware/session-management.middleware.ts @@ -0,0 +1,30 @@ +import { Container } from '@needle-di/core'; +import type { MiddlewareHandler } from 'hono'; +import { createMiddleware } from 'hono/factory'; +import { SessionsService } from '../../iam/sessions/sessions.service'; + +export const sessionManagement: MiddlewareHandler = createMiddleware(async (c, next) => { + const container = new Container(); + const sessionService = container.get(SessionsService); + const sessionId = await sessionService.getSessionCookie(); + + if (!sessionId) { + c.set('session', null); + return next(); + } + + // Validate the session + const session = await sessionService.validateSession(sessionId); + + // If the session is not found, delete the cookie + if (!session) sessionService.deleteSessionCookie(); + + // If the session is fresh, refresh the cookie with the new expiration date + if (session && session.fresh) sessionService.setSessionCookie(session); + + // Set the session in the context + c.set('session', session); + + // Continue to the next middleware + return next(); +}); diff --git a/src/lib/server/api/services/encryption.service.ts b/src/lib/server/api/common/services/encryption.service.ts similarity index 97% rename from src/lib/server/api/services/encryption.service.ts rename to src/lib/server/api/common/services/encryption.service.ts index c44f2f1..1201cc9 100644 --- a/src/lib/server/api/services/encryption.service.ts +++ b/src/lib/server/api/common/services/encryption.service.ts @@ -1,8 +1,8 @@ -import { decodeBase64 } from '@oslojs/encoding'; import { createCipheriv, createDecipheriv } from 'crypto'; -import { DynamicBuffer } from '@oslojs/binary'; import { injectable } from '@needle-di/core'; -import { config } from '../common/config'; +import { DynamicBuffer } from '@oslojs/binary'; +import { decodeBase64 } from '@oslojs/encoding'; +import { config } from '../config'; @injectable() export class EncryptionService { diff --git a/src/lib/server/api/common/services/hashing.service.test.ts b/src/lib/server/api/common/services/hashing.service.test.ts new file mode 100644 index 0000000..d6f78ea --- /dev/null +++ b/src/lib/server/api/common/services/hashing.service.test.ts @@ -0,0 +1,38 @@ +import { Container } from '@needle-di/core'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { HashingService } from './hashing.service'; + +describe('HashingService', () => { + let service: HashingService; + const container = new Container(); + + beforeAll(() => { + service = container.get(HashingService); + }); + + afterAll(() => { + vi.resetAllMocks(); + }); + + describe('Create Hash', () => { + it('should create a hash', async () => { + const hash = await service.hash('111'); + expect(hash).not.toBeUndefined(); + expect(hash).not.toBeNull(); + }); + }); + + describe('Verify Hash', () => { + it('should verify a hash', async () => { + const hash = await service.hash('111'); + const verifiable = await service.verify(hash, '111'); + expect(verifiable).toBeTruthy(); + }); + + it('should not verify a hash', async () => { + const hash = await service.hash('111'); + const verifiable = await service.verify(hash, '222'); + expect(verifiable).toBeFalsy(); + }); + }); +}); diff --git a/src/lib/server/api/common/services/hashing.service.ts b/src/lib/server/api/common/services/hashing.service.ts new file mode 100644 index 0000000..8d8d9a0 --- /dev/null +++ b/src/lib/server/api/common/services/hashing.service.ts @@ -0,0 +1,13 @@ +import { injectable } from '@needle-di/core'; +import { hash, verify } from 'argon2'; + +@injectable() +export class HashingService { + hash(data: string): Promise { + return hash(data); + } + + compare(data: string, encrypted: string): Promise { + return verify(encrypted, data); + } +} diff --git a/src/lib/server/api/common/services/request-context.service.ts b/src/lib/server/api/common/services/request-context.service.ts new file mode 100644 index 0000000..9a3b175 --- /dev/null +++ b/src/lib/server/api/common/services/request-context.service.ts @@ -0,0 +1,22 @@ +import { injectable } from '@needle-di/core'; +import { getContext } from 'hono/context-storage'; +import type { HonoEnv } from '../utils/hono'; + +@injectable() +export class RequestContextService { + getContext() { + return getContext(); + } + + getRequestId(): string { + return this.getContext().get('requestId'); + } + + getAuthedUserId() { + return this.getContext().get('session')?.userId; + } + + getSession() { + return this.getContext().get('session'); + } +} diff --git a/src/lib/server/api/common/services/verification-codes.service.ts b/src/lib/server/api/common/services/verification-codes.service.ts new file mode 100644 index 0000000..3ddf902 --- /dev/null +++ b/src/lib/server/api/common/services/verification-codes.service.ts @@ -0,0 +1,25 @@ +import { inject, injectable } from '@needle-di/core'; +import { generateId } from '../../common/utils/crypto'; +import { HashingService } from '../../common/services/hashing.service'; + +@injectable() +export class VerificationCodesService { + constructor(private hashingService = inject(HashingService)) {} + + async generateCodeWithHash() { + const verificationCode = this.generateCode(); + const hashedVerificationCode = await this.hashingService.hash(verificationCode); + return { verificationCode, hashedVerificationCode }; + } + + verify(args: { verificationCode: string; hashedVerificationCode: string }) { + return this.hashingService.compare(args.verificationCode, args.hashedVerificationCode); + } + + private generateCode() { + // alphabet with removed look-alike characters (0, 1, O, I) + const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; + // generate 6 character long random string + return generateId(6, alphabet); + } +} diff --git a/src/lib/server/api/common/types/hono.ts b/src/lib/server/api/common/types/hono.ts index 92f20a9..c4ab527 100644 --- a/src/lib/server/api/common/types/hono.ts +++ b/src/lib/server/api/common/types/hono.ts @@ -1,34 +1,34 @@ -import type { Session } from '$lib/server/api/services/sessions.service'; +import type { Users } from '$lib/server/api/databases/postgres/tables'; +import type { Session } from '$lib/server/api/iam/sessions/sessions.service'; import type { Hono } from 'hono'; import type { PinoLogger } from 'hono-pino'; import type { Promisify, RateLimitInfo } from 'hono-rate-limiter'; -import type { User } from 'lucia'; // export type AppOpenAPI = OpenAPIHono; export type AppOpenAPI = Hono; export type AppBindings = { - Variables: { - logger: PinoLogger; - session: Session | null; - user: User | null; - rateLimit: RateLimitInfo; - rateLimitStore: { - getKey?: (key: string) => Promisify; - resetKey: (key: string) => Promisify; - }; - }; + Variables: { + logger: PinoLogger; + session: Session | null; + user: Users | null; + rateLimit: RateLimitInfo; + rateLimitStore: { + getKey?: (key: string) => Promisify; + resetKey: (key: string) => Promisify; + }; + }; }; export type HonoTypes = { - Variables: { - logger: PinoLogger; - session: Session | null; - user: User | null; - rateLimit: RateLimitInfo; - rateLimitStore: { - getKey?: (key: string) => Promisify; - resetKey: (key: string) => Promisify; - }; - }; + Variables: { + logger: PinoLogger; + session: Session | null; + user: Users | null; + rateLimit: RateLimitInfo; + rateLimitStore: { + getKey?: (key: string) => Promisify; + resetKey: (key: string) => Promisify; + }; + }; }; diff --git a/src/lib/server/api/common/utils/cookies.ts b/src/lib/server/api/common/utils/cookies.ts index a13bb99..6896a86 100644 --- a/src/lib/server/api/common/utils/cookies.ts +++ b/src/lib/server/api/common/utils/cookies.ts @@ -1,63 +1,62 @@ -import {config} from '$lib/server/api/common/config'; +import { config } from '$lib/server/api/common/config'; import env from '$lib/server/api/common/env'; -import type {Context} from 'hono'; -import {setCookie} from 'hono/cookie'; -import type {CookieOptions} from 'hono/utils/cookie'; -import {TimeSpan} from 'oslo'; +import type { Context } from 'hono'; +import { setCookie } from 'hono/cookie'; +import type { CookieOptions } from 'hono/utils/cookie'; export const cookieMaxAge = 60 * 60 * 24 * 30; -export const cookieExpiresMilliseconds = new TimeSpan(2, 'w').milliseconds(); +export const cookieExpiresMilliseconds = cookieMaxAge * 1000; // new TimeSpan(2, 'w').milliseconds(); export const cookieExpiresAt = new Date(Date.now() + cookieExpiresMilliseconds); export const halfCookieExpiresMilliseconds = cookieExpiresMilliseconds / 2; export const halfCookieExpiresAt = new Date(Date.now() + halfCookieExpiresMilliseconds); export const cookieName = 'session'; export type SessionCookie = { - name: string; - value: string; - attributes: CookieOptions; + name: string; + value: string; + attributes: CookieOptions; }; export function createSessionTokenCookie(token: string, expiresAt: Date): SessionCookie { - return { - name: cookieName, - value: token, - attributes: { - path: '/', - maxAge: cookieMaxAge, - domain: env.DOMAIN, - sameSite: 'lax', - secure: config.isProduction, - httpOnly: true, - expires: expiresAt, - }, - }; + return { + name: cookieName, + value: token, + attributes: { + path: '/', + maxAge: cookieMaxAge, + domain: env.DOMAIN, + sameSite: 'lax', + secure: config.isProduction, + httpOnly: true, + expires: expiresAt, + }, + }; } export function createBlankSessionTokenCookie(): SessionCookie { - return { - name: cookieName, - value: '', - attributes: { - path: '/', - maxAge: 0, - domain: env.DOMAIN, - sameSite: 'lax', - secure: config.isProduction, - httpOnly: true, - expires: new Date(0), - }, - }; + return { + name: cookieName, + value: '', + attributes: { + path: '/', + maxAge: 0, + domain: env.DOMAIN, + sameSite: 'lax', + secure: config.isProduction, + httpOnly: true, + expires: new Date(0), + }, + }; } export function setSessionCookie(c: Context, sessionCookie: SessionCookie) { - setCookie(c, sessionCookie.name, sessionCookie.value, { - path: sessionCookie.attributes.path, - maxAge: sessionCookie.attributes?.maxAge, - domain: sessionCookie.attributes.domain, - sameSite: sessionCookie.attributes.sameSite as undefined, - secure: sessionCookie.attributes.secure, - httpOnly: sessionCookie.attributes.httpOnly, - expires: sessionCookie.attributes.expires, - }); + setCookie(c, sessionCookie.name, sessionCookie.value, { + path: sessionCookie.attributes.path, + maxAge: sessionCookie.attributes?.maxAge, + domain: sessionCookie.attributes.domain, + sameSite: sessionCookie.attributes.sameSite as undefined, + secure: sessionCookie.attributes.secure, + httpOnly: sessionCookie.attributes.httpOnly, + expires: sessionCookie.attributes.expires, + }); } diff --git a/src/lib/server/api/common/utils/crypto.ts b/src/lib/server/api/common/utils/crypto.ts index 62d2cbf..57855d3 100644 --- a/src/lib/server/api/common/utils/crypto.ts +++ b/src/lib/server/api/common/utils/crypto.ts @@ -1,4 +1,4 @@ -import { customAlphabet } from "nanoid"; +import { customAlphabet } from 'nanoid'; // generateId is a function that returns a new unique identifier. // ~4 million years or 30 trillion IDs needed, in order to have a 1% probability of at least one collision. @@ -8,7 +8,7 @@ import { customAlphabet } from "nanoid"; // All hail king roomba, the first of his name, the unclean, king of the dust bunnies and the first allergens, lord of the seven corners, and protector of the realm. // https://zelark.github.io/nano-id-cc/ -export function generateId(length = 16, alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") { - const nanoId = customAlphabet(alphabet, length); - return nanoId(); +export function generateId(length = 16, alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') { + const nanoId = customAlphabet(alphabet, length); + return nanoId(); } diff --git a/src/lib/server/api/common/utils/drizzle.ts b/src/lib/server/api/common/utils/drizzle.ts new file mode 100644 index 0000000..befcb8f --- /dev/null +++ b/src/lib/server/api/common/utils/drizzle.ts @@ -0,0 +1,53 @@ +import { customType, timestamp } from 'drizzle-orm/pg-core'; +import { NotFound } from './exceptions'; + +/* -------------------------------------------------------------------------- */ +/* Repository */ +/* -------------------------------------------------------------------------- */ + +// get the first element of an array or return null +export const takeFirst = (values: T[]): T | null => { + return values.shift() || null; +}; + +// get the first element of an array or throw a 404 error +export const takeFirstOrThrow = (values: T[]): T => { + const value = values.shift(); + if (!value) throw NotFound('The requested resource was not found.'); + return value; +}; + +/* -------------------------------------------------------------------------- */ +/* Tables */ +/* -------------------------------------------------------------------------- */ + +// custom type for citext +export const citext = customType<{ data: string }>({ + dataType() { + return 'citext'; + }, +}); + +// custom type for generating an id +export const id = customType<{ data: string }>({ + dataType() { + return 'text'; + }, +}); + +// timestamps for created_at and updated_at +export const timestamps = { + createdAt: timestamp('created_at', { + mode: 'date', + withTimezone: true, + }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { + mode: 'date', + withTimezone: true, + }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), +}; diff --git a/src/lib/server/api/common/exceptions.ts b/src/lib/server/api/common/utils/exceptions.ts similarity index 62% rename from src/lib/server/api/common/exceptions.ts rename to src/lib/server/api/common/utils/exceptions.ts index 867734d..5f3d57a 100644 --- a/src/lib/server/api/common/exceptions.ts +++ b/src/lib/server/api/common/utils/exceptions.ts @@ -1,40 +1,40 @@ -import {StatusCodes} from '$lib/constants/status-codes'; -import {HTTPException} from 'hono/http-exception'; +import { StatusCodes } from '$lib/utils/status-codes'; +import { HTTPException } from 'hono/http-exception'; import * as HttpStatusPhrases from 'stoker/http-status-phrases'; -import {createMessageObjectSchema} from 'stoker/openapi/schemas'; +import { createMessageObjectSchema } from 'stoker/openapi/schemas'; export function TooManyRequests(message = 'Too many requests') { - return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message }); + return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message }); } export const tooManyRequestsSchema = createMessageObjectSchema(HttpStatusPhrases.TOO_MANY_REQUESTS); export function Forbidden(message = 'Forbidden') { - return new HTTPException(StatusCodes.FORBIDDEN, { message }); + return new HTTPException(StatusCodes.FORBIDDEN, { message }); } export const forbiddenSchema = createMessageObjectSchema(HttpStatusPhrases.FORBIDDEN); export function Unauthorized(message = 'Unauthorized') { - return new HTTPException(StatusCodes.UNAUTHORIZED, { message }); + return new HTTPException(StatusCodes.UNAUTHORIZED, { message }); } export const unauthorizedSchema = createMessageObjectSchema(HttpStatusPhrases.UNAUTHORIZED); export function NotFound(message = 'Not Found') { - return new HTTPException(StatusCodes.NOT_FOUND, { message }); + return new HTTPException(StatusCodes.NOT_FOUND, { message }); } export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND); export function BadRequest(message = 'Bad Request') { - return new HTTPException(StatusCodes.BAD_REQUEST, { message }); + return new HTTPException(StatusCodes.BAD_REQUEST, { message }); } export const badRequestSchema = createMessageObjectSchema(HttpStatusPhrases.BAD_REQUEST); export function InternalError(message = 'Internal Error') { - return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message }); + return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message }); } export const internalErrorSchema = createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR); diff --git a/src/lib/server/api/common/utils/hono.ts b/src/lib/server/api/common/utils/hono.ts new file mode 100644 index 0000000..13dcfc7 --- /dev/null +++ b/src/lib/server/api/common/utils/hono.ts @@ -0,0 +1,20 @@ +import { Hono } from 'hono'; +import type { SessionDto } from '../../iam/sessions/dtos/session.dto'; +import type { PinoLogger } from 'hono-pino'; + +export type AppOpenAPI = Hono; + +export type HonoEnv = { + Variables: { + logger: PinoLogger; + session: SessionDto | null; + browserSessionId: string; + requestId: string; + }; +}; + +export function createHono() { + return new Hono({ + strict: false, + }); +} diff --git a/src/lib/server/api/configure-open-api.ts b/src/lib/server/api/configure-open-api.ts index 2595afe..540bcb9 100644 --- a/src/lib/server/api/configure-open-api.ts +++ b/src/lib/server/api/configure-open-api.ts @@ -1,70 +1,42 @@ -import {apiReference} from '@scalar/hono-api-reference'; - -import type {AppOpenAPI} from '$lib/server/api/common/types/hono'; -// import { createOpenApiDocument } from 'hono-zod-openapi'; -import {createOpenApiDocument} from 'hono-zod-openapi'; +import { apiReference } from '@scalar/hono-api-reference'; +import { createOpenApiDocument } from 'hono-zod-openapi'; import packageJSON from '../../../../package.json'; - -// export default function configureOpenAPI(app: AppOpenAPI) { -// app.doc('/doc', { -// openapi: '3.0.0', -// info: { -// title: 'Bored Game API', -// description: 'Bored Game API', -// version: packageJSON.version, -// }, -// }); -// -// app.get( -// '/reference', -// apiReference({ -// theme: 'kepler', -// layout: 'classic', -// defaultHttpClient: { -// targetKey: 'javascript', -// clientKey: 'fetch', -// }, -// spec: { -// url: '/api/doc', -// }, -// }), -// ); -// } +import type { AppOpenAPI } from './common/utils/hono'; export default function configureOpenAPI(app: AppOpenAPI) { - createOpenApiDocument(app, { - info: { - title: 'Bored Game API', - description: 'Bored Game API', - version: packageJSON.version, - }, - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - }, - cookieAuth: { - type: 'apiKey', - name: 'session', - in: 'cookie', - } - }, - }, - }); + createOpenApiDocument(app, { + info: { + title: 'Bored Game API', + description: 'Bored Game API', + version: packageJSON.version, + }, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + cookieAuth: { + type: 'apiKey', + name: 'session', + in: 'cookie', + }, + }, + }, + }); - app.get( - '/reference', - apiReference({ - theme: 'kepler', - layout: 'classic', - defaultHttpClient: { - targetKey: 'javascript', - clientKey: 'fetch', - }, - spec: { - url: '/api/doc', - }, - }), - ); + app.get( + '/reference', + apiReference({ + theme: 'kepler', + layout: 'classic', + defaultHttpClient: { + targetKey: 'javascript', + clientKey: 'fetch', + }, + spec: { + url: '/api/doc', + }, + }), + ); } diff --git a/src/lib/server/api/controllers/collection.routes.ts b/src/lib/server/api/controllers/collection.routes.ts deleted file mode 100644 index 7c80692..0000000 --- a/src/lib/server/api/controllers/collection.routes.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {StatusCodes} from '$lib/constants/status-codes'; -import {unauthorizedSchema} from '$lib/server/api/common/exceptions'; -import cuidParamsSchema from '$lib/server/api/common/openapi/cuidParamsSchema'; -import {z} from '@hono/zod-openapi'; -import {createErrorSchema} from 'stoker/openapi/schemas'; -import {taggedAuthRoute} from '../common/openapi/create-auth-route'; -import {selectCollectionSchema} from '../databases/postgres/tables'; - -const tag = 'Collection'; - -export const allCollections = taggedAuthRoute(tag, { - responses: { - [StatusCodes.OK]: { - description: 'User profile', - schema: selectCollectionSchema, - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - }, -}); - -export const numberOfCollections = taggedAuthRoute(tag, { - responses: { - [StatusCodes.OK]: { - description: 'User profile', - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - }, -}); - -export const getCollectionByCUID = taggedAuthRoute(tag, { - request: { - param: { - schema: cuidParamsSchema, - example: { cuid: 'z6uiuc9qz82xjf5dexc5kr2d' }, - }, - }, - responses: { - [StatusCodes.OK]: { - description: 'User profile', - schema: selectCollectionSchema, - mediaType: 'application/json', - }, - [StatusCodes.NOT_FOUND]: { - description: 'The collection does not exist', - schema: z.object({ message: z.string() }).openapi({ - example: { - message: 'The collection does not exist', - }, - }), - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - }, -}); diff --git a/src/lib/server/api/controllers/iam.routes.ts b/src/lib/server/api/controllers/iam.routes.ts deleted file mode 100644 index bfd831f..0000000 --- a/src/lib/server/api/controllers/iam.routes.ts +++ /dev/null @@ -1,145 +0,0 @@ -import {StatusCodes} from '$lib/constants/status-codes'; -import {unauthorizedSchema} from '$lib/server/api/common/exceptions'; -import {selectUserSchema} from '$lib/server/api/databases/postgres/tables/users.table'; -import {updateProfileDto} from '$lib/server/api/dtos/update-profile.dto'; -import {createErrorSchema} from 'stoker/openapi/schemas'; -import {taggedAuthRoute} from '../common/openapi/create-auth-route'; -import {changePasswordDto} from '../dtos/change-password.dto'; -import {verifyPasswordDto} from '../dtos/verify-password.dto'; - -const tag = 'IAM'; - -export const iam = taggedAuthRoute(tag, { - responses: { - [StatusCodes.OK]: { - description: 'User profile', - schema: selectUserSchema, - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - }, -}); - -export const updateProfile = taggedAuthRoute(tag, { - request: { - json: updateProfileDto, - }, - responses: { - [StatusCodes.OK]: { - description: 'Updated User', - schema: selectUserSchema, - mediaType: 'application/json', - }, - [StatusCodes.BAD_REQUEST]: { - description: 'The validation error(s)', - schema: createErrorSchema(updateProfileDto), - mediaType: 'application/json', - }, - [StatusCodes.UNPROCESSABLE_ENTITY]: { - description: 'Username already in use', - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - }, -}); - -export const verifyPassword = taggedAuthRoute(tag, { - request: { - json: verifyPasswordDto, - }, - responses: { - [StatusCodes.OK]: { - description: 'Password verified', - mediaType: 'application/json', - }, - [StatusCodes.BAD_REQUEST]: { - description: 'The validation error(s)', - schema: createErrorSchema(verifyPasswordDto), - mediaType: 'application/json', - }, - [StatusCodes.UNPROCESSABLE_ENTITY]: { - description: 'Incorrect password', - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - }, -}); - -export const updatePassword = taggedAuthRoute(tag, { - request: { - json: changePasswordDto, - }, - responses: { - [StatusCodes.OK]: { - description: 'Password updated', - mediaType: 'application/json', - }, - [StatusCodes.BAD_REQUEST]: { - description: 'The validation error(s)', - schema: createErrorSchema(changePasswordDto), - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - [StatusCodes.FORBIDDEN]: { - description: 'Incorrect password', - mediaType: 'application/json', - }, - [StatusCodes.INTERNAL_SERVER_ERROR]: { - description: 'Error updating password', - mediaType: 'application/json', - }, - }, -}); - -export const updateEmail = taggedAuthRoute(tag, { - responses: { - [StatusCodes.OK]: { - description: 'Email updated', - mediaType: 'application/json', - }, - [StatusCodes.BAD_REQUEST]: { - description: 'The validation error(s)', - schema: createErrorSchema(changePasswordDto), - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - [StatusCodes.FORBIDDEN]: { - description: 'Cannot change email address', - mediaType: 'application/json', - }, - }, -}); - -export const logout = taggedAuthRoute(tag, { - responses: { - [StatusCodes.OK]: { - description: 'Logged out', - mediaType: 'application/json', - }, - [StatusCodes.UNAUTHORIZED]: { - description: 'Unauthorized', - schema: createErrorSchema(unauthorizedSchema), - mediaType: 'application/json', - }, - }, -}); diff --git a/src/lib/server/api/controllers/login.routes.ts b/src/lib/server/api/controllers/login.routes.ts deleted file mode 100644 index f118ae9..0000000 --- a/src/lib/server/api/controllers/login.routes.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {defineOpenApiOperation} from "hono-zod-openapi"; -import {StatusCodes} from '$lib/constants/status-codes'; -import {signinUsernameDto} from "../dtos/signin-username.dto"; -import {createErrorSchema} from "stoker/openapi/schemas"; - -export const signinUsername = defineOpenApiOperation({ - tags: ['Login'], - summary: 'Sign in with username', - description: 'Sign in with username', - responses: { - [StatusCodes.OK]: { - description: 'Sign in with username', - schema: signinUsernameDto, - }, - [StatusCodes.UNPROCESSABLE_ENTITY]: { - description: 'The validation error(s)', - schema: createErrorSchema(signinUsernameDto), - mediaType: 'application/json', - } - } -}); diff --git a/src/lib/server/api/controllers/oauth.controller.ts b/src/lib/server/api/controllers/oauth.controller.ts deleted file mode 100644 index 87777af..0000000 --- a/src/lib/server/api/controllers/oauth.controller.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {Controller} from '$lib/server/api/common/types/controller'; -import type {OAuthUser} from '$lib/server/api/common/types/oauth'; -import {cookieExpiresAt, createSessionTokenCookie, setSessionCookie} from '$lib/server/api/common/utils/cookies'; -import {OAuthService} from '$lib/server/api/services/oauth.service'; -import {SessionsService} from '$lib/server/api/services/sessions.service'; -import {github, google} from '$lib/server/auth'; -import {OAuth2RequestError} from 'arctic'; -import {getCookie} from 'hono/cookie'; -import { injectable, inject } from "@needle-di/core"; - -@injectable() -export class OAuthController extends Controller { - constructor( - private oauthService = inject(OAuthService), - private sessionsService = inject(SessionsService), - ) { - super(); - } - - routes() { - return this.controller - .get('/github', async (c) => { - try { - const code = c.req.query('code')?.toString() ?? null; - const state = c.req.query('state')?.toString() ?? null; - const storedState = getCookie(c).github_oauth_state ?? null; - - if (!code || !state || !storedState || state !== storedState) { - return c.body(null, 400); - } - - const tokens = await github.validateAuthorizationCode(code); - const githubUserResponse = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - const githubUser: GitHubUser = await githubUserResponse.json(); - - const oAuthUser: OAuthUser = { - sub: `${githubUser.id}`, - username: githubUser.login, - email: undefined, - }; - - const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'github'); - - const sessionToken = this.sessionsService.generateSessionToken(); - const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false); - const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); - setSessionCookie(c, sessionCookie); - return c.json({ message: 'ok' }); - } catch (error) { - console.error(error); - // the specific error message depends on the provider - if (error instanceof OAuth2RequestError) { - // invalid code - return c.body(null, 400); - } - return c.body(null, 500); - } - }) - .get('/google', async (c) => { - try { - const code = c.req.query('code')?.toString() ?? null; - const state = c.req.query('state')?.toString() ?? null; - const storedState = getCookie(c).google_oauth_state ?? null; - const storedCodeVerifier = getCookie(c).google_oauth_code_verifier ?? null; - - if (!code || !storedState || !storedCodeVerifier || state !== storedState) { - return c.body(null, 400); - } - - const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier); - const googleUserResponse = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - const googleUser: GoogleUser = await googleUserResponse.json(); - - const oAuthUser: OAuthUser = { - sub: googleUser.sub, - given_name: googleUser.given_name, - family_name: googleUser.family_name, - picture: googleUser.picture, - username: googleUser.email, - email: googleUser.email, - email_verified: googleUser.email_verified, - }; - - const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'google'); - const sessionToken = this.sessionsService.generateSessionToken(); - const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false); - const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); - setSessionCookie(c, sessionCookie); - - return c.json({ message: 'ok' }); - } catch (error) { - console.error(error); - // the specific error message depends on the provider - if (error instanceof OAuth2RequestError) { - // invalid code - return c.body(null, 400); - } - return c.body(null, 500); - } - }); - } -} - -interface GitHubUser { - id: number; - login: string; -} - -interface GoogleUser { - sub: string; - name: string; - given_name: string; - family_name: string; - picture: string; - email: string; - email_verified: boolean; -} diff --git a/src/lib/server/api/controllers/signup.controller.ts b/src/lib/server/api/controllers/signup.controller.ts deleted file mode 100644 index 2b6e0cd..0000000 --- a/src/lib/server/api/controllers/signup.controller.ts +++ /dev/null @@ -1,44 +0,0 @@ -import 'reflect-metadata'; -import {Controller} from '$lib/server/api/common/types/controller'; -import {signupUsernameEmailDto} from '$lib/server/api/dtos/signup-username-email.dto'; -import {limiter} from '$lib/server/api/middleware/rate-limiter.middleware'; -import {LoginRequestsService} from '$lib/server/api/services/loginrequest.service'; -import {SessionsService} from '$lib/server/api/services/sessions.service'; -import {UsersService} from '$lib/server/api/services/users.service'; -import {zValidator} from '@hono/zod-validator'; -import {inject, injectable} from '@needle-di/core'; -import {cookieExpiresAt, createSessionTokenCookie, setSessionCookie} from "$lib/server/api/common/utils/cookies"; - -@injectable() -export class SignupController extends Controller { - constructor( - private usersService = inject(UsersService), - private loginRequestService = inject(LoginRequestsService), - private sessionsService = inject(SessionsService), - ) { - super(); - } - - routes() { - return this.controller.post('/', zValidator('json', signupUsernameEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { - const { firstName, lastName, email, username, password, confirm_password } = await c.req.valid('json'); - const existingUser = await this.usersService.findOneByUsername(username); - - if (existingUser) { - return c.body('User already exists', 400); - } - - const user = await this.usersService.create({ firstName, lastName, email, username, password, confirm_password }); - - if (!user) { - return c.body('Failed to create user', 500); - } - - const session = await this.loginRequestService.createUserSession(user.id, c.req, false, false); - const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); - console.log('set cookie', sessionCookie); - setSessionCookie(c, sessionCookie); - return c.json({ message: 'ok' }); - }); - } -} diff --git a/src/lib/server/api/controllers/user.controller.ts b/src/lib/server/api/controllers/user.controller.ts deleted file mode 100644 index b1a2ac5..0000000 --- a/src/lib/server/api/controllers/user.controller.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Controller } from '$lib/server/api/common/types/controller'; -import { UsersService } from '$lib/server/api/services/users.service'; -import { inject, injectable } from '@needle-di/core'; -import { requireFullAuth, requireTempAuth } from '../middleware/require-auth.middleware'; - -@injectable() -export class UserController extends Controller { - constructor(private usersService = inject(UsersService)) { - super(); - } - - routes() { - return this.controller - .get('/', requireTempAuth, async (c) => { - const session = c.var.session; - const user = session ? await this.usersService.findOneById(session.userId) : null; - return c.json({ user, session }); - }) - .get('/:id', requireFullAuth, async (c) => { - const id = c.req.param('id'); - const user = await this.usersService.findOneById(id); - return c.json({ user }); - }) - .get('/username/:userName', requireFullAuth, async (c) => { - const userName = c.req.param('userName'); - const user = await this.usersService.findOneByUsername(userName); - return c.json({ user }); - }); - } -} diff --git a/src/lib/server/api/databases/postgres/drizzle.service.ts b/src/lib/server/api/databases/postgres/drizzle.service.ts new file mode 100644 index 0000000..10dd9e9 --- /dev/null +++ b/src/lib/server/api/databases/postgres/drizzle.service.ts @@ -0,0 +1,31 @@ +import { ConfigService } from '$lib/server/api/common/configs/config.service'; +import { inject, injectable } from '@needle-di/core'; +import { type NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres'; +import pg from 'pg'; +import * as schema from './tables'; + +@injectable() +export class DrizzleService { + pool: pg.Pool; + db: NodePgDatabase; + readonly schema: typeof schema = schema; + + constructor(private configService = inject(ConfigService)) { + const pool = new pg.Pool({ + user: this.configService.envs.DATABASE_USER, + password: this.configService.envs.DATABASE_PASSWORD, + host: this.configService.envs.DATABASE_HOST, + port: Number(this.configService.envs.DATABASE_PORT).valueOf(), + database: this.configService.envs.DATABASE_DB, + ssl: false, + max: this.configService.envs.DB_MIGRATING || this.configService.envs.DB_SEEDING ? 1 : undefined, + }); + this.pool = pool; + this.db = drizzle({ + client: pool, + casing: 'snake_case', + schema, + logger: this.configService.envs.ENV !== 'prod', + }); + } +} diff --git a/src/lib/server/api/databases/postgres/migrate.ts b/src/lib/server/api/databases/postgres/migrate.ts index ab5248e..2f80fae 100644 --- a/src/lib/server/api/databases/postgres/migrate.ts +++ b/src/lib/server/api/databases/postgres/migrate.ts @@ -1,20 +1,20 @@ import 'dotenv/config'; -import {migrate} from 'drizzle-orm/node-postgres/migrator'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; import config from '../../../../../../drizzle.config'; import env from '../../common/env'; -import {DrizzleService} from '../../services/drizzle.service'; +import { DrizzleService } from './drizzle.service'; const drizzleService = new DrizzleService(); if (!config.out) { - console.error('No migrations folder specified in drizzle.config.ts'); - process.exit(); + console.error('No migrations folder specified in drizzle.config.ts'); + process.exit(); } if (!env.DB_MIGRATING) { - throw new Error('You must set DB_MIGRATING to "true" when running migrations.'); + throw new Error('You must set DB_MIGRATING to "true" when running migrations.'); } await migrate(drizzleService.db, { migrationsFolder: config.out }); console.log('Migrations complete'); -await drizzleService.dispose(); +await drizzleService.pool.end(); process.exit(); diff --git a/src/lib/server/api/databases/postgres/seed.ts b/src/lib/server/api/databases/postgres/seed.ts index 61c676b..311481e 100644 --- a/src/lib/server/api/databases/postgres/seed.ts +++ b/src/lib/server/api/databases/postgres/seed.ts @@ -1,53 +1,53 @@ -import {getTableName, sql, type Table} from 'drizzle-orm'; -import type {NodePgDatabase} from 'drizzle-orm/node-postgres'; +import { type Table, getTableName, sql } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import env from '../../common/env'; -import {DrizzleService} from '../../services/drizzle.service'; +import { DrizzleService } from './drizzle.service'; import * as seeds from './seeds'; import * as schema from './tables'; const drizzleService = new DrizzleService(); if (!env.DB_SEEDING) { - throw new Error('You must set DB_SEEDING to "true" when running seeds'); + throw new Error('You must set DB_SEEDING to "true" when running seeds'); } async function resetTable(db: NodePgDatabase, table: Table) { - return db.execute(sql.raw(`TRUNCATE TABLE ${getTableName(table)} RESTART IDENTITY CASCADE`)); + return db.execute(sql.raw(`TRUNCATE TABLE ${getTableName(table)} RESTART IDENTITY CASCADE`)); } for (const table of [ - schema.categoriesTable, - schema.categoriesToExternalIdsTable, - schema.categories_to_games_table, - schema.collection_items, - schema.collections, - schema.credentialsTable, - schema.expansionsTable, - schema.externalIdsTable, - schema.federatedIdentityTable, - schema.gamesTable, - schema.gamesToExternalIdsTable, - schema.mechanicsTable, - schema.mechanicsToExternalIdsTable, - schema.mechanics_to_games, - schema.password_reset_tokens, - schema.publishersTable, - schema.publishersToExternalIdsTable, - schema.publishers_to_games, - schema.recoveryCodesTable, - schema.rolesTable, - schema.twoFactorTable, - schema.user_roles, - schema.usersTable, - schema.wishlist_items, - schema.wishlistsTable, + schema.categoriesTable, + schema.categoriesToExternalIdsTable, + schema.categories_to_games_table, + schema.collection_items, + schema.collections, + schema.credentialsTable, + schema.expansionsTable, + schema.externalIdsTable, + schema.federatedIdentityTable, + schema.gamesTable, + schema.gamesToExternalIdsTable, + schema.mechanicsTable, + schema.mechanicsToExternalIdsTable, + schema.mechanics_to_games, + schema.password_reset_tokens, + schema.publishersTable, + schema.publishersToExternalIdsTable, + schema.publishers_to_games, + schema.recoveryCodesTable, + schema.rolesTable, + schema.twoFactorTable, + schema.user_roles, + schema.usersTable, + schema.wishlist_items, + schema.wishlistsTable, ]) { - // await db.delete(table); // clear tables without truncating / resetting ids - await resetTable(drizzleService.db, table); + // await db.delete(table); // clear tables without truncating / resetting ids + await resetTable(drizzleService.db, table); } await seeds.roles(drizzleService.db); await seeds.users(drizzleService.db); -await drizzleService.dispose(); +await drizzleService.pool.end(); process.exit(); diff --git a/src/lib/server/api/databases/postgres/seeds/users.ts b/src/lib/server/api/databases/postgres/seeds/users.ts index f2da9a8..8cf25cd 100644 --- a/src/lib/server/api/databases/postgres/seeds/users.ts +++ b/src/lib/server/api/databases/postgres/seeds/users.ts @@ -1,89 +1,89 @@ -import {eq} from 'drizzle-orm'; -import type {NodePgDatabase} from 'drizzle-orm/node-postgres'; -import {HashingService} from '../../../services/hashing.service'; +import { eq } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { HashingService } from '../../../common/services/hashing.service'; import * as schema from '../tables'; import users from './data/users.json'; type JsonRole = { - name: string; - primary: boolean; + name: string; + primary: boolean; }; export default async function seed(db: NodePgDatabase) { - const hashingService = new HashingService(); - const adminRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'admin')); - const userRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'user')); + const hashingService = new HashingService(); + const adminRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'admin')); + const userRole = await db.select().from(schema.rolesTable).where(eq(schema.rolesTable.name, 'user')); - const adminUser = await db - .insert(schema.usersTable) - .values({ - username: `${process.env.ADMIN_USERNAME}`, - email: '', - first_name: 'Brad', - last_name: 'S', - verified: true, - }) - .returning() - .onConflictDoNothing(); + const adminUser = await db + .insert(schema.usersTable) + .values({ + username: `${process.env.ADMIN_USERNAME}`, + email: '', + first_name: 'Brad', + last_name: 'S', + verified: true, + }) + .returning() + .onConflictDoNothing(); - await db.insert(schema.credentialsTable).values({ - user_id: adminUser[0].id, - type: schema.CredentialsType.PASSWORD, - secret_data: await hashingService.hash(`${process.env.ADMIN_PASSWORD}`), - }); + await db.insert(schema.credentialsTable).values({ + user_id: adminUser[0].id, + type: schema.CredentialsType.PASSWORD, + secret_data: await hashingService.hash(`${process.env.ADMIN_PASSWORD}`), + }); - await db.insert(schema.collections).values({ user_id: adminUser[0].id }).onConflictDoNothing(); + await db.insert(schema.collections).values({ user_id: adminUser[0].id }).onConflictDoNothing(); - await db.insert(schema.wishlistsTable).values({ user_id: adminUser[0].id }).onConflictDoNothing(); + await db.insert(schema.wishlistsTable).values({ user_id: adminUser[0].id }).onConflictDoNothing(); - await db - .insert(schema.user_roles) - .values({ - user_id: adminUser[0].id, - role_id: adminRole[0].id, - primary: true, - }) - .onConflictDoNothing(); + await db + .insert(schema.user_roles) + .values({ + user_id: adminUser[0].id, + role_id: adminRole[0].id, + primary: true, + }) + .onConflictDoNothing(); - await db - .insert(schema.user_roles) - .values({ - user_id: adminUser[0].id, - role_id: userRole[0].id, - primary: false, - }) - .onConflictDoNothing(); + await db + .insert(schema.user_roles) + .values({ + user_id: adminUser[0].id, + role_id: userRole[0].id, + primary: false, + }) + .onConflictDoNothing(); - await Promise.all( - users.map(async (user) => { - const [insertedUser] = await db - .insert(schema.usersTable) - .values({ - ...user, - }) - .returning(); - await db.insert(schema.credentialsTable).values({ - user_id: insertedUser?.id, - type: schema.CredentialsType.PASSWORD, - secret_data: await hashingService.hash(user.password), - }); - await db.insert(schema.collections).values({ user_id: insertedUser?.id }); - await db.insert(schema.wishlistsTable).values({ user_id: insertedUser?.id }); - await Promise.all( - user.roles.map(async (role: JsonRole) => { - const foundRole = await db.query.rolesTable.findFirst({ - where: eq(schema.rolesTable.name, role.name), - }); - if (!foundRole) { - throw new Error('Role not found'); - } - await db.insert(schema.user_roles).values({ - user_id: insertedUser?.id, - role_id: foundRole?.id, - primary: role?.primary, - }); - }), - ); - }), - ); + await Promise.all( + users.map(async (user) => { + const [insertedUser] = await db + .insert(schema.usersTable) + .values({ + ...user, + }) + .returning(); + await db.insert(schema.credentialsTable).values({ + user_id: insertedUser?.id, + type: schema.CredentialsType.PASSWORD, + secret_data: await hashingService.hash(user.password), + }); + await db.insert(schema.collections).values({ user_id: insertedUser?.id }); + await db.insert(schema.wishlistsTable).values({ user_id: insertedUser?.id }); + await Promise.all( + user.roles.map(async (role: JsonRole) => { + const foundRole = await db.query.rolesTable.findFirst({ + where: eq(schema.rolesTable.name, role.name), + }); + if (!foundRole) { + throw new Error('Role not found'); + } + await db.insert(schema.user_roles).values({ + user_id: insertedUser?.id, + role_id: foundRole?.id, + primary: role?.primary, + }); + }), + ); + }), + ); } diff --git a/src/lib/server/api/databases/postgres/tables/users.table.ts b/src/lib/server/api/databases/postgres/tables/users.table.ts index 2ac7c58..676b077 100644 --- a/src/lib/server/api/databases/postgres/tables/users.table.ts +++ b/src/lib/server/api/databases/postgres/tables/users.table.ts @@ -1,32 +1,55 @@ -import {createId as cuid2} from '@paralleldrive/cuid2'; -import {type InferSelectModel, relations} from 'drizzle-orm'; -import {boolean, pgTable, text, uuid} from 'drizzle-orm/pg-core'; -import {createSelectSchema} from 'drizzle-zod'; -import {timestamps} from '../../../common/utils/table'; -import {user_roles} from './userRoles.table'; +import { createId as cuid2 } from '@paralleldrive/cuid2'; +import { type InferSelectModel, relations, getTableColumns } from 'drizzle-orm'; +import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import { createSelectSchema } from 'drizzle-zod'; +import { timestamps } from '../../../common/utils/table'; +import { user_roles } from './userRoles.table'; +/* -------------------------------------------------------------------------- */ +/* Table */ +/* -------------------------------------------------------------------------- */ export const usersTable = pgTable('users', { - id: uuid().primaryKey().defaultRandom(), - cuid: text() - .unique() - .$defaultFn(() => cuid2()), - username: text().unique(), - email: text().unique(), - first_name: text(), - last_name: text(), - verified: boolean().default(false), - receive_email: boolean().default(false), - email_verified: boolean().default(false), - picture: text(), - mfa_enabled: boolean().notNull().default(false), - theme: text().default('system'), - ...timestamps, + id: uuid().primaryKey().defaultRandom(), + cuid: text() + .unique() + .$defaultFn(() => cuid2()), + username: text().unique(), + email: text().unique(), + first_name: text(), + last_name: text(), + verified: boolean().default(false), + receive_email: boolean().default(false), + email_verified: boolean().default(false), + picture: text(), + mfa_enabled: boolean().notNull().default(false), + theme: text().default('system'), + ...timestamps, }); +/* -------------------------------------------------------------------------- */ +/* Relations */ +/* -------------------------------------------------------------------------- */ export const userRelations = relations(usersTable, ({ many }) => ({ - user_roles: many(user_roles), + user_roles: many(user_roles), })); +/* -------------------------------------------------------------------------- */ +/* Types */ +/* -------------------------------------------------------------------------- */ export const selectUserSchema = createSelectSchema(usersTable); export type Users = InferSelectModel; +export type UserWithRelations = Users & {}; + +const userColumns = getTableColumns(usersTable); + +export const publicUserColumns = { + id: userColumns.id, + cuid: userColumns.cuid, + username: userColumns.username, + first_name: userColumns.first_name, + last_name: userColumns.last_name, + picture: userColumns.picture, + theme: userColumns.theme, + ...timestamps, +}; diff --git a/src/lib/server/api/databases/redis/redis.service.ts b/src/lib/server/api/databases/redis/redis.service.ts new file mode 100644 index 0000000..52f0cb1 --- /dev/null +++ b/src/lib/server/api/databases/redis/redis.service.ts @@ -0,0 +1,35 @@ +import { config } from '$lib/server/api/common/config'; +import { injectable } from '@needle-di/core'; +import { Redis } from 'ioredis'; + +@injectable() +export class RedisService { + readonly redis: Redis; + + constructor() { + this.redis = new Redis(config.redis.url, { + maxRetriesPerRequest: null, + }); + } + + async get(data: { prefix: string; key: string }): Promise { + return this.redis.get(`${data.prefix}:${data.key}`); + } + + async set(data: { prefix: string; key: string; value: string }): Promise { + await this.redis.set(`${data.prefix}:${data.key}`, data.value); + } + + async delete(data: { prefix: string; key: string }): Promise { + await this.redis.del(`${data.prefix}:${data.key}`); + } + + async setWithExpiry(data: { + prefix: string; + key: string; + value: string; + expiry: number; + }): Promise { + await this.redis.set(`${data.prefix}:${data.key}`, data.value, 'EXAT', Math.floor(data.expiry)); + } +} diff --git a/src/lib/server/api/controllers/iam.controller.ts b/src/lib/server/api/iam/iam.controller.ts similarity index 88% rename from src/lib/server/api/controllers/iam.controller.ts rename to src/lib/server/api/iam/iam.controller.ts index daa62ea..fefcdaa 100644 --- a/src/lib/server/api/controllers/iam.controller.ts +++ b/src/lib/server/api/iam/iam.controller.ts @@ -1,20 +1,20 @@ -import { StatusCodes } from '$lib/constants/status-codes'; +import { limiter } from '$lib/server/api/common/middleware/rate-limit.middleware'; +import { requireFullAuth, requireTempAuth } from '$lib/server/api/common/middleware/require-auth.middleware'; import { Controller } from '$lib/server/api/common/types/controller'; import { createBlankSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies'; import { changePasswordDto } from '$lib/server/api/dtos/change-password.dto'; import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto'; import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto'; import { verifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto'; -import { limiter } from '$lib/server/api/middleware/rate-limiter.middleware'; -import { IamService } from '$lib/server/api/services/iam.service'; -import { LoginRequestsService } from '$lib/server/api/services/loginrequest.service'; -import { SessionsService } from '$lib/server/api/services/sessions.service'; +import { IamService } from '$lib/server/api/iam/iam.service'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; +import { LoginRequestsService } from '$lib/server/api/login/loginrequest.service'; +import { StatusCodes } from '$lib/utils/status-codes'; import { zValidator } from '@hono/zod-validator'; +import { inject, injectable } from '@needle-di/core'; import { openApi } from 'hono-zod-openapi'; -import { injectable, inject } from '@needle-di/core'; -import { requireFullAuth, requireTempAuth } from '../middleware/require-auth.middleware'; +import { UsersRepository } from '../users/users.repository'; import { iam, logout, updateEmail, updatePassword, updateProfile, verifyPassword } from './iam.routes'; -import { UsersRepository } from '../repositories/users.repository'; @injectable() export class IamController extends Controller { diff --git a/src/lib/server/api/iam/iam.routes.ts b/src/lib/server/api/iam/iam.routes.ts new file mode 100644 index 0000000..099d81c --- /dev/null +++ b/src/lib/server/api/iam/iam.routes.ts @@ -0,0 +1,145 @@ +import { unauthorizedSchema } from '$lib/server/api/common/utils/exceptions'; +import { selectUserSchema } from '$lib/server/api/databases/postgres/tables/users.table'; +import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto'; +import { StatusCodes } from '$lib/utils/status-codes'; +import { createErrorSchema } from 'stoker/openapi/schemas'; +import { taggedAuthRoute } from '../common/openapi/create-auth-route'; +import { changePasswordDto } from '../dtos/change-password.dto'; +import { verifyPasswordDto } from '../dtos/verify-password.dto'; + +const tag = 'IAM'; + +export const iam = taggedAuthRoute(tag, { + responses: { + [StatusCodes.OK]: { + description: 'User profile', + schema: selectUserSchema, + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const updateProfile = taggedAuthRoute(tag, { + request: { + json: updateProfileDto, + }, + responses: { + [StatusCodes.OK]: { + description: 'Updated User', + schema: selectUserSchema, + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'The validation error(s)', + schema: createErrorSchema(updateProfileDto), + mediaType: 'application/json', + }, + [StatusCodes.UNPROCESSABLE_ENTITY]: { + description: 'Username already in use', + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const verifyPassword = taggedAuthRoute(tag, { + request: { + json: verifyPasswordDto, + }, + responses: { + [StatusCodes.OK]: { + description: 'Password verified', + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'The validation error(s)', + schema: createErrorSchema(verifyPasswordDto), + mediaType: 'application/json', + }, + [StatusCodes.UNPROCESSABLE_ENTITY]: { + description: 'Incorrect password', + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const updatePassword = taggedAuthRoute(tag, { + request: { + json: changePasswordDto, + }, + responses: { + [StatusCodes.OK]: { + description: 'Password updated', + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'The validation error(s)', + schema: createErrorSchema(changePasswordDto), + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + [StatusCodes.FORBIDDEN]: { + description: 'Incorrect password', + mediaType: 'application/json', + }, + [StatusCodes.INTERNAL_SERVER_ERROR]: { + description: 'Error updating password', + mediaType: 'application/json', + }, + }, +}); + +export const updateEmail = taggedAuthRoute(tag, { + responses: { + [StatusCodes.OK]: { + description: 'Email updated', + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'The validation error(s)', + schema: createErrorSchema(changePasswordDto), + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + [StatusCodes.FORBIDDEN]: { + description: 'Cannot change email address', + mediaType: 'application/json', + }, + }, +}); + +export const logout = taggedAuthRoute(tag, { + responses: { + [StatusCodes.OK]: { + description: 'Logged out', + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); diff --git a/src/lib/server/api/services/iam.service.ts b/src/lib/server/api/iam/iam.service.ts similarity index 93% rename from src/lib/server/api/services/iam.service.ts rename to src/lib/server/api/iam/iam.service.ts index c10fcda..4cccd9c 100644 --- a/src/lib/server/api/services/iam.service.ts +++ b/src/lib/server/api/iam/iam.service.ts @@ -2,8 +2,8 @@ import type { ChangePasswordDto } from '$lib/server/api/dtos/change-password.dto import type { UpdateEmailDto } from '$lib/server/api/dtos/update-email.dto'; import type { UpdateProfileDto } from '$lib/server/api/dtos/update-profile.dto'; import type { VerifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto'; -import { SessionsService } from '$lib/server/api/services/sessions.service'; -import { UsersService } from '$lib/server/api/services/users.service'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; +import { UsersService } from '$lib/server/api/users/users.service'; import { inject, injectable } from '@needle-di/core'; @injectable() diff --git a/src/lib/server/api/iam/sessions/dtos/create-session-dto.ts b/src/lib/server/api/iam/sessions/dtos/create-session-dto.ts new file mode 100644 index 0000000..c6a4e4b --- /dev/null +++ b/src/lib/server/api/iam/sessions/dtos/create-session-dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const createSessionDto = z.object({ + id: z.string(), + userId: z.string(), + createdAt: z.date({ coerce: true }), + expiresAt: z.date({ coerce: true }), + ipCountry: z.string(), + ipAddress: z.string(), + twoFactorEnabled: z.boolean(), + twoFactorVerified: z.boolean(), +}); + +export type CreateSessionDto = z.infer; diff --git a/src/lib/server/api/iam/sessions/dtos/session.dto.ts b/src/lib/server/api/iam/sessions/dtos/session.dto.ts new file mode 100644 index 0000000..b0badc3 --- /dev/null +++ b/src/lib/server/api/iam/sessions/dtos/session.dto.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const sessionDto = z.object({ + id: z.string(), + userId: z.string(), + expiresAt: z.date(), + createdAt: z.date(), + fresh: z.boolean(), + twoFactorEnabled: z.boolean(), + twoFactorVerified: z.boolean(), +}); + +export type SessionDto = z.infer; diff --git a/src/lib/server/api/iam/sessions/sessions.repository.ts b/src/lib/server/api/iam/sessions/sessions.repository.ts new file mode 100644 index 0000000..110ccb0 --- /dev/null +++ b/src/lib/server/api/iam/sessions/sessions.repository.ts @@ -0,0 +1,31 @@ +import { RedisRepository } from '$lib/server/api/common/factories/redis-repository.factory'; +import { type CreateSessionDto, createSessionDto } from '$lib/server/api/iam/sessions/dtos/create-session-dto'; +import { injectable } from '@needle-di/core'; + +@injectable() +export class SessionsRepository extends RedisRepository<'session'> { + async get(id: string): Promise { + const response = await this.redis.get({ prefix: this.prefix, key: id }); + if (!response) return null; + return createSessionDto.parse(JSON.parse(response)); + } + + async findOneById(id: string): Promise { + const response = await this.redis.get({ prefix: this.prefix, key: id }); + if (!response) return null; + return createSessionDto.parse(JSON.parse(response)); + } + + delete(id: string) { + return this.redis.delete({ prefix: this.prefix, key: id }); + } + + create(createSessionDto: CreateSessionDto) { + return this.redis.setWithExpiry({ + prefix: this.prefix, + key: createSessionDto.id, + value: JSON.stringify(createSessionDto), + expiry: Math.floor(+createSessionDto.expiresAt / 1000), + }); + } +} diff --git a/src/lib/server/api/iam/sessions/sessions.service.ts b/src/lib/server/api/iam/sessions/sessions.service.ts new file mode 100644 index 0000000..369dadd --- /dev/null +++ b/src/lib/server/api/iam/sessions/sessions.service.ts @@ -0,0 +1,92 @@ +import { ConfigService } from '$lib/server/api/common/configs/config.service'; +import { RequestContextService } from '$lib/server/api/common/services/request-context.service'; +import { generateId } from '$lib/server/api/common/utils/crypto'; +import type { CreateSessionDto } from '$lib/server/api/iam/sessions/dtos/create-session-dto'; +import type { SessionDto } from '$lib/server/api/iam/sessions/dtos/session.dto'; +import { SessionsRepository } from '$lib/server/api/iam/sessions/sessions.repository'; +import { UsersService } from '$lib/server/api/users/users.service'; +import { inject, injectable } from '@needle-di/core'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; + +dayjs.extend(relativeTime); + +@injectable() +export class SessionsService { + private readonly sessionCookieName = 'session'; + + constructor( + private sessionsRepository = inject(SessionsRepository), + private requestContextService = inject(RequestContextService), + private configService = inject(ConfigService), + private usersService = inject(UsersService), + ) {} + + setSessionCookie(session: SessionDto) { + return setSignedCookie(this.requestContextService.getContext(), this.sessionCookieName, session.id, this.configService.envs.SIGNING_SECRET, { + httpOnly: true, + sameSite: 'lax', + secure: this.configService.envs.ENV === 'prod', + path: '/', + expires: session.expiresAt, + }); + } + + async getSessionCookie(): Promise { + const session = await getSignedCookie(this.requestContextService.getContext(), this.configService.envs.SIGNING_SECRET, this.sessionCookieName); + if (!session) return null; + return session; + } + + deleteSessionCookie() { + return deleteCookie(this.requestContextService.getContext(), this.sessionCookieName); + } + + async createSession(userId: string, twoFactorVerified = false, ipCountry = 'unknown', ipAddress = 'unknown'): Promise { + const user = await this.usersService.findOneById(userId); + if (!user) throw new Error('User not found'); + + const session: CreateSessionDto = { + id: this.generateSessionToken(), + userId, + createdAt: dayjs().toDate(), + expiresAt: dayjs().add(30, 'day').toDate(), + ipCountry, + ipAddress, + twoFactorEnabled: user.mfa_enabled, + twoFactorVerified, + }; + + await this.sessionsRepository.create(session); + return { ...session, fresh: true }; + } + + async validateSession(sessionId: string): Promise { + // Check if session exists + const existingSession = await this.sessionsRepository.get(sessionId); + + // If session does not exist, return null + if (!existingSession) return null; + + // If session exists, check if it should be extended + const shouldExtendSession = dayjs(existingSession.expiresAt).diff(Date.now(), 'day') < 15; + + // If session should be extended, update the session in the database + if (shouldExtendSession) { + existingSession.expiresAt = dayjs().add(30, 'day').toDate(); + await this.sessionsRepository.create({ ...existingSession }); + return { ...existingSession, fresh: true }; + } + + return { ...existingSession, fresh: false }; + } + + async invalidateSession(sessionId: string): Promise { + await this.sessionsRepository.delete(sessionId); + } + + private generateSessionToken(): string { + return generateId(); + } +} diff --git a/src/lib/server/api/index.ts b/src/lib/server/api/index.ts index 4ecf447..6655bbb 100644 --- a/src/lib/server/api/index.ts +++ b/src/lib/server/api/index.ts @@ -1,51 +1,21 @@ -import createApp from "$lib/server/api/common/create-app"; -import configureOpenAPI from "$lib/server/api/configure-open-api"; -import { CollectionController } from "$lib/server/api/controllers/collection.controller"; -import { MfaController } from "$lib/server/api/controllers/mfa.controller"; -import { OAuthController } from "$lib/server/api/controllers/oauth.controller"; -import { SignupController } from "$lib/server/api/controllers/signup.controller"; -import { UserController } from "$lib/server/api/controllers/user.controller"; -import { WishlistController } from "$lib/server/api/controllers/wishlist.controller"; -import { AuthCleanupJobs } from "$lib/server/api/jobs/auth-cleanup.job"; -import { Container } from "@needle-di/core"; -import { extendZodWithOpenApi } from "hono-zod-openapi"; -import { hc } from "hono/client"; -import { z } from "zod"; -import { config } from "./common/config"; -import { IamController } from "./controllers/iam.controller"; -import { LoginController } from "./controllers/login.controller"; +import { Container } from '@needle-di/core'; +import { extendZodWithOpenApi } from 'hono-zod-openapi'; +import { z } from 'zod'; +import { ApplicationController } from './application.controller'; +import { ApplicationModule } from './application.module'; extendZodWithOpenApi(z); -const container = new Container(); +const applicationController = new Container().get(ApplicationController); +const applicationModule = new Container().get(ApplicationModule); -export const app = createApp(); +/* ------------------------------ startServer ------------------------------ */ +export function startServer() { + return applicationModule.start(); +} -/* -------------------------------------------------------------------------- */ -/* Routes */ -/* -------------------------------------------------------------------------- */ -const routes = app - .route("/me", container.get(IamController).routes()) - .route("/user", container.get(UserController).routes()) - .route("/login", container.get(LoginController).routes()) - .route("/oauth", container.get(OAuthController).routes()) - .route("/signup", container.get(SignupController).routes()) - .route("/wishlists", container.get(WishlistController).routes()) - .route("/collections", container.get(CollectionController).routes()) - .route("/mfa", container.get(MfaController).routes()) - .get("/", (c) => c.json({ message: "Server is healthy" })); +/* ----------------------------------- api ---------------------------------- */ +export const routes = applicationController.registerControllers(); -configureOpenAPI(app); - -/* -------------------------------------------------------------------------- */ -/* Cron Jobs */ -/* -------------------------------------------------------------------------- */ -container.get(AuthCleanupJobs).deleteStaleEmailVerificationRequests(); -container.get(AuthCleanupJobs).deleteStaleLoginRequests(); - -/* -------------------------------------------------------------------------- */ -/* Exports */ -/* -------------------------------------------------------------------------- */ -export const rpc = hc(config.api.origin); -export type ApiClient = typeof rpc; +/* ---------------------------------- Types --------------------------------- */ export type ApiRoutes = typeof routes; diff --git a/src/lib/server/api/controllers/login.controller.ts b/src/lib/server/api/login/login.controller.ts similarity index 84% rename from src/lib/server/api/controllers/login.controller.ts rename to src/lib/server/api/login/login.controller.ts index 7fa627a..8f2771a 100644 --- a/src/lib/server/api/controllers/login.controller.ts +++ b/src/lib/server/api/login/login.controller.ts @@ -1,13 +1,13 @@ +import { limiter } from '$lib/server/api/common/middleware/rate-limit.middleware'; import { Controller } from '$lib/server/api/common/types/controller'; import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies'; import { signinUsernameDto } from '$lib/server/api/dtos/signin-username.dto'; -import { SessionsService } from '$lib/server/api/services/sessions.service'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; import { zValidator } from '@hono/zod-validator'; -import { openApi } from 'hono-zod-openapi'; import { inject, injectable } from '@needle-di/core'; -import { limiter } from '../middleware/rate-limiter.middleware'; -import { LoginRequestsService } from '../services/loginrequest.service'; +import { openApi } from 'hono-zod-openapi'; import { signinUsername } from './login.routes'; +import { LoginRequestsService } from './loginrequest.service'; @injectable() export class LoginController extends Controller { diff --git a/src/lib/server/api/login/login.routes.ts b/src/lib/server/api/login/login.routes.ts new file mode 100644 index 0000000..23a2dfc --- /dev/null +++ b/src/lib/server/api/login/login.routes.ts @@ -0,0 +1,21 @@ +import { StatusCodes } from '$lib/utils/status-codes'; +import { defineOpenApiOperation } from 'hono-zod-openapi'; +import { createErrorSchema } from 'stoker/openapi/schemas'; +import { signinUsernameDto } from '../dtos/signin-username.dto'; + +export const signinUsername = defineOpenApiOperation({ + tags: ['Login'], + summary: 'Sign in with username', + description: 'Sign in with username', + responses: { + [StatusCodes.OK]: { + description: 'Sign in with username', + schema: signinUsernameDto, + }, + [StatusCodes.UNPROCESSABLE_ENTITY]: { + description: 'The validation error(s)', + schema: createErrorSchema(signinUsernameDto), + mediaType: 'application/json', + }, + }, +}); diff --git a/src/lib/server/api/services/loginrequest.service.ts b/src/lib/server/api/login/loginrequest.service.ts similarity index 88% rename from src/lib/server/api/services/loginrequest.service.ts rename to src/lib/server/api/login/loginrequest.service.ts index 50d67b0..38b9a1f 100644 --- a/src/lib/server/api/services/loginrequest.service.ts +++ b/src/lib/server/api/login/loginrequest.service.ts @@ -1,14 +1,14 @@ +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; import type { SigninUsernameDto } from '$lib/server/api/dtos/signin-username.dto'; -import { SessionsService } from '$lib/server/api/services/sessions.service'; -import type { HonoRequest } from 'hono'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; import { inject, injectable } from '@needle-di/core'; -import { BadRequest, NotFound } from '../common/exceptions'; +import type { HonoRequest } from 'hono'; +import { BadRequest, NotFound } from '../common/utils/exceptions'; import type { Credentials } from '../databases/postgres/tables'; -import { CredentialsRepository } from '../repositories/credentials.repository'; -import { UsersRepository } from '../repositories/users.repository'; -import { MailerService } from './mailer.service'; -import { TokensService } from './tokens.service'; -import { DrizzleService } from '$lib/server/api/services/drizzle.service'; +import { MailerService } from '../services/mailer.service'; +import { TokensService } from '../services/tokens.service'; +import { CredentialsRepository } from '../users/credentials.repository'; +import { UsersRepository } from '../users/users.repository'; @injectable() export class LoginRequestsService { diff --git a/src/lib/server/api/controllers/mfa.controller.ts b/src/lib/server/api/mfa/mfa.controller.ts similarity index 91% rename from src/lib/server/api/controllers/mfa.controller.ts rename to src/lib/server/api/mfa/mfa.controller.ts index 0ed71ef..8542768 100644 --- a/src/lib/server/api/controllers/mfa.controller.ts +++ b/src/lib/server/api/mfa/mfa.controller.ts @@ -1,17 +1,17 @@ -import { StatusCodes } from '$lib/constants/status-codes'; +import { requireFullAuth, requireTempAuth } from '$lib/server/api/common/middleware/require-auth.middleware'; import { Controller } from '$lib/server/api/common/types/controller'; import { verifyTotpDto } from '$lib/server/api/dtos/verify-totp.dto'; -import { RecoveryCodesService } from '$lib/server/api/services/recovery-codes.service'; -import { TotpService } from '$lib/server/api/services/totp.service'; -import { UsersService } from '$lib/server/api/services/users.service'; +import { TotpService } from '$lib/server/api/mfa/totp.service'; +import { RecoveryCodesService } from '$lib/server/api/users/recovery-codes.service'; +import { UsersService } from '$lib/server/api/users/users.service'; +import { StatusCodes } from '$lib/utils/status-codes'; import { zValidator } from '@hono/zod-validator'; import { inject, injectable } from '@needle-di/core'; -import { CredentialsType } from '../databases/postgres/tables'; -import { requireFullAuth, requireTempAuth } from '../middleware/require-auth.middleware'; -import { createTwoFactorSchema } from '../dtos/create-totp.dto'; import { decodeBase64 } from '@oslojs/encoding'; -import { LoginRequestsService } from '../services/loginrequest.service'; import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '../common/utils/cookies'; +import { CredentialsType } from '../databases/postgres/tables'; +import { createTwoFactorSchema } from '../dtos/create-totp.dto'; +import { LoginRequestsService } from '../login/loginrequest.service'; @injectable() export class MfaController extends Controller { diff --git a/src/lib/server/api/services/totp.service.ts b/src/lib/server/api/mfa/totp.service.ts similarity index 85% rename from src/lib/server/api/services/totp.service.ts rename to src/lib/server/api/mfa/totp.service.ts index 8aaea80..1ff3499 100644 --- a/src/lib/server/api/services/totp.service.ts +++ b/src/lib/server/api/mfa/totp.service.ts @@ -1,15 +1,15 @@ -import { CredentialsRepository } from '$lib/server/api/repositories/credentials.repository'; import { inject, injectable } from '@needle-di/core'; -import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; +import { decodeBase64, encodeBase64 } from '@oslojs/encoding'; import { generateTOTP, verifyTOTP } from '@oslojs/otp'; +import { EncryptionService } from '../common/services/encryption.service'; import type { CredentialsType } from '../databases/postgres/tables'; -import { EncryptionService } from './encryption.service'; +import { CredentialsRepository } from '../users/credentials.repository'; @injectable() export class TotpService { constructor( private credentialsRepository = inject(CredentialsRepository), - private encryptionService = inject(EncryptionService) + private encryptionService = inject(EncryptionService), ) {} async findOneByUserId(userId: string) { diff --git a/src/lib/server/api/middleware/auth.middleware.ts b/src/lib/server/api/middleware/auth.middleware.ts deleted file mode 100644 index 27ef290..0000000 --- a/src/lib/server/api/middleware/auth.middleware.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - cookieExpiresAt, - cookieName, - createBlankSessionTokenCookie, - createSessionTokenCookie, - type SessionCookie, - setSessionCookie, -} from '$lib/server/api/common/utils/cookies'; -import {SessionsService} from '$lib/server/api/services/sessions.service'; -import type {MiddlewareHandler} from 'hono'; -import {getCookie} from 'hono/cookie'; -import {createMiddleware} from 'hono/factory'; -import {verifyRequestOrigin} from 'oslo/request'; -import { Container } from '@needle-di/core'; -import type {AppBindings} from '../common/types/hono'; - -// resolve dependencies from the container -const container = new Container(); -const sessionService = container.get(SessionsService); - -// CSRF protection middleware -export const verifyOrigin: MiddlewareHandler = createMiddleware(async (c, next) => { - if (c.req.method === 'GET') { - return next(); - } - const originHeader = c.req.header('Origin') ?? null; - const hostHeader = c.req.header('Host') ?? null; - if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { - return c.body(null, 403); - } - return next(); -}); - -export const validateAuthSession: MiddlewareHandler = createMiddleware(async (c, next) => { - const sessionId = getCookie(c, cookieName) ?? null; - if (!sessionId) { - c.set('user', null); - c.set('session', null); - return next(); - } - - const { session, user } = await sessionService.validateSessionToken(sessionId); - let sessionCookie: SessionCookie; - if (session !== null) { - sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); - } else { - sessionCookie = createBlankSessionTokenCookie(); - } - setSessionCookie(c, sessionCookie); - c.set('session', session); - c.set('user', user); - return next(); -}); diff --git a/src/lib/server/api/middleware/rate-limiter.middleware.ts b/src/lib/server/api/middleware/rate-limiter.middleware.ts deleted file mode 100644 index ac01f72..0000000 --- a/src/lib/server/api/middleware/rate-limiter.middleware.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {rateLimiter} from 'hono-rate-limiter'; -import {RedisStore} from 'rate-limit-redis'; -import { Container } from '@needle-di/core'; -import type {AppBindings} from '../common/types/hono'; -import {RedisService} from '../services/redis.service'; - -const container = new Container(); -// resolve dependencies from the container -const { client } = container.get(RedisService); - -export function limiter({ - limit, - minutes, - key = '', -}: { - limit: number; - minutes: number; - key?: string; -}) { - return rateLimiter({ - windowMs: minutes * 60 * 1000, // every x minutes - limit, // Limit each IP to 100 requests per `window` (here, per 15 minutes). - standardHeaders: 'draft-6', // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header - keyGenerator: (c) => { - const vars = c.var as AppBindings['Variables']; - const clientKey = vars.user?.id || c.req.header('x-forwarded-for'); - const pathKey = key || c.req.routePath; - return `${clientKey}_${pathKey}`; - }, // Method to generate custom identifiers for clients. - // Redis store configuration - store: new RedisStore({ - // @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis - sendCommand: (...args: string[]) => client.call(...args), - }) as any, - }); -} diff --git a/src/lib/server/api/oauth/oauth.controller.ts b/src/lib/server/api/oauth/oauth.controller.ts new file mode 100644 index 0000000..cb9f2e3 --- /dev/null +++ b/src/lib/server/api/oauth/oauth.controller.ts @@ -0,0 +1,125 @@ +import { Controller } from '$lib/server/api/common/types/controller'; +import type { OAuthUser } from '$lib/server/api/common/types/oauth'; +import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; +import { OAuthService } from '$lib/server/api/oauth/oauth.service'; +import { github, google } from '$lib/server/auth'; +import { inject, injectable } from '@needle-di/core'; +import { OAuth2RequestError } from 'arctic'; +import { getCookie } from 'hono/cookie'; + +@injectable() +export class OAuthController extends Controller { + constructor( + private oauthService = inject(OAuthService), + private sessionsService = inject(SessionsService), + ) { + super(); + } + + routes() { + return this.controller + .get('/github', async (c) => { + try { + const code = c.req.query('code')?.toString() ?? null; + const state = c.req.query('state')?.toString() ?? null; + const storedState = getCookie(c).github_oauth_state ?? null; + + if (!code || !state || !storedState || state !== storedState) { + return c.body(null, 400); + } + + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }); + const githubUser: GitHubUser = await githubUserResponse.json(); + + const oAuthUser: OAuthUser = { + sub: `${githubUser.id}`, + username: githubUser.login, + email: undefined, + }; + + const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'github'); + + const sessionToken = this.sessionsService.generateSessionToken(); + const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false); + const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); + setSessionCookie(c, sessionCookie); + return c.json({ message: 'ok' }); + } catch (error) { + console.error(error); + // the specific error message depends on the provider + if (error instanceof OAuth2RequestError) { + // invalid code + return c.body(null, 400); + } + return c.body(null, 500); + } + }) + .get('/google', async (c) => { + try { + const code = c.req.query('code')?.toString() ?? null; + const state = c.req.query('state')?.toString() ?? null; + const storedState = getCookie(c).google_oauth_state ?? null; + const storedCodeVerifier = getCookie(c).google_oauth_code_verifier ?? null; + + if (!code || !storedState || !storedCodeVerifier || state !== storedState) { + return c.body(null, 400); + } + + const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier); + const googleUserResponse = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }); + const googleUser: GoogleUser = await googleUserResponse.json(); + + const oAuthUser: OAuthUser = { + sub: googleUser.sub, + given_name: googleUser.given_name, + family_name: googleUser.family_name, + picture: googleUser.picture, + username: googleUser.email, + email: googleUser.email, + email_verified: googleUser.email_verified, + }; + + const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'google'); + const sessionToken = this.sessionsService.generateSessionToken(); + const session = await this.sessionsService.createSession(sessionToken, userId, '', '', false, false); + const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); + setSessionCookie(c, sessionCookie); + + return c.json({ message: 'ok' }); + } catch (error) { + console.error(error); + // the specific error message depends on the provider + if (error instanceof OAuth2RequestError) { + // invalid code + return c.body(null, 400); + } + return c.body(null, 500); + } + }); + } +} + +interface GitHubUser { + id: number; + login: string; +} + +interface GoogleUser { + sub: string; + name: string; + given_name: string; + family_name: string; + picture: string; + email: string; + email_verified: boolean; +} diff --git a/src/lib/server/api/oauth/oauth.service.ts b/src/lib/server/api/oauth/oauth.service.ts new file mode 100644 index 0000000..2a5c6c9 --- /dev/null +++ b/src/lib/server/api/oauth/oauth.service.ts @@ -0,0 +1,27 @@ +import type { OAuthProviders, OAuthUser } from '$lib/server/api/common/types/oauth'; +import { inject, injectable } from '@needle-di/core'; +import { FederatedIdentityRepository } from '../users/federated_identity.repository'; +import { UsersService } from '../users/users.service'; + +@injectable() +export class OAuthService { + constructor( + private federatedIdentityRepository = inject(FederatedIdentityRepository), + private usersService = inject(UsersService), + ) {} + + async handleOAuthUser(oAuthUser: OAuthUser, oauthProvider: OAuthProviders) { + const federatedUser = await this.federatedIdentityRepository.findOneByFederatedUserIdAndProvider(oAuthUser.sub, oauthProvider); + + if (federatedUser) { + return federatedUser.user_id; + } + + const user = await this.usersService.createOAuthUser(oAuthUser, oauthProvider); + + if (!user) { + throw new Error('Failed to create user'); + } + return user.id; + } +} diff --git a/src/lib/server/api/repositories/collections.repository.ts b/src/lib/server/api/repositories/collections.repository.ts deleted file mode 100644 index 097402f..0000000 --- a/src/lib/server/api/repositories/collections.repository.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository'; -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {collections} from '../databases/postgres/tables'; - -export type CreateCollection = InferInsertModel; -export type UpdateCollection = Partial; - -@injectable() -export class CollectionsRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async findAll(db = this.drizzle.db) { - return db.query.collections.findMany(); - } - - async findOneById(id: string, db = this.drizzle.db) { - return db.query.collections.findFirst({ - where: eq(collections.id, id), - columns: { - cuid: true, - name: true, - }, - }); - } - - async findOneByCuid(cuid: string, db = this.drizzle.db) { - return db.query.collections.findFirst({ - where: eq(collections.cuid, cuid), - columns: { - cuid: true, - name: true, - }, - }); - } - - async findOneByUserId(userId: string, db = this.drizzle.db) { - return db.query.collections.findFirst({ - where: eq(collections.user_id, userId), - columns: { - cuid: true, - name: true, - }, - }); - } - - async findAllByUserId(userId: string, db = this.drizzle.db) { - return db.query.collections.findMany({ - where: eq(collections.user_id, userId), - columns: { - cuid: true, - name: true, - createdAt: true, - }, - }); - } - - async findAllByUserIdWithDetails(userId: string, db = this.drizzle.db) { - return db.query.collections.findMany({ - where: eq(collections.user_id, userId), - columns: { - cuid: true, - name: true, - }, - with: { - collection_items: { - columns: { - cuid: true, - }, - }, - }, - }); - } - - async create(data: CreateCollection, db = this.drizzle.db) { - return db.insert(collections).values(data).returning().then(takeFirstOrThrow); - } - - async update(id: string, data: UpdateCollection, db = this.drizzle.db) { - return db.update(collections).set(data).where(eq(collections.id, id)).returning().then(takeFirstOrThrow); - } -} diff --git a/src/lib/server/api/repositories/credentials.repository.ts b/src/lib/server/api/repositories/credentials.repository.ts deleted file mode 100644 index e77d117..0000000 --- a/src/lib/server/api/repositories/credentials.repository.ts +++ /dev/null @@ -1,70 +0,0 @@ -import {credentialsTable, CredentialsType} from '$lib/server/api/databases/postgres/tables/credentials.table'; -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {and, eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {takeFirstOrThrow} from '../common/utils/repository'; - -export type CreateCredentials = InferInsertModel; -export type UpdateCredentials = Partial; -export type DeleteCredentials = Pick; - -@injectable() -export class CredentialsRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async findOneByUserId(userId: string, db = this.drizzle.db) { - return db.query.credentialsTable.findFirst({ - where: eq(credentialsTable.user_id, userId), - }); - } - - async findOneByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) { - return db.query.credentialsTable.findFirst({ - where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type)), - }); - } - - async findPasswordCredentialsByUserId(userId: string, db = this.drizzle.db) { - return db.query.credentialsTable.findFirst({ - where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.PASSWORD)), - }); - } - - async findTOTPCredentialsByUserId(userId: string, db = this.drizzle.db) { - return db.query.credentialsTable.findFirst({ - where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.TOTP)), - }); - } - - async findOneById(id: string, db = this.drizzle.db) { - return db.query.credentialsTable.findFirst({ - where: eq(credentialsTable.id, id), - }); - } - - async findOneByIdOrThrow(id: string, db = this.drizzle.db) { - const credentials = await this.findOneById(id, db); - if (!credentials) throw Error('Credentials not found'); - return credentials; - } - - async create(data: CreateCredentials, db = this.drizzle.db) { - return db.insert(credentialsTable).values(data).returning().then(takeFirstOrThrow); - } - - async update(id: string, data: UpdateCredentials, db = this.drizzle.db) { - return db.update(credentialsTable).set(data).where(eq(credentialsTable.id, id)).returning().then(takeFirstOrThrow); - } - - async delete(id: string, db = this.drizzle.db) { - return db.delete(credentialsTable).where(eq(credentialsTable.id, id)); - } - - async deleteByUserId(userId: string, db = this.drizzle.db) { - return db.delete(credentialsTable).where(eq(credentialsTable.user_id, userId)); - } - - async deleteByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) { - return db.delete(credentialsTable).where(and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type))); - } -} diff --git a/src/lib/server/api/repositories/federated_identity.repository.ts b/src/lib/server/api/repositories/federated_identity.repository.ts deleted file mode 100644 index 3b250d9..0000000 --- a/src/lib/server/api/repositories/federated_identity.repository.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {and, eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {takeFirstOrThrow} from '../common/utils/repository'; -import {federatedIdentityTable} from '../databases/postgres/tables'; -import {DrizzleService} from '../services/drizzle.service'; - -export type CreateFederatedIdentity = InferInsertModel; - -@injectable() -export class FederatedIdentityRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async findOneByUserIdAndProvider(userId: string, provider: string) { - return this.drizzle.db.query.federatedIdentityTable.findFirst({ - where: and(eq(federatedIdentityTable.user_id, userId), eq(federatedIdentityTable.identity_provider, provider)), - }); - } - - async findOneByFederatedUserIdAndProvider(federatedUserId: string, provider: string) { - return this.drizzle.db.query.federatedIdentityTable.findFirst({ - where: and(eq(federatedIdentityTable.federated_user_id, federatedUserId), eq(federatedIdentityTable.identity_provider, provider)), - }); - } - - async create(data: CreateFederatedIdentity, db = this.drizzle.db) { - return db.insert(federatedIdentityTable).values(data).returning().then(takeFirstOrThrow); - } -} diff --git a/src/lib/server/api/repositories/recovery-codes.repository.ts b/src/lib/server/api/repositories/recovery-codes.repository.ts deleted file mode 100644 index f9d602e..0000000 --- a/src/lib/server/api/repositories/recovery-codes.repository.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository'; -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {and, eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {recoveryCodesTable} from '../databases/postgres/tables'; - -export type CreateRecoveryCodes = InferInsertModel; - -@injectable() -export class RecoveryCodesRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async create(data: CreateRecoveryCodes, db = this.drizzle.db) { - return db.insert(recoveryCodesTable).values(data).returning().then(takeFirstOrThrow); - } - - async findAllByUserId(userId: string, db = this.drizzle.db) { - return db.query.recoveryCodesTable.findMany({ - where: eq(recoveryCodesTable.userId, userId), - }); - } - - async findAllNotUsedByUserId(userId: string, db = this.drizzle.db) { - return db.query.recoveryCodesTable.findMany({ - where: and(eq(recoveryCodesTable.userId, userId), eq(recoveryCodesTable.used, false)), - }); - } - - async deleteAllByUserId(userId: string, db = this.drizzle.db) { - return db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId)); - } -} diff --git a/src/lib/server/api/repositories/roles.repository.ts b/src/lib/server/api/repositories/roles.repository.ts deleted file mode 100644 index 40999c6..0000000 --- a/src/lib/server/api/repositories/roles.repository.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {takeFirstOrThrow} from '../common/utils/repository'; -import {rolesTable} from '../databases/postgres/tables'; - -export type CreateRole = InferInsertModel; -export type UpdateRole = Partial; - -@injectable() -export class RolesRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async findOneById(id: string, db = this.drizzle.db) { - return db.query.rolesTable.findFirst({ - where: eq(rolesTable.id, id), - }); - } - - async findOneByIdOrThrow(id: string, db = this.drizzle.db) { - const role = await this.findOneById(id, db); - if (!role) throw Error('Role not found'); - return role; - } - - async findAll(db = this.drizzle.db) { - return db.query.rolesTable.findMany(); - } - - async findOneByName(name: string, db = this.drizzle.db) { - return db.query.rolesTable.findFirst({ - where: eq(rolesTable.name, name), - }); - } - - async findOneByNameOrThrow(name: string, db = this.drizzle.db) { - const role = await this.findOneByName(name, db); - if (!role) throw Error('Role not found'); - return role; - } - - async create(data: CreateRole, db = this.drizzle.db) { - return db.insert(rolesTable).values(data).returning().then(takeFirstOrThrow); - } - - async update(id: string, data: UpdateRole, db = this.drizzle.db) { - return db.update(rolesTable).set(data).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow); - } - - async delete(id: string, db = this.drizzle.db) { - return db.delete(rolesTable).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow); - } -} diff --git a/src/lib/server/api/repositories/sessions.repository.ts b/src/lib/server/api/repositories/sessions.repository.ts deleted file mode 100644 index 0238526..0000000 --- a/src/lib/server/api/repositories/sessions.repository.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository'; -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {sessionsTable, usersTable} from '../databases/postgres/tables'; - -export type CreateSession = InferInsertModel; - -@injectable() -export class SessionsRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async create(data: CreateSession, db = this.drizzle.db) { - return db.insert(sessionsTable).values(data).returning().then(takeFirstOrThrow); - } - - async findBySessionId(sessionId: string, db = this.drizzle.db) { - return await db - .select({ user: usersTable, session: sessionsTable }) - .from(sessionsTable) - .innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id)) - .where(eq(sessionsTable.id, sessionId)); - } - - async deleteBySessionId(sessionId: string, db = this.drizzle.db) { - return db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId)); - } - - async updateSessionExpiresAt(sessionId: string, expiresAt: Date, db = this.drizzle.db) { - db.update(sessionsTable).set({ expiresAt }).where(eq(sessionsTable.id, sessionId)); - } -} diff --git a/src/lib/server/api/repositories/user_roles.repository.ts b/src/lib/server/api/repositories/user_roles.repository.ts deleted file mode 100644 index c605b1a..0000000 --- a/src/lib/server/api/repositories/user_roles.repository.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {takeFirstOrThrow} from '../common/utils/repository'; -import {user_roles} from '../databases/postgres/tables'; - -export type CreateUserRole = InferInsertModel; -export type UpdateUserRole = Partial; - -@injectable() -export class UserRolesRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async findOneById(id: string, db = this.drizzle.db) { - return db.query.user_roles.findFirst({ - where: eq(user_roles.id, id), - }); - } - - async findOneByIdOrThrow(id: string, db = this.drizzle.db) { - const userRole = await this.findOneById(id, db); - if (!userRole) throw Error('User not found'); - return userRole; - } - - async findAllByUserId(userId: string, db = this.drizzle.db) { - return db.query.user_roles.findMany({ - where: eq(user_roles.user_id, userId), - }); - } - - async create(data: CreateUserRole, db = this.drizzle.db) { - return db.insert(user_roles).values(data).returning().then(takeFirstOrThrow); - } - - async delete(id: string, db = this.drizzle.db) { - return db.delete(user_roles).where(eq(user_roles.id, id)).returning().then(takeFirstOrThrow); - } -} diff --git a/src/lib/server/api/repositories/users.repository.ts b/src/lib/server/api/repositories/users.repository.ts deleted file mode 100644 index dccc1c8..0000000 --- a/src/lib/server/api/repositories/users.repository.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {usersTable} from '$lib/server/api/databases/postgres/tables/users.table'; -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {takeFirstOrThrow} from '../common/utils/repository'; - -export type CreateUser = InferInsertModel; -export type UpdateUser = Partial; - -@injectable() -export class UsersRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async findOneById(id: string, db = this.drizzle.db) { - return db.query.usersTable.findFirst({ - where: eq(usersTable.id, id), - }); - } - - async findOneByIdOrThrow(id: string, db = this.drizzle.db) { - const user = await this.findOneById(id); - if (!user) throw Error('User not found'); - return user; - } - - async findOneByUsername(username: string, db = this.drizzle.db) { - return db.query.usersTable.findFirst({ - where: eq(usersTable.username, username), - }); - } - - async findOneByEmail(email: string, db = this.drizzle.db) { - return db.query.usersTable.findFirst({ - where: eq(usersTable.email, email), - }); - } - - async create(data: CreateUser, db = this.drizzle.db) { - return db.insert(usersTable).values(data).returning().then(takeFirstOrThrow); - } - - async update(id: string, data: UpdateUser, db = this.drizzle.db) { - return db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow); - } - - async delete(id: string, db = this.drizzle.db) { - return db.delete(usersTable).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow); - } -} diff --git a/src/lib/server/api/repositories/wishlists.repository.ts b/src/lib/server/api/repositories/wishlists.repository.ts deleted file mode 100644 index f03310d..0000000 --- a/src/lib/server/api/repositories/wishlists.repository.ts +++ /dev/null @@ -1,66 +0,0 @@ -import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {eq, type InferInsertModel} from 'drizzle-orm'; -import {inject, injectable} from '@needle-di/core'; -import {takeFirstOrThrow} from '../common/utils/repository'; -import {wishlistsTable} from '../databases/postgres/tables'; - -export type CreateWishlist = InferInsertModel; -export type UpdateWishlist = Partial; - -@injectable() -export class WishlistsRepository { - constructor(private drizzle = inject(DrizzleService)) {} - - async findAll(db = this.drizzle.db) { - return db.query.wishlistsTable.findMany(); - } - - async findOneById(id: string, db = this.drizzle.db) { - return db.query.wishlistsTable.findFirst({ - where: eq(wishlistsTable.id, id), - columns: { - cuid: true, - name: true, - }, - }); - } - - async findOneByCuid(cuid: string, db = this.drizzle.db) { - return db.query.wishlistsTable.findFirst({ - where: eq(wishlistsTable.cuid, cuid), - columns: { - cuid: true, - name: true, - }, - }); - } - - async findOneByUserId(userId: string, db = this.drizzle.db) { - return db.query.wishlistsTable.findFirst({ - where: eq(wishlistsTable.user_id, userId), - columns: { - cuid: true, - name: true, - }, - }); - } - - async findAllByUserId(userId: string, db = this.drizzle.db) { - return db.query.wishlistsTable.findMany({ - where: eq(wishlistsTable.user_id, userId), - columns: { - cuid: true, - name: true, - createdAt: true, - }, - }); - } - - async create(data: CreateWishlist, db = this.drizzle.db) { - return db.insert(wishlistsTable).values(data).returning().then(takeFirstOrThrow); - } - - async update(id: string, data: UpdateWishlist, db = this.drizzle.db) { - return db.update(wishlistsTable).set(data).where(eq(wishlistsTable.id, id)).returning().then(takeFirstOrThrow); - } -} diff --git a/src/lib/server/api/services/collections.service.ts b/src/lib/server/api/services/collections.service.ts deleted file mode 100644 index e029ab0..0000000 --- a/src/lib/server/api/services/collections.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type {db} from '$lib/server/api/packages/drizzle' -import {generateRandomAnimalName} from '$lib/utils/randomDataUtil' -import {inject, injectable} from '@needle-di/core' -import {CollectionsRepository} from '../repositories/collections.repository' - -@injectable() -export class CollectionsService { - constructor(private collectionsRepository = inject(CollectionsRepository)) {} - - async findOneByUserId(userId: string) { - return this.collectionsRepository.findOneByUserId(userId) - } - - async findAllByUserId(userId: string) { - return this.collectionsRepository.findAllByUserId(userId) - } - - async findAllByUserIdWithDetails(userId: string) { - return this.collectionsRepository.findAllByUserIdWithDetails(userId) - } - - async findOneById(id: string) { - return this.collectionsRepository.findOneById(id) - } - - async findOneByCuid(cuid: string) { - return this.collectionsRepository.findOneByCuid(cuid) - } - - async createEmptyNoName(userId: string, trx: Parameters[0]>[0] | null = null) { - return this.createEmpty(userId, null, trx) - } - - async createEmpty(userId: string, name: string | null, trx: Parameters[0]>[0] | null = null) { - if (!trx) { - return this.collectionsRepository.create({ - user_id: userId, - name: name ?? generateRandomAnimalName(), - }) - } - - return this.collectionsRepository.create( - { - user_id: userId, - name: name ?? generateRandomAnimalName(), - }, - trx, - ) - } -} diff --git a/src/lib/server/api/services/drizzle.service.ts b/src/lib/server/api/services/drizzle.service.ts deleted file mode 100644 index ae2154c..0000000 --- a/src/lib/server/api/services/drizzle.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {drizzle, type NodePgDatabase} from 'drizzle-orm/node-postgres'; -import pg from 'pg'; -import {type Disposable, injectable} from '@needle-di/core'; -import {config} from '../common/config'; -import * as schema from '../databases/postgres/tables'; - -@injectable() -export class DrizzleService implements Disposable { - protected readonly pool: pg.Pool; - db: NodePgDatabase; - readonly schema: typeof schema = schema; - - constructor() { - const pool = new pg.Pool({ - user: config.postgres.user, - password: config.postgres.password, - host: config.postgres.host, - port: Number(config.postgres.port).valueOf(), - database: config.postgres.database, - ssl: config.postgres.ssl, - max: config.postgres.max, - }); - this.pool = pool; - this.db = drizzle({ - client: pool, - casing: 'snake_case', - schema, - logger: !config.isProduction, - }); - } - - dispose(): Promise | void { - this.pool.end(); - } -} diff --git a/src/lib/server/api/services/hashing.service.ts b/src/lib/server/api/services/hashing.service.ts deleted file mode 100644 index 95455d4..0000000 --- a/src/lib/server/api/services/hashing.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {scrypt} from 'node:crypto' -import {decodeHex, encodeHexLowerCase} from '@oslojs/encoding' -import {constantTimeEqual} from '@oslojs/crypto/subtle' -import {injectable} from '@needle-di/core' - -@injectable() -export class HashingService { - private N: number - private r: number - private p: number - private dkLen: number - - constructor() { - this.N = 16384 - this.r = 16 - this.p = 1 - this.dkLen = 64 - } - async hash(password: string) { - const salt = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(16))) - const key = await this.generateKey(password, salt) - return `${salt}:${encodeHexLowerCase(key)}` - } - - 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 { - return await new Promise((resolve, reject) => { - scrypt( - password.normalize('NFKC'), - salt, - this.dkLen, - { - N: this.N, - p: this.p, - r: this.r, - // errors when 128 * N * r > `maxmem` (approximately) - maxmem: 128 * this.N * this.r * 2, - }, - (err, buff) => { - if (err) return reject(err) - return resolve(buff) - }, - ) - }) - } -} diff --git a/src/lib/server/api/services/oauth.service.ts b/src/lib/server/api/services/oauth.service.ts deleted file mode 100644 index 11d413a..0000000 --- a/src/lib/server/api/services/oauth.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {inject, injectable} from '@needle-di/core' -import {FederatedIdentityRepository} from '../repositories/federated_identity.repository' -import {UsersService} from './users.service' -import type {OAuthProviders, OAuthUser} from "$lib/server/api/common/types/oauth"; - -@injectable() -export class OAuthService { - constructor( - private federatedIdentityRepository = inject(FederatedIdentityRepository), - private usersService = inject(UsersService), - ) {} - - async handleOAuthUser(oAuthUser: OAuthUser, oauthProvider: OAuthProviders) { - const federatedUser = await this.federatedIdentityRepository.findOneByFederatedUserIdAndProvider(oAuthUser.sub, oauthProvider) - - if (federatedUser) { - return federatedUser.user_id - } - - const user = await this.usersService.createOAuthUser(oAuthUser, oauthProvider) - - if (!user) { - throw new Error('Failed to create user') - } - return user.id - } -} diff --git a/src/lib/server/api/services/recovery-codes.service.ts b/src/lib/server/api/services/recovery-codes.service.ts deleted file mode 100644 index f642083..0000000 --- a/src/lib/server/api/services/recovery-codes.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {RecoveryCodesRepository} from '$lib/server/api/repositories/recovery-codes.repository' -import {alphabet, generateRandomString} from 'oslo/crypto' -import {inject, injectable} from '@needle-di/core' -import {HashingService} from './hashing.service' - -@injectable() -export class RecoveryCodesService { - constructor( - private hashingService = inject(HashingService), - private recoveryCodesRepository = inject(RecoveryCodesRepository), - ) {} - - async findAllRecoveryCodesByUserId(userId: string) { - return this.recoveryCodesRepository.findAllByUserId(userId) - } - - async createRecoveryCodes(userId: string) { - const createdRecoveryCodes = Array.from({ length: 5 }, () => generateRandomString(10, alphabet('A-Z', '0-9'))) - if (createdRecoveryCodes && userId) { - for (const code of createdRecoveryCodes) { - const hashedCode = await this.hashingService.hash(code) - console.log('Inserting recovery code', code, hashedCode) - await this.recoveryCodesRepository.create({ userId, code: hashedCode }) - } - - return createdRecoveryCodes - } - - return [] - } - - async verify(userId: string, code: string) { - const recoveryCodes = await this.recoveryCodesRepository.findAllNotUsedByUserId(userId); - return recoveryCodes.find(recoveryCode => this.hashingService.verify(recoveryCode.code, code)) - } - - async deleteAllRecoveryCodesByUserId(userId: string) { - return this.recoveryCodesRepository.deleteAllByUserId(userId) - } -} diff --git a/src/lib/server/api/services/redis.service.ts b/src/lib/server/api/services/redis.service.ts deleted file mode 100644 index b2d7352..0000000 --- a/src/lib/server/api/services/redis.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {config} from '$lib/server/api/common/config' -import {Redis} from 'ioredis' -import { injectable} from '@needle-di/core'; -import type {Disposable} from 'tsyringe'; - -@injectable() -export class RedisService implements Disposable { - readonly client: Redis; - - constructor() { - this.client = new Redis(config.redis.url, { - maxRetriesPerRequest: null, - }); - } - - async get(data: { prefix: string; key: string }): Promise { - return this.client.get(`${data.prefix}:${data.key}`); - } - - async set(data: { prefix: string; key: string; value: string }): Promise { - await this.client.set(`${data.prefix}:${data.key}`, data.value); - } - - async delete(data: { prefix: string; key: string }): Promise { - await this.client.del(`${data.prefix}:${data.key}`); - } - - async setWithExpiry(data: { - prefix: string; - key: string; - value: string; - expiry: number; - }): Promise { - await this.client.set(`${data.prefix}:${data.key}`, data.value, 'EXAT', Math.floor(data.expiry)); - } - - async dispose(): Promise { - this.client.disconnect(); - } -} diff --git a/src/lib/server/api/services/roles.service.ts b/src/lib/server/api/services/roles.service.ts deleted file mode 100644 index 2245644..0000000 --- a/src/lib/server/api/services/roles.service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {inject, injectable} from "@needle-di/core"; -import {RolesRepository} from "$lib/server/api/repositories/roles.repository"; - -@injectable() -export class RolesService { - constructor( - private rolesRepository = inject(RolesRepository) - ) { } - - - async findOneByNameOrThrow(name: string) { - return this.rolesRepository.findOneByNameOrThrow(name); - } -} \ No newline at end of file diff --git a/src/lib/server/api/services/sessions.service.ts b/src/lib/server/api/services/sessions.service.ts deleted file mode 100644 index b08a14e..0000000 --- a/src/lib/server/api/services/sessions.service.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { cookieExpiresAt, cookieExpiresMilliseconds, halfCookieExpiresMilliseconds } from '$lib/server/api/common/utils/cookies'; -import { UsersRepository } from '$lib/server/api/repositories/users.repository'; -import { RedisService } from '$lib/server/api/services/redis.service'; -import { sha256 } from '@oslojs/crypto/sha2'; -import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; -import { inject, injectable } from '@needle-di/core'; -import type { Users } from '../databases/postgres/tables'; - -export type RedisSession = { - id: string; - user_id: string; - expires_at: number; - ip_country: string; - ip_address: string; - two_factor_auth_enabled: boolean; - is_two_factor_authenticated: boolean; -}; - -export type Session = { - id: string; - userId: string; - expiresAt: Date; - ipCountry: string; - ipAddress: string; - twoFactorEnabled: boolean; - twoFactorVerified: boolean; -}; - -export type SessionValidationResult = { session: Session; user: Users } | { session: null; user: null } | { session: Session; user: undefined }; - -@injectable() -export class SessionsService { - constructor( - private redisService = inject(RedisService), - private usersRepository = inject(UsersRepository), - ) {} - - generateSessionToken() { - const bytes = new Uint8Array(20); - crypto.getRandomValues(bytes); - return encodeBase32LowerCaseNoPadding(bytes); - } - - async createSession( - token: string, - userId: string, - ipCountry: string, - ipAddress: string, - twoFactorAuthEnabled: boolean, - twoFactorVerified: boolean, - ) { - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); - const session = { - id: sessionId, - userId, - expiresAt: cookieExpiresAt, - ipCountry, - ipAddress, - twoFactorAuthEnabled, - twoFactorVerified, - }; - await this.redisService.client.set( - `session:${sessionId}`, - JSON.stringify({ - id: session.id, - user_id: session.userId, - expires_at: session.expiresAt, - ip_country: session.ipCountry, - ip_address: session.ipAddress, - two_factor_auth_enabled: session.twoFactorAuthEnabled, - is_two_factor_authenticated: session.twoFactorVerified, - }), - 'EXAT', - Math.floor(session.expiresAt.getTime() / 1000), - ); - return session; - } - - async validateSessionToken(token: string): Promise { - // TODO: Why was this needed in the docs? https://lucia-next.pages.dev/sessions/basic-api/drizzle-orm - // const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); - const item = await this.redisService.client.get(`session:${token}`); - if (item === null) { - return { - session: null, - user: null, - }; - } - const result: RedisSession = JSON.parse(item); - const session: Session = { - id: result.id, - userId: result.user_id, - expiresAt: new Date(result.expires_at * 1000), - ipCountry: result.ip_country, - ipAddress: result.ip_address, - twoFactorEnabled: result.two_factor_auth_enabled, - twoFactorVerified: result.is_two_factor_authenticated, - }; - let user: Users | undefined = undefined; - if (session.userId) { - user = await this.usersRepository.findOneById(session.userId); - } - if (Date.now() >= session.expiresAt.getTime()) { - await this.redisService.client.del(`session:${token}`); - return { - session: null, - user: null, - }; - } - - if (Date.now() >= session.expiresAt.getTime() - cookieExpiresMilliseconds) { - session.expiresAt = new Date(Date.now() + halfCookieExpiresMilliseconds); - await this.redisService.client.set( - `session:${token}`, - JSON.stringify({ - id: session.id, - user_id: session.userId, - expires_at: Math.floor(session.expiresAt.getTime() / 1000), - ip_country: session.ipCountry, - ip_address: session.ipAddress, - two_factor_enabled: session.twoFactorEnabled, - two_factor_verified: session.twoFactorVerified, - }), - 'EXAT', - Math.floor(session.expiresAt.getTime() / 1000), - ); - } - - return { session, user }; - } - - async invalidateSession(sessionId: string) { - await this.redisService.client.del(`session:${sessionId}`); - } -} diff --git a/src/lib/server/api/services/tokens.service.ts b/src/lib/server/api/services/tokens.service.ts index b1b6fdf..01acd16 100644 --- a/src/lib/server/api/services/tokens.service.ts +++ b/src/lib/server/api/services/tokens.service.ts @@ -1,39 +1,39 @@ -import {inject, injectable} from "@needle-di/core"; -import {generateRandomString} from "oslo/crypto"; -import {createDate, TimeSpan, type TimeSpanUnit} from 'oslo'; -import {HashingService} from "./hashing.service"; +import { inject, injectable } from '@needle-di/core'; +import { TimeSpan, type TimeSpanUnit, createDate } from 'oslo'; +import { generateRandomString } from 'oslo/crypto'; +import { HashingService } from '../common/services/hashing.service'; @injectable() export class TokensService { - constructor(private hashingService = inject(HashingService)) { } + constructor(private hashingService = inject(HashingService)) {} - generateToken() { - const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; // alphabet with removed look-alike characters (0, 1, O, I) - return generateRandomString(6, alphabet); - } + generateToken() { + const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; // alphabet with removed look-alike characters (0, 1, O, I) + return generateRandomString(6, alphabet); + } - generateTokenWithExpiry(number: number, lifespan: TimeSpanUnit) { - return { - token: this.generateToken(), - expiry: createDate(new TimeSpan(number, lifespan)) - } - } + generateTokenWithExpiry(number: number, lifespan: TimeSpanUnit) { + return { + token: this.generateToken(), + expiry: createDate(new TimeSpan(number, lifespan)), + }; + } - async generateTokenWithExpiryAndHash(number: number, lifespan: TimeSpanUnit) { - const token = this.generateToken() - const hashedToken = await this.hashingService.hash(token) - return { - token, - hashedToken, - expiry: createDate(new TimeSpan(number, lifespan)) - } - } + async generateTokenWithExpiryAndHash(number: number, lifespan: TimeSpanUnit) { + const token = this.generateToken(); + const hashedToken = await this.hashingService.hash(token); + return { + token, + hashedToken, + expiry: createDate(new TimeSpan(number, lifespan)), + }; + } - async createHashedToken(token: string) { - return this.hashingService.hash(token) - } + async createHashedToken(token: string) { + return this.hashingService.hash(token); + } - async verifyHashedToken(hashedToken: string, token: string) { - return this.hashingService.verify(hashedToken, token) - } + async verifyHashedToken(hashedToken: string, token: string) { + return this.hashingService.verify(hashedToken, token); + } } diff --git a/src/lib/server/api/services/user_roles.service.ts b/src/lib/server/api/services/user_roles.service.ts deleted file mode 100644 index 8c8b4c2..0000000 --- a/src/lib/server/api/services/user_roles.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type {db} from '$lib/server/api/packages/drizzle' -import {type CreateUserRole, UserRolesRepository} from '$lib/server/api/repositories/user_roles.repository' -import {RolesService} from '$lib/server/api/services/roles.service' -import {inject, injectable} from '@needle-di/core' - -@injectable() -export class UserRolesService { - constructor( - private userRolesRepository = inject(UserRolesRepository), - private rolesService = inject(RolesService), - ) {} - - async findOneById(id: string) { - return this.userRolesRepository.findOneById(id) - } - - async findAllByUserId(userId: string) { - return this.userRolesRepository.findAllByUserId(userId) - } - - async create(data: CreateUserRole) { - return this.userRolesRepository.create(data) - } - - async addRoleToUser(userId: string, roleName: string, primary = false, trx: Parameters[0]>[0] | null = null) { - // Find the role by its name - const role = await this.rolesService.findOneByNameOrThrow(roleName) - - if (!role || !role.id) { - throw new Error(`Role with name ${roleName} not found`) - } - - if (!trx) { - return this.userRolesRepository.create({ - user_id: userId, - role_id: role.id, - primary, - }) - } - - // Create a UserRole entry linking the user and the role - return this.userRolesRepository.create( - { - user_id: userId, - role_id: role.id, - primary, - }, - trx, - ) - } -} diff --git a/src/lib/server/api/services/users.service.ts b/src/lib/server/api/services/users.service.ts deleted file mode 100644 index 3c4741e..0000000 --- a/src/lib/server/api/services/users.service.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type {OAuthUser} from '$lib/server/api/common/types/oauth'; -import type {SignupUsernameEmailDto} from '$lib/server/api/dtos/signup-username-email.dto'; -import {CredentialsRepository} from '$lib/server/api/repositories/credentials.repository'; -import {FederatedIdentityRepository} from '$lib/server/api/repositories/federated_identity.repository'; -import {WishlistsRepository} from '$lib/server/api/repositories/wishlists.repository'; -import {TokensService} from '$lib/server/api/services/tokens.service'; -import {UserRolesService} from '$lib/server/api/services/user_roles.service'; -import { inject, injectable } from '@needle-di/core'; -import {CredentialsType, RoleName} from '../databases/postgres/tables'; -import {type UpdateUser, UsersRepository} from '../repositories/users.repository'; -import {CollectionsService} from './collections.service'; -import {DrizzleService} from './drizzle.service'; -import {WishlistsService} from './wishlists.service'; - -@injectable() -export class UsersService { - constructor( - private collectionsService = inject(CollectionsService), - private credentialsRepository = inject(CredentialsRepository), - private drizzleService = inject(DrizzleService), - private federatedIdentityRepository = inject(FederatedIdentityRepository), - private tokenService = inject(TokensService), - private usersRepository = inject(UsersRepository), - private userRolesService = inject(UserRolesService), - private wishlistsRepository = inject(WishlistsRepository), - private wishlistsService = inject(WishlistsService), - ) {} - - async create(data: SignupUsernameEmailDto) { - const { firstName, lastName, email, username, password } = data; - - const hashedPassword = await this.tokenService.createHashedToken(password); - return await this.drizzleService.db.transaction(async (trx) => { - const createdUser = await this.usersRepository.create( - { - first_name: firstName, - last_name: lastName, - email, - username, - }, - trx, - ); - - if (!createdUser) { - return null; - } - - const credentials = await this.credentialsRepository.create( - { - user_id: createdUser.id, - type: CredentialsType.PASSWORD, - secret_data: hashedPassword, - }, - trx, - ); - - if (!credentials) { - await this.usersRepository.delete(createdUser.id); - return null; - } - - await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx); - - await this.wishlistsService.createEmptyNoName(createdUser.id, trx); - await this.collectionsService.createEmptyNoName(createdUser.id, trx); - return createdUser; - }); - } - - async createOAuthUser(oAuthUser: OAuthUser, oauthProvider: string) { - return await this.drizzleService.db.transaction(async (trx) => { - const createdUser = await this.usersRepository.create( - { - username: oAuthUser.username || oAuthUser.username, - email: oAuthUser.email || null, - first_name: oAuthUser.given_name || null, - last_name: oAuthUser.family_name || null, - picture: oAuthUser.picture || null, - email_verified: oAuthUser.email_verified || false, - }, - trx, - ); - - if (!createdUser) { - return null; - } - - await this.federatedIdentityRepository.create( - { - identity_provider: oauthProvider, - user_id: createdUser.id, - federated_user_id: oAuthUser.sub, - federated_username: oAuthUser.email || oAuthUser.username, - }, - trx, - ); - - await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx); - - await this.wishlistsService.createEmptyNoName(createdUser.id, trx); - await this.collectionsService.createEmptyNoName(createdUser.id, trx); - return createdUser; - }); - } - - async updateUser(userId: string, data: UpdateUser) { - return this.usersRepository.update(userId, data); - } - - async findOneByUsername(username: string) { - return this.usersRepository.findOneByUsername(username); - } - - async findOneByEmail(email: string) { - return this.usersRepository.findOneByEmail(email); - } - - async findOneById(id: string) { - return this.usersRepository.findOneById(id); - } - - async updatePassword(userId: string, password: string) { - const hashedPassword = await this.tokenService.createHashedToken(password); - const currentCredentials = await this.credentialsRepository.findPasswordCredentialsByUserId(userId); - if (!currentCredentials) { - await this.credentialsRepository.create({ - user_id: userId, - type: CredentialsType.PASSWORD, - secret_data: hashedPassword, - }); - } else { - await this.credentialsRepository.update(currentCredentials.id, { - secret_data: hashedPassword, - }); - } - } - - async verifyPassword(userId: string, data: { password: string }) { - const user = await this.usersRepository.findOneById(userId); - if (!user) { - throw new Error('User not found'); - } - const credential = await this.credentialsRepository.findOneByUserIdAndType(userId, CredentialsType.PASSWORD); - if (!credential) { - throw new Error('Password credentials not found'); - } - const { password } = data; - return this.tokenService.verifyHashedToken(credential.secret_data, password); - } -} diff --git a/src/lib/server/api/services/wishlists.service.ts b/src/lib/server/api/services/wishlists.service.ts deleted file mode 100644 index e109911..0000000 --- a/src/lib/server/api/services/wishlists.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type {db} from '$lib/server/api/packages/drizzle' -import {generateRandomAnimalName} from '$lib/utils/randomDataUtil' -import {inject, injectable} from '@needle-di/core' -import {WishlistsRepository} from '../repositories/wishlists.repository' - -@injectable() -export class WishlistsService { - constructor(private wishlistsRepository = inject(WishlistsRepository)) {} - - async findAllByUserId(userId: string) { - return this.wishlistsRepository.findAllByUserId(userId) - } - - async findOneById(id: string) { - return this.wishlistsRepository.findOneById(id) - } - - async findOneByCuid(cuid: string) { - return this.wishlistsRepository.findOneByCuid(cuid) - } - - async createEmptyNoName(userId: string, trx: Parameters[0]>[0] | null = null) { - return this.createEmpty(userId, null, trx) - } - - async createEmpty(userId: string, name: string | null, trx: Parameters[0]>[0] | null = null) { - if (!trx) { - return this.wishlistsRepository.create({ - user_id: userId, - name: name ?? generateRandomAnimalName(), - }) - } - return this.wishlistsRepository.create( - { - user_id: userId, - name: name ?? generateRandomAnimalName(), - }, - trx, - ) - } -} diff --git a/src/lib/server/api/signup/signup.controller.ts b/src/lib/server/api/signup/signup.controller.ts new file mode 100644 index 0000000..781330b --- /dev/null +++ b/src/lib/server/api/signup/signup.controller.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { limiter } from '$lib/server/api/common/middleware/rate-limit.middleware'; +import { Controller } from '$lib/server/api/common/types/controller'; +import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '$lib/server/api/common/utils/cookies'; +import { signupUsernameEmailDto } from '$lib/server/api/dtos/signup-username-email.dto'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; +import { LoginRequestsService } from '$lib/server/api/login/loginrequest.service'; +import { UsersService } from '$lib/server/api/users/users.service'; +import { zValidator } from '@hono/zod-validator'; +import { inject, injectable } from '@needle-di/core'; + +@injectable() +export class SignupController extends Controller { + constructor( + private usersService = inject(UsersService), + private loginRequestService = inject(LoginRequestsService), + private sessionsService = inject(SessionsService), + ) { + super(); + } + + routes() { + return this.controller.post('/', zValidator('json', signupUsernameEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { + const { firstName, lastName, email, username, password, confirm_password } = await c.req.valid('json'); + const existingUser = await this.usersService.findOneByUsername(username); + + if (existingUser) { + return c.body('User already exists', 400); + } + + const user = await this.usersService.create({ firstName, lastName, email, username, password, confirm_password }); + + if (!user) { + return c.body('Failed to create user', 500); + } + + const session = await this.loginRequestService.createUserSession(user.id, c.req, false, false); + const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); + console.log('set cookie', sessionCookie); + setSessionCookie(c, sessionCookie); + return c.json({ message: 'ok' }); + }); + } +} diff --git a/src/lib/server/api/tests/hashing.service.test.ts b/src/lib/server/api/tests/hashing.service.test.ts deleted file mode 100644 index 677c92d..0000000 --- a/src/lib/server/api/tests/hashing.service.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Container } from '@needle-di/core'; -import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest' -import {HashingService} from '../services/hashing.service' - -describe('HashingService', () => { - let service: HashingService - const container = new Container() - - beforeAll(() => { - service = container.get(HashingService) - }) - - afterAll(() => { - vi.resetAllMocks() - }) - - describe('Create Hash', () => { - it('should create a hash', async () => { - const hash = await service.hash('111') - expect(hash).not.toBeUndefined() - expect(hash).not.toBeNull() - }) - }) - - describe('Verify Hash', () => { - it('should verify a hash', async () => { - const hash = await service.hash('111') - const verifiable = await service.verify(hash, '111') - expect(verifiable).toBeTruthy() - }) - - it('should not verify a hash', async () => { - const hash = await service.hash('111') - const verifiable = await service.verify(hash, '222') - expect(verifiable).toBeFalsy() - }) - }) -}) diff --git a/src/lib/server/api/tests/iam.service.test.ts b/src/lib/server/api/tests/iam.service.test.ts index e318912..59eda65 100644 --- a/src/lib/server/api/tests/iam.service.test.ts +++ b/src/lib/server/api/tests/iam.service.test.ts @@ -1,125 +1,125 @@ -import {IamService} from '$lib/server/api/services/iam.service'; -import {SessionsService} from '$lib/server/api/services/sessions.service'; -import {UsersService} from '$lib/server/api/services/users.service'; -import {faker} from '@faker-js/faker'; +import { IamService } from '$lib/server/api/iam/iam.service'; +import { SessionsService } from '$lib/server/api/iam/sessions/sessions.service'; +import { UsersService } from '$lib/server/api/users/users.service'; +import { faker } from '@faker-js/faker'; import { Container } from '@needle-di/core'; -import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; describe('IamService', () => { - let service: IamService; - const container = new Container(); - const sessionService = vi.mocked(SessionsService.prototype); - const userService = vi.mocked(UsersService.prototype); + let service: IamService; + const container = new Container(); + const sessionService = vi.mocked(SessionsService.prototype); + const userService = vi.mocked(UsersService.prototype); - beforeAll(() => { - container - .bind({ provide: SessionsService, useValue: sessionService }) - .bind({ provide: UsersService, useValue: userService }); + beforeAll(() => { + container + .bind({ provide: SessionsService, useValue: sessionService }) + .bind({ provide: UsersService, useValue: userService }); - service = container.get(IamService); - }); + service = container.get(IamService); + }); - beforeEach(() => { - vi.resetAllMocks(); - }); + beforeEach(() => { + vi.resetAllMocks(); + }); - afterAll(() => { - vi.resetAllMocks(); - }); + afterAll(() => { + vi.resetAllMocks(); + }); - const timeStampDate = new Date(); - const dbUser = { - id: faker.string.uuid(), - cuid: 'ciglo1j8q0000t9j4xq8d6p5e', - first_name: faker.person.firstName(), - last_name: faker.person.lastName(), - email: faker.internet.email(), - username: faker.internet.userName(), - verified: false, - receive_email: false, - mfa_enabled: false, - theme: 'system', - createdAt: timeStampDate, - updatedAt: timeStampDate, - }; + const timeStampDate = new Date(); + const dbUser = { + id: faker.string.uuid(), + cuid: 'ciglo1j8q0000t9j4xq8d6p5e', + first_name: faker.person.firstName(), + last_name: faker.person.lastName(), + email: faker.internet.email(), + username: faker.internet.userName(), + verified: false, + receive_email: false, + mfa_enabled: false, + theme: 'system', + createdAt: timeStampDate, + updatedAt: timeStampDate, + }; - describe('Update Profile', () => { - it('should update user', async () => { - userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser); - userService.findOneByUsername = vi.fn().mockResolvedValue(undefined); - userService.updateUser = vi.fn().mockResolvedValue(dbUser); + describe('Update Profile', () => { + it('should update user', async () => { + userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser); + userService.findOneByUsername = vi.fn().mockResolvedValue(undefined); + userService.updateUser = vi.fn().mockResolvedValue(dbUser); - const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); - const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); - const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); - await expect( - service.updateProfile(faker.string.uuid(), { - username: faker.internet.userName(), - }), - ).resolves.toEqual(dbUser); - expect(spy_userService_findOneById).toBeCalledTimes(1); - expect(spy_userService_findOneByUsername).toBeCalledTimes(1); - expect(spy_userService_updateUser).toBeCalledTimes(1); - }); + const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); + const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); + const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); + await expect( + service.updateProfile(faker.string.uuid(), { + username: faker.internet.userName(), + }), + ).resolves.toEqual(dbUser); + expect(spy_userService_findOneById).toBeCalledTimes(1); + expect(spy_userService_findOneByUsername).toBeCalledTimes(1); + expect(spy_userService_updateUser).toBeCalledTimes(1); + }); - it('should error on no user found', async () => { - userService.findOneById = vi.fn().mockResolvedValueOnce(undefined); + it('should error on no user found', async () => { + userService.findOneById = vi.fn().mockResolvedValueOnce(undefined); - const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); - const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); - const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); - await expect( - service.updateProfile(faker.string.uuid(), { - username: faker.internet.userName(), - }), - ).resolves.toEqual({ - error: 'User not found', - }); - expect(spy_userService_findOneById).toBeCalledTimes(1); - expect(spy_userService_findOneByUsername).toBeCalledTimes(0); - expect(spy_userService_updateUser).toBeCalledTimes(0); - }); + const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); + const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); + const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); + await expect( + service.updateProfile(faker.string.uuid(), { + username: faker.internet.userName(), + }), + ).resolves.toEqual({ + error: 'User not found', + }); + expect(spy_userService_findOneById).toBeCalledTimes(1); + expect(spy_userService_findOneByUsername).toBeCalledTimes(0); + expect(spy_userService_updateUser).toBeCalledTimes(0); + }); - it('should error on duplicate username', async () => { - userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser); - userService.findOneByUsername = vi.fn().mockResolvedValue({ - id: faker.string.uuid(), - }); - userService.updateUser = vi.fn().mockResolvedValue(dbUser); + it('should error on duplicate username', async () => { + userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser); + userService.findOneByUsername = vi.fn().mockResolvedValue({ + id: faker.string.uuid(), + }); + userService.updateUser = vi.fn().mockResolvedValue(dbUser); - const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); - const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); - const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); - await expect( - service.updateProfile(faker.string.uuid(), { - username: faker.internet.userName(), - }), - ).resolves.toEqual({ - error: 'Username already in use', - }); - expect(spy_userService_findOneById).toBeCalledTimes(1); - expect(spy_userService_findOneByUsername).toBeCalledTimes(1); - expect(spy_userService_updateUser).toBeCalledTimes(0); - }); - }); + const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); + const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); + const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); + await expect( + service.updateProfile(faker.string.uuid(), { + username: faker.internet.userName(), + }), + ).resolves.toEqual({ + error: 'Username already in use', + }); + expect(spy_userService_findOneById).toBeCalledTimes(1); + expect(spy_userService_findOneByUsername).toBeCalledTimes(1); + expect(spy_userService_updateUser).toBeCalledTimes(0); + }); + }); - it('should not error if the user id of new username is the current user id', async () => { - userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser); - userService.findOneByUsername = vi.fn().mockResolvedValue({ - id: dbUser.id, - }); - userService.updateUser = vi.fn().mockResolvedValue(dbUser); + it('should not error if the user id of new username is the current user id', async () => { + userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser); + userService.findOneByUsername = vi.fn().mockResolvedValue({ + id: dbUser.id, + }); + userService.updateUser = vi.fn().mockResolvedValue(dbUser); - const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); - const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); - const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); - await expect( - service.updateProfile(dbUser.id, { - username: dbUser.id, - }), - ).resolves.toEqual(dbUser); - expect(spy_userService_findOneById).toBeCalledTimes(1); - expect(spy_userService_findOneByUsername).toBeCalledTimes(1); - expect(spy_userService_updateUser).toBeCalledTimes(1); - }); + const spy_userService_findOneById = vi.spyOn(userService, 'findOneById'); + const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername'); + const spy_userService_updateUser = vi.spyOn(userService, 'updateUser'); + await expect( + service.updateProfile(dbUser.id, { + username: dbUser.id, + }), + ).resolves.toEqual(dbUser); + expect(spy_userService_findOneById).toBeCalledTimes(1); + expect(spy_userService_findOneByUsername).toBeCalledTimes(1); + expect(spy_userService_updateUser).toBeCalledTimes(1); + }); }); diff --git a/src/lib/server/api/tests/tokens.service.test.ts b/src/lib/server/api/tests/tokens.service.test.ts index dde3d52..324ac8a 100644 --- a/src/lib/server/api/tests/tokens.service.test.ts +++ b/src/lib/server/api/tests/tokens.service.test.ts @@ -1,46 +1,46 @@ -import 'reflect-metadata' -import { Container } from '@needle-di/core' -import {afterAll, beforeAll, describe, expect, expectTypeOf, it, vi} from 'vitest' -import {HashingService} from '../services/hashing.service' -import {TokensService} from '../services/tokens.service' +import 'reflect-metadata'; +import { Container } from '@needle-di/core'; +import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { HashingService } from '../common/services/hashing.service'; +import { TokensService } from '../services/tokens.service'; describe('TokensService', () => { - const container = new Container() - let service: TokensService - const hashingService = vi.mocked(HashingService.prototype) + const container = new Container(); + let service: TokensService; + const hashingService = vi.mocked(HashingService.prototype); - beforeAll(() => { - container.bind({ provide: HashingService, useValue: hashingService }); - service = container.get(TokensService); - }) + beforeAll(() => { + container.bind({ provide: HashingService, useValue: hashingService }); + service = container.get(TokensService); + }); - afterAll(() => { - vi.resetAllMocks() - }) + afterAll(() => { + vi.resetAllMocks(); + }); - describe('Generate Token', () => { - it('should resolve', async () => { - const hashedPassword = 'testhash' - hashingService.hash = vi.fn().mockResolvedValue(hashedPassword) - const spy_hashingService_hash = vi.spyOn(hashingService, 'hash') - const spy_hashingService_verify = vi.spyOn(hashingService, 'verify') - await expectTypeOf(service.createHashedToken('111')).resolves.toBeString() - expect(spy_hashingService_hash).toBeCalledTimes(1) - expect(spy_hashingService_verify).toBeCalledTimes(0) - }) - it('should generate a token that is verifiable', async () => { - hashingService.hash = vi.fn().mockResolvedValue('testhash') - hashingService.verify = vi.fn().mockResolvedValue(true) - const spy_hashingService_hash = vi.spyOn(hashingService, 'hash') - const spy_hashingService_verify = vi.spyOn(hashingService, 'verify') - const token = await service.createHashedToken('111') - expect(token).not.toBeNaN() - expect(token).not.toBeUndefined() - expect(token).not.toBeNull() - const verifiable = await service.verifyHashedToken(token, '111') - expect(verifiable).toBeTruthy() - expect(spy_hashingService_hash).toBeCalledTimes(1) - expect(spy_hashingService_verify).toBeCalledTimes(1) - }) - }) -}) + describe('Generate Token', () => { + it('should resolve', async () => { + const hashedPassword = 'testhash'; + hashingService.hash = vi.fn().mockResolvedValue(hashedPassword); + const spy_hashingService_hash = vi.spyOn(hashingService, 'hash'); + const spy_hashingService_verify = vi.spyOn(hashingService, 'verify'); + await expectTypeOf(service.createHashedToken('111')).resolves.toBeString(); + expect(spy_hashingService_hash).toBeCalledTimes(1); + expect(spy_hashingService_verify).toBeCalledTimes(0); + }); + it('should generate a token that is verifiable', async () => { + hashingService.hash = vi.fn().mockResolvedValue('testhash'); + hashingService.verify = vi.fn().mockResolvedValue(true); + const spy_hashingService_hash = vi.spyOn(hashingService, 'hash'); + const spy_hashingService_verify = vi.spyOn(hashingService, 'verify'); + const token = await service.createHashedToken('111'); + expect(token).not.toBeNaN(); + expect(token).not.toBeUndefined(); + expect(token).not.toBeNull(); + const verifiable = await service.verifyHashedToken(token, '111'); + expect(verifiable).toBeTruthy(); + expect(spy_hashingService_hash).toBeCalledTimes(1); + expect(spy_hashingService_verify).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/lib/server/api/tests/user_roles.service.test.ts b/src/lib/server/api/tests/user_roles.service.test.ts index d861807..42a38b0 100644 --- a/src/lib/server/api/tests/user_roles.service.test.ts +++ b/src/lib/server/api/tests/user_roles.service.test.ts @@ -1,77 +1,77 @@ import 'reflect-metadata'; -import {faker} from '@faker-js/faker'; +import { faker } from '@faker-js/faker'; import { Container } from '@needle-di/core'; -import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; -import {RoleName} from '../databases/postgres/tables'; -import {UserRolesRepository} from '../repositories/user_roles.repository'; -import {RolesService} from '../services/roles.service'; -import {UserRolesService} from '../services/user_roles.service'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { RoleName } from '../databases/postgres/tables'; +import { RolesService } from '../users/roles.service'; +import { UserRolesRepository } from '../users/user_roles.repository'; +import { UserRolesService } from '../users/user_roles.service'; describe('UserRolesService', () => { - const container = new Container(); - let service: UserRolesService; - const userRolesRepository = vi.mocked(UserRolesRepository.prototype); - const rolesService = vi.mocked(RolesService.prototype); + const container = new Container(); + let service: UserRolesService; + const userRolesRepository = vi.mocked(UserRolesRepository.prototype); + const rolesService = vi.mocked(RolesService.prototype); - beforeAll(() => { - container - .bind({ provide: UserRolesRepository, useValue: userRolesRepository }) - .bind({ provide: RolesService, useValue: rolesService }); + beforeAll(() => { + container + .bind({ provide: UserRolesRepository, useValue: userRolesRepository }) + .bind({ provide: RolesService, useValue: rolesService }); - service = container.get(UserRolesService); - }); + service = container.get(UserRolesService); + }); - afterAll(() => { - vi.resetAllMocks(); - }); + afterAll(() => { + vi.resetAllMocks(); + }); - const timeStampDate = new Date(); - const roleUUID = faker.string.uuid(); - const userUUID = faker.string.uuid(); - const dbRole = { - id: roleUUID, - cuid: 'ciglo1j8q0000t9j4xq8d6p5e', - name: RoleName.ADMIN, - createdAt: timeStampDate, - updatedAt: timeStampDate, - }; + const timeStampDate = new Date(); + const roleUUID = faker.string.uuid(); + const userUUID = faker.string.uuid(); + const dbRole = { + id: roleUUID, + cuid: 'ciglo1j8q0000t9j4xq8d6p5e', + name: RoleName.ADMIN, + createdAt: timeStampDate, + updatedAt: timeStampDate, + }; - const dbUserRole = { - id: faker.string.uuid(), - cuid: 'ciglo1j8q0000t9j4xq8d6p5e', - role_id: roleUUID, - user_id: userUUID, - primary: true, - createdAt: timeStampDate, - updatedAt: timeStampDate, - }; + const dbUserRole = { + id: faker.string.uuid(), + cuid: 'ciglo1j8q0000t9j4xq8d6p5e', + role_id: roleUUID, + user_id: userUUID, + primary: true, + createdAt: timeStampDate, + updatedAt: timeStampDate, + }; - describe('Create User Role', () => { - it('should resolve', async () => { - rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(dbRole satisfies Awaited>); + describe('Create User Role', () => { + it('should resolve', async () => { + rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(dbRole satisfies Awaited>); - userRolesRepository.create = vi.fn().mockResolvedValue(dbUserRole satisfies Awaited>); + userRolesRepository.create = vi.fn().mockResolvedValue(dbUserRole satisfies Awaited>); - const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow'); - const spy_userRolesRepository_create = vi.spyOn(userRolesRepository, 'create'); + const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow'); + const spy_userRolesRepository_create = vi.spyOn(userRolesRepository, 'create'); - await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).resolves.not.toThrowError(); - expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN); - expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1); - expect(spy_userRolesRepository_create).toBeCalledWith({ - user_id: userUUID, - role_id: dbRole.id, - primary: true, - }); - expect(spy_userRolesRepository_create).toBeCalledTimes(1); - }); - it('should error on no role found', async () => { - rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(undefined); + await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).resolves.not.toThrowError(); + expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN); + expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1); + expect(spy_userRolesRepository_create).toBeCalledWith({ + user_id: userUUID, + role_id: dbRole.id, + primary: true, + }); + expect(spy_userRolesRepository_create).toBeCalledTimes(1); + }); + it('should error on no role found', async () => { + rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(undefined); - const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow'); - await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).rejects.toThrowError(`Role with name ${RoleName.ADMIN} not found`); - expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN); - expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1); - }); - }); + const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow'); + await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).rejects.toThrowError(`Role with name ${RoleName.ADMIN} not found`); + expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN); + expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1); + }); + }); }); diff --git a/src/lib/server/api/tests/users.service.test.ts b/src/lib/server/api/tests/users.service.test.ts index 04c8d67..a005521 100644 --- a/src/lib/server/api/tests/users.service.test.ts +++ b/src/lib/server/api/tests/users.service.test.ts @@ -1,143 +1,143 @@ import 'reflect-metadata'; -import {faker} from '@faker-js/faker'; +import { faker } from '@faker-js/faker'; import { Container } from '@needle-di/core'; -import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; -import {CredentialsType} from '../databases/postgres/tables'; -import {CredentialsRepository} from '../repositories/credentials.repository'; -import {UsersRepository} from '../repositories/users.repository'; -import {CollectionsService} from '../services/collections.service'; -import {DrizzleService} from '../services/drizzle.service'; -import {TokensService} from '../services/tokens.service'; -import {UserRolesService} from '../services/user_roles.service'; -import {UsersService} from '../services/users.service'; -import {WishlistsService} from '../services/wishlists.service'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { CollectionsService } from '../collections/collections.service'; +import { DrizzleService } from '../databases/postgres/drizzle.service'; +import { CredentialsType } from '../databases/postgres/tables'; +import { TokensService } from '../services/tokens.service'; +import { CredentialsRepository } from '../users/credentials.repository'; +import { UserRolesService } from '../users/user_roles.service'; +import { UsersRepository } from '../users/users.repository'; +import { UsersService } from '../users/users.service'; +import { WishlistsService } from '../wishlists/wishlists.service'; describe('UsersService', () => { - const container = new Container(); - let service: UsersService; - const credentialsRepository = vi.mocked(CredentialsRepository.prototype); - const drizzleService = vi.mocked(DrizzleService.prototype, { deep: true }); - const tokensService = vi.mocked(TokensService.prototype); - const usersRepository = vi.mocked(UsersRepository.prototype); - const userRolesService = vi.mocked(UserRolesService.prototype); - const wishlistsService = vi.mocked(WishlistsService.prototype); - const collectionsService = vi.mocked(CollectionsService.prototype); + const container = new Container(); + let service: UsersService; + const credentialsRepository = vi.mocked(CredentialsRepository.prototype); + const drizzleService = vi.mocked(DrizzleService.prototype, { deep: true }); + const tokensService = vi.mocked(TokensService.prototype); + const usersRepository = vi.mocked(UsersRepository.prototype); + const userRolesService = vi.mocked(UserRolesService.prototype); + const wishlistsService = vi.mocked(WishlistsService.prototype); + const collectionsService = vi.mocked(CollectionsService.prototype); - // Mocking the dependencies - vi.mock('pg', () => ({ - Pool: vi.fn().mockImplementation(() => ({ - connect: vi.fn(), - end: vi.fn(), - })), - })); + // Mocking the dependencies + vi.mock('pg', () => ({ + Pool: vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + end: vi.fn(), + })), + })); - vi.mock('drizzle-orm/node-postgres', () => ({ - drizzle: vi.fn().mockImplementation(() => ({ - transaction: vi.fn().mockImplementation((callback) => callback()), - // Add other methods you need to mock - })), - })); + vi.mock('drizzle-orm/node-postgres', () => ({ + drizzle: vi.fn().mockImplementation(() => ({ + transaction: vi.fn().mockImplementation((callback) => callback()), + // Add other methods you need to mock + })), + })); - beforeAll(() => { - container - .bind({ provide: CredentialsRepository, useValue: credentialsRepository }) - .bind({ provide: DrizzleService, useValue: drizzleService }) - .bind({ provide: TokensService, useValue: tokensService }) - .bind({ provide: UsersRepository, useValue: usersRepository }) - .bind({ provide: UserRolesService, useValue: userRolesService }) - .bind({ provide: WishlistsService, useValue: wishlistsService }) - .bind({ provide: CollectionsService, useValue: collectionsService }); + beforeAll(() => { + container + .bind({ provide: CredentialsRepository, useValue: credentialsRepository }) + .bind({ provide: DrizzleService, useValue: drizzleService }) + .bind({ provide: TokensService, useValue: tokensService }) + .bind({ provide: UsersRepository, useValue: usersRepository }) + .bind({ provide: UserRolesService, useValue: userRolesService }) + .bind({ provide: WishlistsService, useValue: wishlistsService }) + .bind({ provide: CollectionsService, useValue: collectionsService }); - service = container.get(UsersService); + service = container.get(UsersService); - drizzleService.db = { - transaction: vi.fn().mockImplementation(async (callback) => { - return await callback(); - }), - } as any; - }); + drizzleService.db = { + transaction: vi.fn().mockImplementation(async (callback) => { + return await callback(); + }), + } as any; + }); - afterAll(() => { - vi.resetAllMocks(); - }); + afterAll(() => { + vi.resetAllMocks(); + }); - const timeStampDate = new Date(); - const dbUser = { - id: faker.string.uuid(), - cuid: 'ciglo1j8q0000t9j4xq8d6p5e', - first_name: faker.person.firstName(), - last_name: faker.person.lastName(), - email: faker.internet.email(), - username: faker.internet.userName(), - verified: false, - receive_email: false, - mfa_enabled: false, - theme: 'system', - createdAt: timeStampDate, - updatedAt: timeStampDate, - }; - const dbCredentials = { - id: faker.string.uuid(), - user_id: dbUser.id, - type: CredentialsType.PASSWORD, - secret_data: 'hashedPassword', - createdAt: timeStampDate, - updatedAt: timeStampDate, - }; + const timeStampDate = new Date(); + const dbUser = { + id: faker.string.uuid(), + cuid: 'ciglo1j8q0000t9j4xq8d6p5e', + first_name: faker.person.firstName(), + last_name: faker.person.lastName(), + email: faker.internet.email(), + username: faker.internet.userName(), + verified: false, + receive_email: false, + mfa_enabled: false, + theme: 'system', + createdAt: timeStampDate, + updatedAt: timeStampDate, + }; + const dbCredentials = { + id: faker.string.uuid(), + user_id: dbUser.id, + type: CredentialsType.PASSWORD, + secret_data: 'hashedPassword', + createdAt: timeStampDate, + updatedAt: timeStampDate, + }; - describe('Create User', () => { - it('should resolve', async () => { - const hashedPassword = 'testhash'; - tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword); + describe('Create User', () => { + it('should resolve', async () => { + const hashedPassword = 'testhash'; + tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword); - drizzleService.db.transaction = vi.fn().mockImplementation(async (callback) => { - return dbUser satisfies Awaited> - }); + drizzleService.db.transaction = vi.fn().mockImplementation(async (callback) => { + return dbUser satisfies Awaited>; + }); - const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken'); - const createdUser = await service.create({ - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - email: faker.internet.email(), - username: faker.internet.userName(), - password: faker.string.alphanumeric(10), - confirm_password: faker.string.alphanumeric(10), - }); - expect(createdUser).toEqual(dbUser); - expect(spy_tokensService_createHashToken).toBeCalledTimes(1); - }); - }); - describe('Update User', () => { - it('should resolve Password Exiting Credentials', async () => { - const hashedPassword = 'testhash'; - tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword); - credentialsRepository.update = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited>); - credentialsRepository.findPasswordCredentialsByUserId = vi - .fn() - .mockResolvedValue(dbCredentials satisfies Awaited>); + const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken'); + const createdUser = await service.create({ + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + username: faker.internet.userName(), + password: faker.string.alphanumeric(10), + confirm_password: faker.string.alphanumeric(10), + }); + expect(createdUser).toEqual(dbUser); + expect(spy_tokensService_createHashToken).toBeCalledTimes(1); + }); + }); + describe('Update User', () => { + it('should resolve Password Exiting Credentials', async () => { + const hashedPassword = 'testhash'; + tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword); + credentialsRepository.update = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited>); + credentialsRepository.findPasswordCredentialsByUserId = vi + .fn() + .mockResolvedValue(dbCredentials satisfies Awaited>); - const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken'); - const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId'); - const spy_credentialsRepository_update = vi.spyOn(credentialsRepository, 'update'); - await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.toBeUndefined(); - expect(spy_tokensService_createHashToken).toBeCalledTimes(1); - expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1); - expect(spy_credentialsRepository_update).toBeCalledTimes(1); - }); - it('Should Create User Password No Existing Credentials', async () => { - const hashedPassword = 'testhash'; - tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword); - credentialsRepository.findPasswordCredentialsByUserId = vi.fn().mockResolvedValue(null); - credentialsRepository.create = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited>); + const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken'); + const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId'); + const spy_credentialsRepository_update = vi.spyOn(credentialsRepository, 'update'); + await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.toBeUndefined(); + expect(spy_tokensService_createHashToken).toBeCalledTimes(1); + expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1); + expect(spy_credentialsRepository_update).toBeCalledTimes(1); + }); + it('Should Create User Password No Existing Credentials', async () => { + const hashedPassword = 'testhash'; + tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword); + credentialsRepository.findPasswordCredentialsByUserId = vi.fn().mockResolvedValue(null); + credentialsRepository.create = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited>); - const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken'); - const spy_credentialsRepository_create = vi.spyOn(credentialsRepository, 'create'); - const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId'); + const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken'); + const spy_credentialsRepository_create = vi.spyOn(credentialsRepository, 'create'); + const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId'); - await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.not.toThrow(); - expect(spy_tokensService_createHashToken).toBeCalledTimes(1); - expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1); - expect(spy_credentialsRepository_create).toHaveBeenCalledTimes(1); - }); - }); + await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.not.toThrow(); + expect(spy_tokensService_createHashToken).toBeCalledTimes(1); + expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1); + expect(spy_credentialsRepository_create).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/lib/server/api/users/credentials.repository.ts b/src/lib/server/api/users/credentials.repository.ts new file mode 100644 index 0000000..9408d65 --- /dev/null +++ b/src/lib/server/api/users/credentials.repository.ts @@ -0,0 +1,70 @@ +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; +import { CredentialsType, credentialsTable } from '$lib/server/api/databases/postgres/tables/credentials.table'; +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, and, eq } from 'drizzle-orm'; +import { takeFirstOrThrow } from '../common/utils/repository'; + +export type CreateCredentials = InferInsertModel; +export type UpdateCredentials = Partial; +export type DeleteCredentials = Pick; + +@injectable() +export class CredentialsRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async findOneByUserId(userId: string, db = this.drizzle.db) { + return db.query.credentialsTable.findFirst({ + where: eq(credentialsTable.user_id, userId), + }); + } + + async findOneByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) { + return db.query.credentialsTable.findFirst({ + where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type)), + }); + } + + async findPasswordCredentialsByUserId(userId: string, db = this.drizzle.db) { + return db.query.credentialsTable.findFirst({ + where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.PASSWORD)), + }); + } + + async findTOTPCredentialsByUserId(userId: string, db = this.drizzle.db) { + return db.query.credentialsTable.findFirst({ + where: and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, CredentialsType.TOTP)), + }); + } + + async findOneById(id: string, db = this.drizzle.db) { + return db.query.credentialsTable.findFirst({ + where: eq(credentialsTable.id, id), + }); + } + + async findOneByIdOrThrow(id: string, db = this.drizzle.db) { + const credentials = await this.findOneById(id, db); + if (!credentials) throw Error('Credentials not found'); + return credentials; + } + + async create(data: CreateCredentials, db = this.drizzle.db) { + return db.insert(credentialsTable).values(data).returning().then(takeFirstOrThrow); + } + + async update(id: string, data: UpdateCredentials, db = this.drizzle.db) { + return db.update(credentialsTable).set(data).where(eq(credentialsTable.id, id)).returning().then(takeFirstOrThrow); + } + + async delete(id: string, db = this.drizzle.db) { + return db.delete(credentialsTable).where(eq(credentialsTable.id, id)); + } + + async deleteByUserId(userId: string, db = this.drizzle.db) { + return db.delete(credentialsTable).where(eq(credentialsTable.user_id, userId)); + } + + async deleteByUserIdAndType(userId: string, type: CredentialsType, db = this.drizzle.db) { + return db.delete(credentialsTable).where(and(eq(credentialsTable.user_id, userId), eq(credentialsTable.type, type))); + } +} diff --git a/src/lib/server/api/users/dtos/update-user.dto.ts b/src/lib/server/api/users/dtos/update-user.dto.ts new file mode 100644 index 0000000..8a3b9cf --- /dev/null +++ b/src/lib/server/api/users/dtos/update-user.dto.ts @@ -0,0 +1,14 @@ +import type { z } from 'zod'; +import { userDto } from './user.dto'; + +export const updateUserDto = userDto + .pick({ + firstName: true, + lastName: true, + email: true, + username: true, + avatar: true, + }) + .optional(); + +export type UpdateUserDto = z.infer; diff --git a/src/lib/server/api/users/dtos/user.dto.ts b/src/lib/server/api/users/dtos/user.dto.ts new file mode 100644 index 0000000..0e806c0 --- /dev/null +++ b/src/lib/server/api/users/dtos/user.dto.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const MAX_UPLOAD_SIZE = 1024 * 1024 * 3; // 3MB +const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; + +export const userDto = z.object({ + id: z.string(), + firstName: z.string().trim().optional(), + lastName: z.string().trim().optional(), + email: z.string().trim().max(64, { message: 'Email must be less than 64 characters' }).optional(), + username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }), + avatar: z + .instanceof(File) + .optional() + .refine((file) => { + return !file || file.size <= MAX_UPLOAD_SIZE; + }, 'File size must be less than 3MB') + .refine((file) => { + return ACCEPTED_FILE_TYPES.includes(file!.type); + }, 'File must be a PNG'), +}); + +export type UserDto = z.infer; diff --git a/src/lib/server/api/users/federated_identity.repository.ts b/src/lib/server/api/users/federated_identity.repository.ts new file mode 100644 index 0000000..6da0c20 --- /dev/null +++ b/src/lib/server/api/users/federated_identity.repository.ts @@ -0,0 +1,28 @@ +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, and, eq } from 'drizzle-orm'; +import { takeFirstOrThrow } from '../common/utils/repository'; +import { DrizzleService } from '../databases/postgres/drizzle.service'; +import { federatedIdentityTable } from '../databases/postgres/tables'; + +export type CreateFederatedIdentity = InferInsertModel; + +@injectable() +export class FederatedIdentityRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async findOneByUserIdAndProvider(userId: string, provider: string) { + return this.drizzle.db.query.federatedIdentityTable.findFirst({ + where: and(eq(federatedIdentityTable.user_id, userId), eq(federatedIdentityTable.identity_provider, provider)), + }); + } + + async findOneByFederatedUserIdAndProvider(federatedUserId: string, provider: string) { + return this.drizzle.db.query.federatedIdentityTable.findFirst({ + where: and(eq(federatedIdentityTable.federated_user_id, federatedUserId), eq(federatedIdentityTable.identity_provider, provider)), + }); + } + + async create(data: CreateFederatedIdentity, db = this.drizzle.db) { + return db.insert(federatedIdentityTable).values(data).returning().then(takeFirstOrThrow); + } +} diff --git a/src/lib/server/api/users/recovery-codes.repository.ts b/src/lib/server/api/users/recovery-codes.repository.ts new file mode 100644 index 0000000..f2a133f --- /dev/null +++ b/src/lib/server/api/users/recovery-codes.repository.ts @@ -0,0 +1,32 @@ +import { takeFirstOrThrow } from '$lib/server/api/common/utils/repository'; +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, and, eq } from 'drizzle-orm'; +import { recoveryCodesTable } from '../databases/postgres/tables'; + +export type CreateRecoveryCodes = InferInsertModel; + +@injectable() +export class RecoveryCodesRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async create(data: CreateRecoveryCodes, db = this.drizzle.db) { + return db.insert(recoveryCodesTable).values(data).returning().then(takeFirstOrThrow); + } + + async findAllByUserId(userId: string, db = this.drizzle.db) { + return db.query.recoveryCodesTable.findMany({ + where: eq(recoveryCodesTable.userId, userId), + }); + } + + async findAllNotUsedByUserId(userId: string, db = this.drizzle.db) { + return db.query.recoveryCodesTable.findMany({ + where: and(eq(recoveryCodesTable.userId, userId), eq(recoveryCodesTable.used, false)), + }); + } + + async deleteAllByUserId(userId: string, db = this.drizzle.db) { + return db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId)); + } +} diff --git a/src/lib/server/api/users/recovery-codes.service.ts b/src/lib/server/api/users/recovery-codes.service.ts new file mode 100644 index 0000000..f29bbe6 --- /dev/null +++ b/src/lib/server/api/users/recovery-codes.service.ts @@ -0,0 +1,40 @@ +import { RecoveryCodesRepository } from '$lib/server/api/users/recovery-codes.repository'; +import { inject, injectable } from '@needle-di/core'; +import { alphabet, generateRandomString } from 'oslo/crypto'; +import { HashingService } from '../common/services/hashing.service'; + +@injectable() +export class RecoveryCodesService { + constructor( + private hashingService = inject(HashingService), + private recoveryCodesRepository = inject(RecoveryCodesRepository), + ) {} + + async findAllRecoveryCodesByUserId(userId: string) { + return this.recoveryCodesRepository.findAllByUserId(userId); + } + + async createRecoveryCodes(userId: string) { + const createdRecoveryCodes = Array.from({ length: 5 }, () => generateRandomString(10, alphabet('A-Z', '0-9'))); + if (createdRecoveryCodes && userId) { + for (const code of createdRecoveryCodes) { + const hashedCode = await this.hashingService.hash(code); + console.log('Inserting recovery code', code, hashedCode); + await this.recoveryCodesRepository.create({ userId, code: hashedCode }); + } + + return createdRecoveryCodes; + } + + return []; + } + + async verify(userId: string, code: string) { + const recoveryCodes = await this.recoveryCodesRepository.findAllNotUsedByUserId(userId); + return recoveryCodes.find((recoveryCode) => this.hashingService.verify(recoveryCode.code, code)); + } + + async deleteAllRecoveryCodesByUserId(userId: string) { + return this.recoveryCodesRepository.deleteAllByUserId(userId); + } +} diff --git a/src/lib/server/api/users/roles.repository.ts b/src/lib/server/api/users/roles.repository.ts new file mode 100644 index 0000000..8bacc7a --- /dev/null +++ b/src/lib/server/api/users/roles.repository.ts @@ -0,0 +1,53 @@ +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, eq } from 'drizzle-orm'; +import { takeFirstOrThrow } from '../common/utils/repository'; +import { rolesTable } from '../databases/postgres/tables'; + +export type CreateRole = InferInsertModel; +export type UpdateRole = Partial; + +@injectable() +export class RolesRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async findOneById(id: string, db = this.drizzle.db) { + return db.query.rolesTable.findFirst({ + where: eq(rolesTable.id, id), + }); + } + + async findOneByIdOrThrow(id: string, db = this.drizzle.db) { + const role = await this.findOneById(id, db); + if (!role) throw Error('Role not found'); + return role; + } + + async findAll(db = this.drizzle.db) { + return db.query.rolesTable.findMany(); + } + + async findOneByName(name: string, db = this.drizzle.db) { + return db.query.rolesTable.findFirst({ + where: eq(rolesTable.name, name), + }); + } + + async findOneByNameOrThrow(name: string, db = this.drizzle.db) { + const role = await this.findOneByName(name, db); + if (!role) throw Error('Role not found'); + return role; + } + + async create(data: CreateRole, db = this.drizzle.db) { + return db.insert(rolesTable).values(data).returning().then(takeFirstOrThrow); + } + + async update(id: string, data: UpdateRole, db = this.drizzle.db) { + return db.update(rolesTable).set(data).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow); + } + + async delete(id: string, db = this.drizzle.db) { + return db.delete(rolesTable).where(eq(rolesTable.id, id)).returning().then(takeFirstOrThrow); + } +} diff --git a/src/lib/server/api/users/roles.service.ts b/src/lib/server/api/users/roles.service.ts new file mode 100644 index 0000000..595d24a --- /dev/null +++ b/src/lib/server/api/users/roles.service.ts @@ -0,0 +1,11 @@ +import { RolesRepository } from '$lib/server/api/users/roles.repository'; +import { inject, injectable } from '@needle-di/core'; + +@injectable() +export class RolesService { + constructor(private rolesRepository = inject(RolesRepository)) {} + + async findOneByNameOrThrow(name: string) { + return this.rolesRepository.findOneByNameOrThrow(name); + } +} diff --git a/src/lib/server/api/users/user_roles.repository.ts b/src/lib/server/api/users/user_roles.repository.ts new file mode 100644 index 0000000..aa5e4da --- /dev/null +++ b/src/lib/server/api/users/user_roles.repository.ts @@ -0,0 +1,39 @@ +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, eq } from 'drizzle-orm'; +import { takeFirstOrThrow } from '../common/utils/repository'; +import { user_roles } from '../databases/postgres/tables'; + +export type CreateUserRole = InferInsertModel; +export type UpdateUserRole = Partial; + +@injectable() +export class UserRolesRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async findOneById(id: string, db = this.drizzle.db) { + return db.query.user_roles.findFirst({ + where: eq(user_roles.id, id), + }); + } + + async findOneByIdOrThrow(id: string, db = this.drizzle.db) { + const userRole = await this.findOneById(id, db); + if (!userRole) throw Error('User not found'); + return userRole; + } + + async findAllByUserId(userId: string, db = this.drizzle.db) { + return db.query.user_roles.findMany({ + where: eq(user_roles.user_id, userId), + }); + } + + async create(data: CreateUserRole, db = this.drizzle.db) { + return db.insert(user_roles).values(data).returning().then(takeFirstOrThrow); + } + + async delete(id: string, db = this.drizzle.db) { + return db.delete(user_roles).where(eq(user_roles.id, id)).returning().then(takeFirstOrThrow); + } +} diff --git a/src/lib/server/api/users/user_roles.service.ts b/src/lib/server/api/users/user_roles.service.ts new file mode 100644 index 0000000..d9b80ea --- /dev/null +++ b/src/lib/server/api/users/user_roles.service.ts @@ -0,0 +1,51 @@ +import type { db } from '$lib/server/api/packages/drizzle'; +import { RolesService } from '$lib/server/api/users/roles.service'; +import { type CreateUserRole, UserRolesRepository } from '$lib/server/api/users/user_roles.repository'; +import { inject, injectable } from '@needle-di/core'; + +@injectable() +export class UserRolesService { + constructor( + private userRolesRepository = inject(UserRolesRepository), + private rolesService = inject(RolesService), + ) {} + + async findOneById(id: string) { + return this.userRolesRepository.findOneById(id); + } + + async findAllByUserId(userId: string) { + return this.userRolesRepository.findAllByUserId(userId); + } + + async create(data: CreateUserRole) { + return this.userRolesRepository.create(data); + } + + async addRoleToUser(userId: string, roleName: string, primary = false, trx: Parameters[0]>[0] | null = null) { + // Find the role by its name + const role = await this.rolesService.findOneByNameOrThrow(roleName); + + if (!role || !role.id) { + throw new Error(`Role with name ${roleName} not found`); + } + + if (!trx) { + return this.userRolesRepository.create({ + user_id: userId, + role_id: role.id, + primary, + }); + } + + // Create a UserRole entry linking the user and the role + return this.userRolesRepository.create( + { + user_id: userId, + role_id: role.id, + primary, + }, + trx, + ); + } +} diff --git a/src/lib/server/api/users/users.controller.ts b/src/lib/server/api/users/users.controller.ts new file mode 100644 index 0000000..f41a10d --- /dev/null +++ b/src/lib/server/api/users/users.controller.ts @@ -0,0 +1,43 @@ +import { requireFullAuth, requireTempAuth } from '$lib/server/api/common/middleware/require-auth.middleware'; +import { Controller } from '$lib/server/api/common/types/controller'; +import { UsersService } from '$lib/server/api/users/users.service'; +import { inject, injectable } from '@needle-di/core'; +import { authState } from '../common/middleware/auth.middleware'; +import { zValidator } from '@hono/zod-validator'; +import { updateProfileDto } from '../dtos/update-profile.dto'; + +@injectable() +export class UsersController extends Controller { + constructor(private usersService = inject(UsersService)) { + super(); + } + + routes() { + return this.controller + .get('/me', async (c) => { + const session = c.var.session; + const user = session ? await this.usersService.findOneById(session.userId) : null; + return c.json({ user, session }); + }) + .patch('/me', authState('session'), zValidator('json', updateProfileDto), async (c) => { + await this.usersService.updateUser(c.var.session.userId, c.req.valid('json')); + const user = await this.usersService.findOneById(c.var.session.userId); + return c.json(user); + }) + .get('/', requireTempAuth, async (c) => { + const session = c.var.session; + const user = session ? await this.usersService.findOneById(session.userId) : null; + return c.json({ user, session }); + }) + .get('/:id', requireFullAuth, async (c) => { + const id = c.req.param('id'); + const user = await this.usersService.findOneById(id); + return c.json({ user }); + }) + .get('/username/:userName', requireFullAuth, async (c) => { + const userName = c.req.param('userName'); + const user = await this.usersService.findOneByUsername(userName); + return c.json({ user }); + }); + } +} diff --git a/src/lib/server/api/users/users.repository.ts b/src/lib/server/api/users/users.repository.ts new file mode 100644 index 0000000..b2ee300 --- /dev/null +++ b/src/lib/server/api/users/users.repository.ts @@ -0,0 +1,44 @@ +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; +import { usersTable } from '$lib/server/api/databases/postgres/tables/users.table'; +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, eq } from 'drizzle-orm'; +import { takeFirstOrThrow } from '../common/utils/repository'; +import { takeFirst } from '../common/utils/drizzle'; + +export type Create = InferInsertModel; +export type Update = Partial; + +@injectable() +export class UsersRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async findOneById(id: string, db = this.drizzle.db) { + return db.select().from(usersTable).where(eq(usersTable.id, id)).then(takeFirst); + } + + async findOneByIdOrThrow(id: string, db = this.drizzle.db) { + const user = await this.findOneById(id); + if (!user) throw Error('User not found'); + return user; + } + + async findOneByUsername(username: string, db = this.drizzle.db) { + return db.select().from(usersTable).where(eq(usersTable.username, username)).then(takeFirst); + } + + async findOneByEmail(email: string, db = this.drizzle.db) { + return db.select().from(usersTable).where(eq(usersTable.email, email)).then(takeFirst); + } + + async create(data: Create, db = this.drizzle.db) { + return db.insert(usersTable).values(data).returning().then(takeFirstOrThrow); + } + + async update(id: string, data: Update, db = this.drizzle.db) { + return db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow); + } + + async delete(id: string, db = this.drizzle.db) { + return db.delete(usersTable).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow); + } +} diff --git a/src/lib/server/api/users/users.service.ts b/src/lib/server/api/users/users.service.ts new file mode 100644 index 0000000..7ce7454 --- /dev/null +++ b/src/lib/server/api/users/users.service.ts @@ -0,0 +1,151 @@ +import type { OAuthUser } from '$lib/server/api/common/types/oauth'; +import type { SignupUsernameEmailDto } from '$lib/server/api/dtos/signup-username-email.dto'; +import { TokensService } from '$lib/server/api/services/tokens.service'; +import { CredentialsRepository } from '$lib/server/api/users/credentials.repository'; +import { FederatedIdentityRepository } from '$lib/server/api/users/federated_identity.repository'; +import { UserRolesService } from '$lib/server/api/users/user_roles.service'; +import { WishlistsRepository } from '$lib/server/api/wishlists/wishlists.repository'; +import { inject, injectable } from '@needle-di/core'; +import { CollectionsService } from '../collections/collections.service'; +import { DrizzleService } from '../databases/postgres/drizzle.service'; +import { CredentialsType, RoleName } from '../databases/postgres/tables'; +import { WishlistsService } from '../wishlists/wishlists.service'; +import type { UpdateUserDto } from './dtos/update-user.dto'; +import { type Update, UsersRepository } from './users.repository'; + +@injectable() +export class UsersService { + constructor( + private collectionsService = inject(CollectionsService), + private credentialsRepository = inject(CredentialsRepository), + private drizzleService = inject(DrizzleService), + private federatedIdentityRepository = inject(FederatedIdentityRepository), + private tokenService = inject(TokensService), + private usersRepository = inject(UsersRepository), + private userRolesService = inject(UserRolesService), + private wishlistsRepository = inject(WishlistsRepository), + private wishlistsService = inject(WishlistsService), + ) {} + + async create(data: SignupUsernameEmailDto) { + const { firstName, lastName, email, username, password } = data; + + const hashedPassword = await this.tokenService.createHashedToken(password); + return await this.drizzleService.db.transaction(async (trx) => { + const createdUser = await this.usersRepository.create( + { + first_name: firstName, + last_name: lastName, + email, + username, + }, + trx, + ); + + if (!createdUser) { + return null; + } + + const credentials = await this.credentialsRepository.create( + { + user_id: createdUser.id, + type: CredentialsType.PASSWORD, + secret_data: hashedPassword, + }, + trx, + ); + + if (!credentials) { + await this.usersRepository.delete(createdUser.id); + return null; + } + + await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx); + + await this.wishlistsService.createEmptyNoName(createdUser.id, trx); + await this.collectionsService.createEmptyNoName(createdUser.id, trx); + return createdUser; + }); + } + + async createOAuthUser(oAuthUser: OAuthUser, oauthProvider: string) { + return await this.drizzleService.db.transaction(async (trx) => { + const createdUser = await this.usersRepository.create( + { + username: oAuthUser.username || oAuthUser.username, + email: oAuthUser.email || null, + first_name: oAuthUser.given_name || null, + last_name: oAuthUser.family_name || null, + picture: oAuthUser.picture || null, + email_verified: oAuthUser.email_verified || false, + }, + trx, + ); + + if (!createdUser) { + return null; + } + + await this.federatedIdentityRepository.create( + { + identity_provider: oauthProvider, + user_id: createdUser.id, + federated_user_id: oAuthUser.sub, + federated_username: oAuthUser.email || oAuthUser.username, + }, + trx, + ); + + await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx); + + await this.wishlistsService.createEmptyNoName(createdUser.id, trx); + await this.collectionsService.createEmptyNoName(createdUser.id, trx); + return createdUser; + }); + } + + async updateUser(userId: string, data: UpdateUserDto) { + return this.usersRepository.update(userId, data); + } + + async findOneByUsername(username: string) { + return this.usersRepository.findOneByUsername(username); + } + + async findOneByEmail(email: string) { + return this.usersRepository.findOneByEmail(email); + } + + async findOneById(id: string) { + return this.usersRepository.findOneById(id); + } + + async updatePassword(userId: string, password: string) { + const hashedPassword = await this.tokenService.createHashedToken(password); + const currentCredentials = await this.credentialsRepository.findPasswordCredentialsByUserId(userId); + if (!currentCredentials) { + await this.credentialsRepository.create({ + user_id: userId, + type: CredentialsType.PASSWORD, + secret_data: hashedPassword, + }); + } else { + await this.credentialsRepository.update(currentCredentials.id, { + secret_data: hashedPassword, + }); + } + } + + async verifyPassword(userId: string, data: { password: string }) { + const user = await this.usersRepository.findOneById(userId); + if (!user) { + throw new Error('User not found'); + } + const credential = await this.credentialsRepository.findOneByUserIdAndType(userId, CredentialsType.PASSWORD); + if (!credential) { + throw new Error('Password credentials not found'); + } + const { password } = data; + return this.tokenService.verifyHashedToken(credential.secret_data, password); + } +} diff --git a/src/lib/server/api/controllers/wishlist.controller.ts b/src/lib/server/api/wishlists/wishlist.controller.ts similarity index 81% rename from src/lib/server/api/controllers/wishlist.controller.ts rename to src/lib/server/api/wishlists/wishlist.controller.ts index 2ba445f..faaf8ca 100644 --- a/src/lib/server/api/controllers/wishlist.controller.ts +++ b/src/lib/server/api/wishlists/wishlist.controller.ts @@ -1,7 +1,7 @@ +import { requireFullAuth } from '$lib/server/api/common/middleware/require-auth.middleware'; import { Controller } from '$lib/server/api/common/types/controller'; -import { WishlistsService } from '$lib/server/api/services/wishlists.service'; +import { WishlistsService } from '$lib/server/api/wishlists/wishlists.service'; import { inject, injectable } from '@needle-di/core'; -import { requireFullAuth } from '../middleware/require-auth.middleware'; @injectable() export class WishlistController extends Controller { diff --git a/src/lib/server/api/wishlists/wishlists.repository.ts b/src/lib/server/api/wishlists/wishlists.repository.ts new file mode 100644 index 0000000..f9ffd2f --- /dev/null +++ b/src/lib/server/api/wishlists/wishlists.repository.ts @@ -0,0 +1,66 @@ +import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service'; +import { inject, injectable } from '@needle-di/core'; +import { type InferInsertModel, eq } from 'drizzle-orm'; +import { takeFirstOrThrow } from '../common/utils/repository'; +import { wishlistsTable } from '../databases/postgres/tables'; + +export type CreateWishlist = InferInsertModel; +export type UpdateWishlist = Partial; + +@injectable() +export class WishlistsRepository { + constructor(private drizzle = inject(DrizzleService)) {} + + async findAll(db = this.drizzle.db) { + return db.query.wishlistsTable.findMany(); + } + + async findOneById(id: string, db = this.drizzle.db) { + return db.query.wishlistsTable.findFirst({ + where: eq(wishlistsTable.id, id), + columns: { + cuid: true, + name: true, + }, + }); + } + + async findOneByCuid(cuid: string, db = this.drizzle.db) { + return db.query.wishlistsTable.findFirst({ + where: eq(wishlistsTable.cuid, cuid), + columns: { + cuid: true, + name: true, + }, + }); + } + + async findOneByUserId(userId: string, db = this.drizzle.db) { + return db.query.wishlistsTable.findFirst({ + where: eq(wishlistsTable.user_id, userId), + columns: { + cuid: true, + name: true, + }, + }); + } + + async findAllByUserId(userId: string, db = this.drizzle.db) { + return db.query.wishlistsTable.findMany({ + where: eq(wishlistsTable.user_id, userId), + columns: { + cuid: true, + name: true, + createdAt: true, + }, + }); + } + + async create(data: CreateWishlist, db = this.drizzle.db) { + return db.insert(wishlistsTable).values(data).returning().then(takeFirstOrThrow); + } + + async update(id: string, data: UpdateWishlist, db = this.drizzle.db) { + return db.update(wishlistsTable).set(data).where(eq(wishlistsTable.id, id)).returning().then(takeFirstOrThrow); + } +} diff --git a/src/lib/server/api/wishlists/wishlists.service.ts b/src/lib/server/api/wishlists/wishlists.service.ts new file mode 100644 index 0000000..c82c782 --- /dev/null +++ b/src/lib/server/api/wishlists/wishlists.service.ts @@ -0,0 +1,41 @@ +import type { db } from '$lib/server/api/packages/drizzle'; +import { generateRandomAnimalName } from '$lib/utils/randomDataUtil'; +import { inject, injectable } from '@needle-di/core'; +import { WishlistsRepository } from './wishlists.repository'; + +@injectable() +export class WishlistsService { + constructor(private wishlistsRepository = inject(WishlistsRepository)) {} + + async findAllByUserId(userId: string) { + return this.wishlistsRepository.findAllByUserId(userId); + } + + async findOneById(id: string) { + return this.wishlistsRepository.findOneById(id); + } + + async findOneByCuid(cuid: string) { + return this.wishlistsRepository.findOneByCuid(cuid); + } + + async createEmptyNoName(userId: string, trx: Parameters[0]>[0] | null = null) { + return this.createEmpty(userId, null, trx); + } + + async createEmpty(userId: string, name: string | null, trx: Parameters[0]>[0] | null = null) { + if (!trx) { + return this.wishlistsRepository.create({ + user_id: userId, + name: name ?? generateRandomAnimalName(), + }); + } + return this.wishlistsRepository.create( + { + user_id: userId, + name: name ?? generateRandomAnimalName(), + }, + trx, + ); + } +} diff --git a/src/lib/server/auth-utils.ts b/src/lib/server/auth-utils.ts index 820a8fc..8b5702e 100644 --- a/src/lib/server/auth-utils.ts +++ b/src/lib/server/auth-utils.ts @@ -1,9 +1,19 @@ +import { generateId } from '$lib/server/api/common/utils/crypto'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; import { eq } from 'drizzle-orm'; -import { generateIdFromEntropySize } from 'lucia'; -import { createDate, TimeSpan } from 'oslo'; -import { password_reset_tokens, type Users } from './api/databases/postgres/tables'; +import { type Users, password_reset_tokens } from './api/databases/postgres/tables'; +import type { Session } from './api/iam/sessions/sessions.service'; import { db } from './api/packages/drizzle'; -import type { Session } from './api/services/sessions.service'; + +dayjs.extend(relativeTime); + +function generateCode() { + // alphabet with removed look-alike characters (0, 1, O, I) + const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; + // generate 6 character long random string + return generateId(6, alphabet); +} export async function createPasswordResetToken(userId: string): Promise { // optionally invalidate all existing tokens @@ -12,7 +22,7 @@ export async function createPasswordResetToken(userId: string): Promise await db.insert(password_reset_tokens).values({ id: tokenId, user_id: userId, - expires_at: createDate(new TimeSpan(2, 'h')), + expires_at: dayjs().add(30, 'day').toDate(), }); return tokenId; } @@ -35,7 +45,7 @@ export function userNotFullyAuthenticated(user: Users | null, session: Session | * @param {Session | null} session - The session object. * @returns {boolean} True if the user is not fully authenticated, otherwise false. */ -export function userNotAuthenticated(user: User | null, session: Session | null) { +export function userNotAuthenticated(user: Users | null, session: Session | null) { return !user || !session || userNotFullyAuthenticated(user, session); } @@ -46,6 +56,6 @@ export function userNotAuthenticated(user: User | null, session: Session | null) * @param {Session | null} session - The session object. * @returns {boolean} True if the user is fully authenticated, otherwise false. */ -export function userFullyAuthenticated(user: User | null, session: Session | null) { +export function userFullyAuthenticated(user: Users | null, session: Session | null) { return !userNotAuthenticated(user, session); } diff --git a/src/lib/utils/status-codes.ts b/src/lib/utils/status-codes.ts new file mode 100644 index 0000000..17d1c8e --- /dev/null +++ b/src/lib/utils/status-codes.ts @@ -0,0 +1,358 @@ +// Taken from https://github.com/prettymuchbryce/http-status-codes/blob/master/src/status-codes.js + +export enum StatusCodes { + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.1 + * + * This interim response indicates that everything so far is OK and that the client should continue with the request or ignore it if it is already finished. + */ + CONTINUE = 100, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.2 + * + * This code is sent in response to an Upgrade request header by the client, and indicates the protocol the server is switching too. + */ + SWITCHING_PROTOCOLS = 101, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.1 + * + * This code indicates that the server has received and is processing the request, but no response is available yet. + */ + PROCESSING = 102, + /** + * Official Documentation @ https://www.rfc-editor.org/rfc/rfc8297#page-3 + * + * This code indicates to the client that the server is likely to send a final response with the header fields included in the informational response. + */ + EARLY_HINTS = 103, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.1 + * + * The request has succeeded. The meaning of a success varies depending on the HTTP method: + * GET: The resource has been fetched and is transmitted in the message body. + * HEAD: The entity headers are in the message body. + * POST: The resource describing the result of the action is transmitted in the message body. + * TRACE: The message body contains the request message as received by the server + */ + OK = 200, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.2 + * + * The request has succeeded and a new resource has been created as a result of it. This is typically the response sent after a PUT request. + */ + CREATED = 201, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.3 + * + * The request has been received but not yet acted upon. It is non-committal, meaning that there is no way in HTTP to later send an asynchronous response indicating the outcome of processing the request. It is intended for cases where another process or server handles the request, or for batch processing. + */ + ACCEPTED = 202, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.4 + * + * This response code means returned meta-information set is not exact set as available from the origin server, but collected from a local or a third party copy. Except this condition, 200 OK response should be preferred instead of this response. + */ + NON_AUTHORITATIVE_INFORMATION = 203, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.5 + * + * There is no content to send for this request, but the headers may be useful. The user-agent may update its cached headers for this resource with the new ones. + */ + NO_CONTENT = 204, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.6 + * + * This response code is sent after accomplishing request to tell user agent reset document view which sent this request. + */ + RESET_CONTENT = 205, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.1 + * + * This response code is used because of range header sent by the client to separate download into multiple streams. + */ + PARTIAL_CONTENT = 206, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.2 + * + * A Multi-Status response conveys information about multiple resources in situations where multiple status codes might be appropriate. + */ + MULTI_STATUS = 207, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.1 + * + * The request has more than one possible responses. User-agent or user should choose one of them. There is no standardized way to choose one of the responses. + */ + MULTIPLE_CHOICES = 300, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.2 + * + * This response code means that URI of requested resource has been changed. Probably, new URI would be given in the response. + */ + MOVED_PERMANENTLY = 301, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.3 + * + * This response code means that URI of requested resource has been changed temporarily. New changes in the URI might be made in the future. Therefore, this same URI should be used by the client in future requests. + */ + MOVED_TEMPORARILY = 302, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.4 + * + * Server sent this response to directing client to get requested resource to another URI with an GET request. + */ + SEE_OTHER = 303, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.1 + * + * This is used for caching purposes. It is telling to client that response has not been modified. So, client can continue to use same cached version of response. + */ + NOT_MODIFIED = 304, + /** + * @deprecated + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.6 + * + * Was defined in a previous version of the HTTP specification to indicate that a requested response must be accessed by a proxy. It has been deprecated due to security concerns regarding in-band configuration of a proxy. + */ + USE_PROXY = 305, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.7 + * + * Server sent this response to directing client to get requested resource to another URI with same method that used prior request. This has the same semantic than the 302 Found HTTP response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the second request. + */ + TEMPORARY_REDIRECT = 307, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7538#section-3 + * + * This means that the resource is now permanently located at another URI, specified by the Location: HTTP Response header. This has the same semantics as the 301 Moved Permanently HTTP response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the second request. + */ + PERMANENT_REDIRECT = 308, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.1 + * + * This response means that server could not understand the request due to invalid syntax. + */ + BAD_REQUEST = 400, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.1 + * + * Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response. + */ + UNAUTHORIZED = 401, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.2 + * + * This response code is reserved for future use. Initial aim for creating this code was using it for digital payment systems however this is not used currently. + */ + PAYMENT_REQUIRED = 402, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.3 + * + * The client does not have access rights to the content, i.e. they are unauthorized, so server is rejecting to give proper response. Unlike 401, the client's identity is known to the server. + */ + FORBIDDEN = 403, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.4 + * + * The server can not find requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 to hide the existence of a resource from an unauthorized client. This response code is probably the most famous one due to its frequent occurence on the web. + */ + NOT_FOUND = 404, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.5 + * + * The request method is known by the server but has been disabled and cannot be used. For example, an API may forbid DELETE-ing a resource. The two mandatory methods, GET and HEAD, must never be disabled and should not return this error code. + */ + METHOD_NOT_ALLOWED = 405, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.6 + * + * This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content following the criteria given by the user agent. + */ + NOT_ACCEPTABLE = 406, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.2 + * + * This is similar to 401 but authentication is needed to be done by a proxy. + */ + PROXY_AUTHENTICATION_REQUIRED = 407, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.7 + * + * This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection mechanisms to speed up surfing. Also note that some servers merely shut down the connection without sending this message. + */ + REQUEST_TIMEOUT = 408, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.8 + * + * This response is sent when a request conflicts with the current state of the server. + */ + CONFLICT = 409, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.9 + * + * This response would be sent when the requested content has been permenantly deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code. + */ + GONE = 410, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.10 + * + * The server rejected the request because the Content-Length header field is not defined and the server requires it. + */ + LENGTH_REQUIRED = 411, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.2 + * + * The client has indicated preconditions in its headers which the server does not meet. + */ + PRECONDITION_FAILED = 412, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.11 + * + * Request entity is larger than limits defined by server; the server might close the connection or return an Retry-After header field. + */ + REQUEST_TOO_LONG = 413, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.12 + * + * The URI requested by the client is longer than the server is willing to interpret. + */ + REQUEST_URI_TOO_LONG = 414, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.13 + * + * The media format of the requested data is not supported by the server, so the server is rejecting the request. + */ + UNSUPPORTED_MEDIA_TYPE = 415, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.4 + * + * The range specified by the Range header field in the request can't be fulfilled; it's possible that the range is outside the size of the target URI's data. + */ + REQUESTED_RANGE_NOT_SATISFIABLE = 416, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.14 + * + * This response code means the expectation indicated by the Expect request header field can't be met by the server. + */ + EXPECTATION_FAILED = 417, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2324#section-2.3.2 + * + * Any attempt to brew coffee with a teapot should result in the error code "418 I'm a teapot". The resulting entity body MAY be short and stout. + */ + IM_A_TEAPOT = 418, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6 + * + * The 507 (Insufficient Storage) status code means the method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request. This condition is considered to be temporary. If the request which received this status code was the result of a user action, the request MUST NOT be repeated until it is requested by a separate user action. + */ + INSUFFICIENT_SPACE_ON_RESOURCE = 419, + /** + * @deprecated + * Official Documentation @ https://tools.ietf.org/rfcdiff?difftype=--hwdiff&url2=draft-ietf-webdav-protocol-06.txt + * + * A deprecated response used by the Spring Framework when a method has failed. + */ + METHOD_FAILURE = 420, + /** + * Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7540#section-9.1.2 + * + * Defined in the specification of HTTP/2 to indicate that a server is not able to produce a response for the combination of scheme and authority that are included in the request URI. + */ + MISDIRECTED_REQUEST = 421, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.3 + * + * The request was well-formed but was unable to be followed due to semantic errors. + */ + UNPROCESSABLE_ENTITY = 422, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.4 + * + * The resource that is being accessed is locked. + */ + LOCKED = 423, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.5 + * + * The request failed due to failure of a previous request. + */ + FAILED_DEPENDENCY = 424, + /** + * Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.15 + * + * The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. + */ + UPGRADE_REQUIRED = 426, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-3 + * + * The origin server requires the request to be conditional. Intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict. + */ + PRECONDITION_REQUIRED = 428, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-4 + * + * The user has sent too many requests in a given amount of time ("rate limiting"). + */ + TOO_MANY_REQUESTS = 429, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-5 + * + * The server is unwilling to process the request because its header fields are too large. The request MAY be resubmitted after reducing the size of the request header fields. + */ + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7725 + * + * The user-agent requested a resource that cannot legally be provided, such as a web page censored by a government. + */ + UNAVAILABLE_FOR_LEGAL_REASONS = 451, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.1 + * + * The server encountered an unexpected condition that prevented it from fulfilling the request. + */ + INTERNAL_SERVER_ERROR = 500, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2 + * + * The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that must not return this code) are GET and HEAD. + */ + NOT_IMPLEMENTED = 501, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.3 + * + * This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response. + */ + BAD_GATEWAY = 502, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.4 + * + * The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This responses should be used for temporary conditions and the Retry-After: HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached. + */ + SERVICE_UNAVAILABLE = 503, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.5 + * + * This error response is given when the server is acting as a gateway and cannot get a response in time. + */ + GATEWAY_TIMEOUT = 504, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.6 + * + * The HTTP version used in the request is not supported by the server. + */ + HTTP_VERSION_NOT_SUPPORTED = 505, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6 + * + * The server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process. + */ + INSUFFICIENT_STORAGE = 507, + /** + * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-6 + * + * The 511 status code indicates that the client needs to authenticate to gain network access. + */ + NETWORK_AUTHENTICATION_REQUIRED = 511, +} diff --git a/src/lib/utils/ui.ts b/src/lib/utils/ui.ts index ab648bd..59bd1f4 100644 --- a/src/lib/utils/ui.ts +++ b/src/lib/utils/ui.ts @@ -1,51 +1,51 @@ -import {type ClassValue, clsx} from 'clsx' -import {cubicOut} from 'svelte/easing' -import type {TransitionConfig} from 'svelte/transition' -import {twMerge} from 'tailwind-merge' +import { type ClassValue, clsx } from 'clsx'; +import { cubicOut } from 'svelte/easing'; +import type { TransitionConfig } from 'svelte/transition'; +import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } type FlyAndScaleParams = { - y?: number - x?: number - start?: number - duration?: number -} + y?: number; + x?: number; + start?: number; + duration?: number; +}; export const flyAndScale = (node: Element, params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }): TransitionConfig => { - const style = getComputedStyle(node) - const transform = style.transform === 'none' ? '' : style.transform + const style = getComputedStyle(node); + const transform = style.transform === 'none' ? '' : style.transform; - const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => { - const [minA, maxA] = scaleA - const [minB, maxB] = scaleB + const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => { + const [minA, maxA] = scaleA; + const [minB, maxB] = scaleB; - const percentage = (valueA - minA) / (maxA - minA) - return percentage * (maxB - minB) + minB - } + const percentage = (valueA - minA) / (maxA - minA); + return percentage * (maxB - minB) + minB; + }; - const styleToString = (style: Record): string => { - return Object.keys(style).reduce((str, key) => { - if (style[key] === undefined) return str - return `${str}${key}:${style[key]};` - }, '') - } + const styleToString = (style: Record): string => { + return Object.keys(style).reduce((str, key) => { + if (style[key] === undefined) return str; + return `${str}${key}:${style[key]};`; + }, ''); + }; - return { - duration: params.duration ?? 200, - delay: 0, - css: (t) => { - const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]) - const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]) - const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]) + return { + duration: params.duration ?? 200, + delay: 0, + css: (t) => { + const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); + const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); + const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); - return styleToString({ - transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, - opacity: t, - }) - }, - easing: cubicOut, - } -} + return styleToString({ + transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, + opacity: t, + }); + }, + easing: cubicOut, + }; +}; diff --git a/src/routes/(auth)/auth/callback/github/+server.ts b/src/routes/(auth)/auth/callback/github/+server.ts index 0b6d59c..d877e5c 100644 --- a/src/routes/(auth)/auth/callback/github/+server.ts +++ b/src/routes/(auth)/auth/callback/github/+server.ts @@ -1,26 +1,26 @@ -import { StatusCodes } from '$lib/constants/status-codes'; +import { StatusCodes } from '$lib/utils/status-codes'; import type { RequestEvent } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; export async function GET(event: RequestEvent): Promise { - const { locals, url } = event; - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - console.log('code', code, 'state', state); + const { locals, url } = event; + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + console.log('code', code, 'state', state); - const { data, error } = await locals.api.oauth.github.$get({ query: { code, state } }).then(locals.parseApiResponse); + const { data, error } = await locals.api.oauth.github.$get({ query: { code, state } }).then(locals.parseApiResponse); - if (error) { - return new Response(JSON.stringify(error), { - status: 400, - }); - } + if (error) { + return new Response(JSON.stringify(error), { + status: 400, + }); + } - if (!data) { - return new Response(JSON.stringify({ message: 'Invalid request' }), { - status: 400, - }); - } + if (!data) { + return new Response(JSON.stringify({ message: 'Invalid request' }), { + status: 400, + }); + } - redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); + redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); } diff --git a/src/routes/(auth)/auth/callback/google/+server.ts b/src/routes/(auth)/auth/callback/google/+server.ts index 8ed31f6..863a7d7 100644 --- a/src/routes/(auth)/auth/callback/google/+server.ts +++ b/src/routes/(auth)/auth/callback/google/+server.ts @@ -1,26 +1,26 @@ -import { StatusCodes } from '$lib/constants/status-codes'; +import { StatusCodes } from '$lib/utils/status-codes'; import type { RequestEvent } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; export async function GET(event: RequestEvent): Promise { - const { locals, url } = event; - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - console.log('code', code, 'state', state); + const { locals, url } = event; + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + console.log('code', code, 'state', state); - const { data, error } = await locals.api.oauth.google.$get({ query: { code, state } }).then(locals.parseApiResponse); + const { data, error } = await locals.api.oauth.google.$get({ query: { code, state } }).then(locals.parseApiResponse); - if (error) { - return new Response(JSON.stringify(error), { - status: 400, - }); - } + if (error) { + return new Response(JSON.stringify(error), { + status: 400, + }); + } - if (!data) { - return new Response(JSON.stringify({ message: 'Invalid request' }), { - status: 400, - }); - } + if (!data) { + return new Response(JSON.stringify({ message: 'Invalid request' }), { + status: 400, + }); + } - redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); + redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); } diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 53bd2f8..896ac9f 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -1,5 +1,5 @@ -import { StatusCodes } from '$lib/constants/status-codes'; import { signinUsernameDto } from '$lib/dtos/signin-username.dto'; +import { StatusCodes } from '$lib/utils/status-codes'; import { type Actions, fail } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; import { zod } from 'sveltekit-superforms/adapters'; diff --git a/src/routes/(auth)/logout/+page.server.ts b/src/routes/(auth)/logout/+page.server.ts index afe3a1c..5d6b597 100644 --- a/src/routes/(auth)/logout/+page.server.ts +++ b/src/routes/(auth)/logout/+page.server.ts @@ -1,12 +1,12 @@ -import { StatusCodes } from '$lib/constants/status-codes'; +import { StatusCodes } from '$lib/utils/status-codes'; import { redirect } from 'sveltekit-flash-message/server'; import type { Actions } from './$types'; export const actions: Actions = { - default: async (event) => { - const { locals } = event; - console.log('Signing out user'); - await locals.api.me.logout.$post(); - redirect(StatusCodes.SEE_OTHER, '/login'); - }, + default: async (event) => { + const { locals } = event; + console.log('Signing out user'); + await locals.api.me.logout.$post(); + redirect(StatusCodes.SEE_OTHER, '/login'); + }, }; diff --git a/src/routes/(auth)/password/reset/+page.server.ts b/src/routes/(auth)/password/reset/+page.server.ts index 63fa8e7..be9f0ac 100644 --- a/src/routes/(auth)/password/reset/+page.server.ts +++ b/src/routes/(auth)/password/reset/+page.server.ts @@ -1,5 +1,5 @@ -import { StatusCodes } from '$lib/constants/status-codes'; import { notSignedInMessage } from '$lib/flashMessages'; +import { StatusCodes } from '$lib/utils/status-codes'; import { resetPasswordEmailSchema, resetPasswordTokenSchema } from '$lib/validations/auth'; import { fail } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; @@ -8,49 +8,49 @@ import { setError, superValidate } from 'sveltekit-superforms/server'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { - return { - emailForm: await superValidate(zod(resetPasswordEmailSchema)), - tokenForm: await superValidate(zod(resetPasswordTokenSchema)), - }; + return { + emailForm: await superValidate(zod(resetPasswordEmailSchema)), + tokenForm: await superValidate(zod(resetPasswordTokenSchema)), + }; }; export const actions = { - passwordReset: async (event) => { - const { request, locals } = event; + passwordReset: async (event) => { + const { request, locals } = event; - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const emailForm = await superValidate(request, zod(resetPasswordEmailSchema)); - if (!emailForm.valid) { - return fail(StatusCodes.BAD_REQUEST, { emailForm }); - } - // const error = {}; - // // const { error } = await locals.api.iam.login.request.$post({ json: emailRegisterForm.data }).then(locals.parseApiResponse); - // if (error) { - // return setError(emailForm, 'email', error); - // } - return { emailForm }; - }, - verifyToken: async (event) => { - const { request, locals } = event; + const emailForm = await superValidate(request, zod(resetPasswordEmailSchema)); + if (!emailForm.valid) { + return fail(StatusCodes.BAD_REQUEST, { emailForm }); + } + // const error = {}; + // // const { error } = await locals.api.iam.login.request.$post({ json: emailRegisterForm.data }).then(locals.parseApiResponse); + // if (error) { + // return setError(emailForm, 'email', error); + // } + return { emailForm }; + }, + verifyToken: async (event) => { + const { request, locals } = event; - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const tokenForm = await superValidate(request, zod(resetPasswordTokenSchema)); - if (!tokenForm.valid) { - return fail(StatusCodes.BAD_REQUEST, { tokenForm }); - } - const error = {}; - // const { error } = await locals.api.iam.login.verify.$post({ json: emailSignInForm.data }).then(locals.parseApiResponse) - if (error) { - return setError(tokenForm, 'token', error); - } - redirect(301, '/'); - }, + const tokenForm = await superValidate(request, zod(resetPasswordTokenSchema)); + if (!tokenForm.valid) { + return fail(StatusCodes.BAD_REQUEST, { tokenForm }); + } + const error = {}; + // const { error } = await locals.api.iam.login.verify.$post({ json: emailSignInForm.data }).then(locals.parseApiResponse) + if (error) { + return setError(tokenForm, 'token', error); + } + redirect(301, '/'); + }, };