From ef7149fbd5537cb059c8809e9d933150208025c4 Mon Sep 17 00:00:00 2001 From: underscope Date: Wed, 24 Sep 2025 13:27:59 +0200 Subject: [PATCH 01/57] Nest backend init --- .env.example | 259 +- .gitignore | 122 +- apps/backend/app.ts | 103 - apps/backend/config/ai.js | 3 - apps/backend/config/auth.js | 48 - apps/backend/config/consumer.js | 20 - apps/backend/config/database.js | 12 - apps/backend/config/general.js | 19 - apps/backend/config/index.js | 53 - apps/backend/config/kvStore.js | 11 - apps/backend/config/mail.js | 18 - apps/backend/config/storage.js | 14 - apps/backend/config/test.js | 3 - apps/backend/index.ts | 37 - apps/backend/mikro-orm.config.ts | 19 + apps/backend/nest-cli.json | 16 + apps/backend/nodemon.json | 7 - apps/backend/oidc/index.js | 61 - apps/backend/package.json | 214 +- apps/backend/router.js | 44 - apps/backend/script/addAdmin.js | 28 - apps/backend/script/addIntegration.js | 25 - .../script/generateIntegrationToken.js | 17 - apps/backend/script/inviteAdmin.js | 30 - apps/backend/script/preflight.js | 41 - apps/backend/script/sequelize.js | 67 - apps/backend/sequelize.config.cjs | 10 - apps/backend/shared/ai/ai.controller.js | 10 - apps/backend/shared/ai/ai.service.js | 67 - apps/backend/shared/ai/index.js | 11 - apps/backend/shared/auth/audience.js | 6 - apps/backend/shared/auth/authenticator.js | 83 - apps/backend/shared/auth/index.js | 115 - apps/backend/shared/auth/mw.js | 23 - apps/backend/shared/auth/oidc.js | 86 - apps/backend/shared/database/config.js | 46 - apps/backend/shared/database/helpers.js | 113 - apps/backend/shared/database/hooks.js | 12 - apps/backend/shared/database/index.js | 167 - .../migrations/20181115140901-create-user.js | 50 - .../shared/database/migrations/package.json | 3 - apps/backend/shared/database/pagination.js | 23 - .../seeds/20181115140901-insert-users.js | 31 - .../shared/database/seeds/package.json | 3 - apps/backend/shared/error/helpers.js | 12 - apps/backend/shared/logger.js | 31 - apps/backend/shared/mail/formatters.js | 7 - apps/backend/shared/mail/index.js | 95 - apps/backend/shared/mail/render.js | 38 - .../mail/templates/components/footer.mjml | 4 - .../mail/templates/components/head.mjml | 61 - .../mail/templates/components/header.mjml | 9 - apps/backend/shared/mail/templates/reset.mjml | 27 - apps/backend/shared/mail/templates/reset.txt | 15 - .../shared/mail/templates/welcome.mjml | 27 - .../backend/shared/mail/templates/welcome.txt | 15 - apps/backend/shared/oAuth2Provider.js | 54 - apps/backend/shared/origin.js | 19 - apps/backend/shared/request/mw.js | 56 - apps/backend/shared/sse/channels.js | 59 - apps/backend/shared/sse/index.js | 125 - apps/backend/shared/storage/index.js | 89 - .../shared/storage/providers/amazon.js | 199 - .../shared/storage/providers/filesystem.js | 99 - .../shared/storage/storage.controller.js | 29 - apps/backend/shared/storage/storage.router.js | 13 - .../backend/shared/storage/storage.service.js | 58 - apps/backend/shared/storage/util.js | 15 - apps/backend/shared/storage/validation.js | 26 - apps/backend/shared/util/Deferred.js | 9 - apps/backend/shared/util/processListQuery.js | 38 - apps/backend/src/app.module.ts | 48 + apps/backend/src/common/common.module.ts | 27 + apps/backend/src/common/decorators/.gitkeep | 0 .../common/filters/all-exceptions.filter.ts | 73 + .../common/filters/http-exception.filter.ts | 46 + .../filters/validation-exception.filter.ts | 49 + .../interceptors/logging.interceptor.ts | 50 + .../interceptors/response.interceptor.ts | 46 + apps/backend/src/common/pipes/.gitkeep | 0 .../utils/sanitize-request-body.util.ts | 100 + .../src/common/utils/sanitize-user.util.ts | 13 + apps/backend/src/config/auth.config.ts | 47 + apps/backend/src/config/db.config.ts | 35 + apps/backend/src/config/general.config.ts | 28 + apps/backend/src/config/index.ts | 6 + apps/backend/src/config/mail.config.ts | 43 + apps/backend/src/config/mikro-orm.config.ts | 44 + apps/backend/src/config/validation.ts | 13 + .../src/database/entities/base.entity.ts | 27 + apps/backend/src/database/entities/index.ts | 2 + .../src/database/entities/user.entity.ts | 103 + .../src/database/seeders/DatabaseSeeder.ts | 41 + apps/backend/src/main.ts | 87 + .../src/modules/auth/auth.controller.ts | 121 + apps/backend/src/modules/auth/auth.module.ts | 44 + apps/backend/src/modules/auth/auth.service.ts | 171 + .../auth/decorators/current-user.decorator.ts | 24 + .../src/modules/auth/decorators/index.ts | 3 + .../auth/decorators/public.decorator.ts | 4 + .../auth/decorators/roles.decorator.ts | 6 + apps/backend/src/modules/auth/dto/index.ts | 2 + .../backend/src/modules/auth/dto/login.dto.ts | 24 + .../modules/auth/dto/reset-password.dto.ts | 72 + apps/backend/src/modules/auth/guards/index.ts | 3 + .../src/modules/auth/guards/jwt-auth.guard.ts | 30 + .../modules/auth/guards/local-auth.guard.ts | 6 + .../src/modules/auth/guards/roles.guard.ts | 20 + .../src/modules/auth/strategies/index.ts | 2 + .../modules/auth/strategies/jwt.strategy.ts | 50 + .../modules/auth/strategies/local.strategy.ts | 18 + .../src/modules/health/health.controller.ts | 49 + .../src/modules/health/health.module.ts | 7 + apps/backend/src/modules/mail/mail.module.ts | 10 + apps/backend/src/modules/mail/mail.service.ts | 133 + .../src/modules/mail/template.service.ts | 98 + .../content/invitation.template.html | 25 + .../templates/content/invitation.template.txt | 20 + .../content/password-reset.template.html | 29 + .../content/password-reset.template.txt | 22 + .../templates/content/welcome.template.html | 25 + .../templates/content/welcome.template.txt | 20 + .../mail/templates/layouts/base.template.html | 16 + .../templates/partials/footer.template.html | 12 + .../templates/partials/header.template.html | 6 + .../templates/partials/styles.template.html | 112 + .../src/modules/user/dto/create-user.dto.ts | 52 + apps/backend/src/modules/user/dto/index.ts | 4 + .../src/modules/user/dto/query-user.dto.ts | 89 + .../src/modules/user/dto/update-user.dto.ts | 66 + .../src/modules/user/dto/user-response.dto.ts | 74 + .../src/modules/user/user.controller.ts | 124 + apps/backend/src/modules/user/user.module.ts | 17 + .../src/modules/user/user.repository.ts | 12 + apps/backend/src/modules/user/user.service.ts | 147 + apps/backend/tests/api/index.js | 16 - apps/backend/tests/api/seed.controller.js | 16 - apps/backend/tests/api/seed.service.js | 34 - apps/backend/tsconfig.json | 28 + apps/backend/user/index.js | 50 - apps/backend/user/mw.js | 22 - apps/backend/user/user.controller.js | 100 - apps/backend/user/user.model.js | 217 - apps/frontend/api/ai.js | 14 - apps/frontend/api/auth.js | 23 +- apps/frontend/api/user.js | 18 +- apps/frontend/components/admin/UserDialog.vue | 2 +- apps/frontend/components/common/AppBar.vue | 6 +- apps/frontend/pages/admin/user-management.vue | 6 +- apps/frontend/stores/auth.ts | 8 +- packages/app-seed/user.json | 2 +- pnpm-lock.yaml | 9272 ++++++++++------- 152 files changed, 8575 insertions(+), 7335 deletions(-) delete mode 100644 apps/backend/app.ts delete mode 100644 apps/backend/config/ai.js delete mode 100644 apps/backend/config/auth.js delete mode 100644 apps/backend/config/consumer.js delete mode 100644 apps/backend/config/database.js delete mode 100644 apps/backend/config/general.js delete mode 100644 apps/backend/config/index.js delete mode 100644 apps/backend/config/kvStore.js delete mode 100644 apps/backend/config/mail.js delete mode 100644 apps/backend/config/storage.js delete mode 100644 apps/backend/config/test.js delete mode 100644 apps/backend/index.ts create mode 100644 apps/backend/mikro-orm.config.ts create mode 100644 apps/backend/nest-cli.json delete mode 100644 apps/backend/nodemon.json delete mode 100644 apps/backend/oidc/index.js delete mode 100644 apps/backend/router.js delete mode 100644 apps/backend/script/addAdmin.js delete mode 100644 apps/backend/script/addIntegration.js delete mode 100644 apps/backend/script/generateIntegrationToken.js delete mode 100644 apps/backend/script/inviteAdmin.js delete mode 100644 apps/backend/script/preflight.js delete mode 100644 apps/backend/script/sequelize.js delete mode 100644 apps/backend/sequelize.config.cjs delete mode 100644 apps/backend/shared/ai/ai.controller.js delete mode 100644 apps/backend/shared/ai/ai.service.js delete mode 100644 apps/backend/shared/ai/index.js delete mode 100644 apps/backend/shared/auth/audience.js delete mode 100644 apps/backend/shared/auth/authenticator.js delete mode 100644 apps/backend/shared/auth/index.js delete mode 100644 apps/backend/shared/auth/mw.js delete mode 100644 apps/backend/shared/auth/oidc.js delete mode 100644 apps/backend/shared/database/config.js delete mode 100644 apps/backend/shared/database/helpers.js delete mode 100644 apps/backend/shared/database/hooks.js delete mode 100644 apps/backend/shared/database/index.js delete mode 100644 apps/backend/shared/database/migrations/20181115140901-create-user.js delete mode 100644 apps/backend/shared/database/migrations/package.json delete mode 100644 apps/backend/shared/database/pagination.js delete mode 100644 apps/backend/shared/database/seeds/20181115140901-insert-users.js delete mode 100644 apps/backend/shared/database/seeds/package.json delete mode 100644 apps/backend/shared/error/helpers.js delete mode 100644 apps/backend/shared/logger.js delete mode 100644 apps/backend/shared/mail/formatters.js delete mode 100644 apps/backend/shared/mail/index.js delete mode 100644 apps/backend/shared/mail/render.js delete mode 100644 apps/backend/shared/mail/templates/components/footer.mjml delete mode 100644 apps/backend/shared/mail/templates/components/head.mjml delete mode 100644 apps/backend/shared/mail/templates/components/header.mjml delete mode 100644 apps/backend/shared/mail/templates/reset.mjml delete mode 100644 apps/backend/shared/mail/templates/reset.txt delete mode 100644 apps/backend/shared/mail/templates/welcome.mjml delete mode 100644 apps/backend/shared/mail/templates/welcome.txt delete mode 100644 apps/backend/shared/oAuth2Provider.js delete mode 100644 apps/backend/shared/origin.js delete mode 100644 apps/backend/shared/request/mw.js delete mode 100644 apps/backend/shared/sse/channels.js delete mode 100644 apps/backend/shared/sse/index.js delete mode 100644 apps/backend/shared/storage/index.js delete mode 100644 apps/backend/shared/storage/providers/amazon.js delete mode 100644 apps/backend/shared/storage/providers/filesystem.js delete mode 100644 apps/backend/shared/storage/storage.controller.js delete mode 100644 apps/backend/shared/storage/storage.router.js delete mode 100644 apps/backend/shared/storage/storage.service.js delete mode 100644 apps/backend/shared/storage/util.js delete mode 100644 apps/backend/shared/storage/validation.js delete mode 100644 apps/backend/shared/util/Deferred.js delete mode 100644 apps/backend/shared/util/processListQuery.js create mode 100644 apps/backend/src/app.module.ts create mode 100644 apps/backend/src/common/common.module.ts create mode 100644 apps/backend/src/common/decorators/.gitkeep create mode 100644 apps/backend/src/common/filters/all-exceptions.filter.ts create mode 100644 apps/backend/src/common/filters/http-exception.filter.ts create mode 100644 apps/backend/src/common/filters/validation-exception.filter.ts create mode 100644 apps/backend/src/common/interceptors/logging.interceptor.ts create mode 100644 apps/backend/src/common/interceptors/response.interceptor.ts create mode 100644 apps/backend/src/common/pipes/.gitkeep create mode 100644 apps/backend/src/common/utils/sanitize-request-body.util.ts create mode 100644 apps/backend/src/common/utils/sanitize-user.util.ts create mode 100644 apps/backend/src/config/auth.config.ts create mode 100644 apps/backend/src/config/db.config.ts create mode 100644 apps/backend/src/config/general.config.ts create mode 100644 apps/backend/src/config/index.ts create mode 100644 apps/backend/src/config/mail.config.ts create mode 100644 apps/backend/src/config/mikro-orm.config.ts create mode 100644 apps/backend/src/config/validation.ts create mode 100644 apps/backend/src/database/entities/base.entity.ts create mode 100644 apps/backend/src/database/entities/index.ts create mode 100644 apps/backend/src/database/entities/user.entity.ts create mode 100644 apps/backend/src/database/seeders/DatabaseSeeder.ts create mode 100644 apps/backend/src/main.ts create mode 100644 apps/backend/src/modules/auth/auth.controller.ts create mode 100644 apps/backend/src/modules/auth/auth.module.ts create mode 100644 apps/backend/src/modules/auth/auth.service.ts create mode 100644 apps/backend/src/modules/auth/decorators/current-user.decorator.ts create mode 100644 apps/backend/src/modules/auth/decorators/index.ts create mode 100644 apps/backend/src/modules/auth/decorators/public.decorator.ts create mode 100644 apps/backend/src/modules/auth/decorators/roles.decorator.ts create mode 100644 apps/backend/src/modules/auth/dto/index.ts create mode 100644 apps/backend/src/modules/auth/dto/login.dto.ts create mode 100644 apps/backend/src/modules/auth/dto/reset-password.dto.ts create mode 100644 apps/backend/src/modules/auth/guards/index.ts create mode 100644 apps/backend/src/modules/auth/guards/jwt-auth.guard.ts create mode 100644 apps/backend/src/modules/auth/guards/local-auth.guard.ts create mode 100644 apps/backend/src/modules/auth/guards/roles.guard.ts create mode 100644 apps/backend/src/modules/auth/strategies/index.ts create mode 100644 apps/backend/src/modules/auth/strategies/jwt.strategy.ts create mode 100644 apps/backend/src/modules/auth/strategies/local.strategy.ts create mode 100644 apps/backend/src/modules/health/health.controller.ts create mode 100644 apps/backend/src/modules/health/health.module.ts create mode 100644 apps/backend/src/modules/mail/mail.module.ts create mode 100644 apps/backend/src/modules/mail/mail.service.ts create mode 100644 apps/backend/src/modules/mail/template.service.ts create mode 100644 apps/backend/src/modules/mail/templates/content/invitation.template.html create mode 100644 apps/backend/src/modules/mail/templates/content/invitation.template.txt create mode 100644 apps/backend/src/modules/mail/templates/content/password-reset.template.html create mode 100644 apps/backend/src/modules/mail/templates/content/password-reset.template.txt create mode 100644 apps/backend/src/modules/mail/templates/content/welcome.template.html create mode 100644 apps/backend/src/modules/mail/templates/content/welcome.template.txt create mode 100644 apps/backend/src/modules/mail/templates/layouts/base.template.html create mode 100644 apps/backend/src/modules/mail/templates/partials/footer.template.html create mode 100644 apps/backend/src/modules/mail/templates/partials/header.template.html create mode 100644 apps/backend/src/modules/mail/templates/partials/styles.template.html create mode 100644 apps/backend/src/modules/user/dto/create-user.dto.ts create mode 100644 apps/backend/src/modules/user/dto/index.ts create mode 100644 apps/backend/src/modules/user/dto/query-user.dto.ts create mode 100644 apps/backend/src/modules/user/dto/update-user.dto.ts create mode 100644 apps/backend/src/modules/user/dto/user-response.dto.ts create mode 100644 apps/backend/src/modules/user/user.controller.ts create mode 100644 apps/backend/src/modules/user/user.module.ts create mode 100644 apps/backend/src/modules/user/user.repository.ts create mode 100644 apps/backend/src/modules/user/user.service.ts delete mode 100644 apps/backend/tests/api/index.js delete mode 100644 apps/backend/tests/api/seed.controller.js delete mode 100644 apps/backend/tests/api/seed.service.js create mode 100644 apps/backend/tsconfig.json delete mode 100644 apps/backend/user/index.js delete mode 100644 apps/backend/user/mw.js delete mode 100644 apps/backend/user/user.controller.js delete mode 100644 apps/backend/user/user.model.js delete mode 100644 apps/frontend/api/ai.js diff --git a/.env.example b/.env.example index 6169cc6..5d4ee16 100644 --- a/.env.example +++ b/.env.example @@ -1,143 +1,126 @@ -# dotenv-linter:off UnorderedKey +# ============================================================================= +# BACKEND ENVIRONMENT CONFIGURATION +# ============================================================================= +# This file contains all environment variables used by the NestJS backend. +# Copy this file to .env and update the values according to your environment. +# ============================================================================= -# Logger -LOG_LEVEL=info +# ----------------------------------------------------------------------------- +# GENERAL CONFIGURATION +# From: apps/backend/src/config/general.config.ts +# ----------------------------------------------------------------------------- -# ------------------------------------------------------------------- -# Server configuration -# ------------------------------------------------------------------- -# Set to page DNS record once deployed -HOSTNAME=localhost -# Server port +# Server port - The port on which the backend server will run +# Default: 3000 PORT=3000 -# Protocol is used to generate the app URL. -# If omitted, if HOSTNAME is set to local address it will be set to http, -# otherwise it will be set to https. -PROTOCOL=http -# Port on which the app is available to the end user. In development -# mode, this configures the vite dev server which servers the application -# frontend. In production, this configures the port on which the app is -# available to the end user (443, if app is deployed with https configured). -REVERSE_PROXY_PORT=8080 -# If the app is behind a reverse proxy and rate limiting is enabled -# See https://expressjs.com/en/guide/behind-proxies.html -REVERSE_PROXY_TRUST=false - -# ------------------------------------------------------------------- -# Database configuration -# ------------------------------------------------------------------- -# You can pass the database connection string -DATABASE_URI=postgres://user:pass@hostname:port/database -# or individual database connection parameters -# DATABASE_NAME=app_starter -# DATABASE_USER=dev -# DATABASE_PASSWORD=dev -# DATABASE_HOST=localhost -# DATABASE_PORT=5432 -# DATABASE_ADAPTER=postgres - -# ------------------------------------------------------------------- -# In-memory store configuration -# ------------------------------------------------------------------- -# If KV_STORE_URL is omitted, in-memory store is used -# Example config: -# redis://user:pass@localhost:6379 -# See https://github.com/jaredwray/keyv/blob/main/packages/keyv/README.md#usage -KV_STORE_URL= -# ttl - time to live measured in seconds -# records do not expire by default -KV_STORE_DEFAULT_TTL=0 - -# ------------------------------------------------------------------- -# Security and authentication -# ------------------------------------------------------------------- -# Origins allowed in CORS requests. Multiple origins can be listed by using -# comma as a separator. In development, this matches vite dev server address: -CORS_ALLOWED_ORIGINS=http://localhost:8080 + +# Node environment +# Valid values: development, production, test, staging +# Default: development +NODE_ENV=development + +# CORS allowed origins - comma-separated list of allowed origins +# Multiple origins example: http://localhost:3000,https://app.example.com +# Default: http://localhost:3000 +CORS_ALLOWED_ORIGINS=http://localhost:3000 + +# ----------------------------------------------------------------------------- +# DATABASE CONFIGURATION +# From: apps/backend/src/config/db.config.ts +# ----------------------------------------------------------------------------- + +# PostgreSQL host address +# Default: localhost +DATABASE_HOST=localhost + +# PostgreSQL port +# Default: 5432 +DATABASE_PORT=5432 + +# Database name +# Default: app +DATABASE_NAME=app + +# Database username +# Default: dev +DATABASE_USERNAME=dev + +# Database password +# Default: dev +DATABASE_PASSWORD=dev + +# Enable SSL for database connection +# Accepts: true, false, yes, no, 1, 0 +# Default: false +DATABASE_SSL=false + +# Enable database query logging +# Useful for debugging but can be verbose in production +# Accepts: true, false, yes, no, 1, 0 +# Default: false +DATABASE_LOGGING=false + +# ----------------------------------------------------------------------------- +# AUTHENTICATION CONFIGURATION +# From: apps/backend/src/config/auth.config.ts +# ----------------------------------------------------------------------------- + # Bcrypt salt rounds for password hashing -# For more information see https://www.npmjs.com/package/bcrypt +# Higher values are more secure but slower (10-12 recommended for production) +# Default: 10 AUTH_SALT_ROUNDS=10 -# JWT tokens are used to authenticate requests to the API. -# For more information see https://www.npmjs.com/package/jsonwebtoken. -AUTH_JWT_SECRET=example_secret123! -AUTH_JWT_ISSUER=app_starter -AUTH_JWT_COOKIE_NAME=access_token -AUTH_JWT_COOKIE_SECRET=example_cookie_sign_secret123! -# Enable rate limiting for authentication routes -# Make sure you configure REVERSE_PROXY_TRUST -ENABLE_RATE_LIMITING=false - -# ------------------------------------------------------------------- -# OIDC -# ------------------------------------------------------------------- -# Configuration can be skipped if OIDC is not used -# NUXT_PUBLIC_OIDC_ENABLED=0 -# OIDC_ALLOW_SIGNUP=0 -# NUXT_PUBLIC_OIDC_LOGIN_TEXT=Sign in with OIDC -# OIDC_DEFAULT_ROLE=ADMIN -# OIDC_CLIENT_ID= -# OIDC_CLIENT_SECRET= -# OIDC_ISSUER= -# OIDC_JWKS_URL= -# OIDC_AUTHORIZATION_ENDPOINT= -# OIDC_TOKEN_ENDPOINT= -# OIDC_USERINFO_ENDPOINT= -# NUXT_PUBLIC_OIDC_LOGOUT_ENABLED=1 -# OIDC_LOGOUT_ENDPOINT= -# Use OIDC_POST_LOGOUT_URI_KEY if OIDC provider uses post logout uri key not -# aligned with OIDC RP-Initiated Logout standard key (post_logout_redirect_uri) -# OIDC_POST_LOGOUT_URI_KEY= -# OIDC_SESSION_SECRET= - -# ------------------------------------------------------------------- -# Email configuration for sending notifications -# ------------------------------------------------------------------- -EMAIL_SENDER_NAME=App -EMAIL_SENDER_ADDRESS=app_starter@example.com -EMAIL_USER= -EMAIL_PASSWORD= -EMAIL_HOST=email-smtp.us-east-1.amazonaws.com -# Can be omitted if using SSL -EMAIL_PORT= -EMAIL_SSL=1 -EMAIL_TLS= - -# ------------------------------------------------------------------- -# File storage configuration -# ------------------------------------------------------------------- -# Supports two storage providers: Amazon S3 and filesystem. -# Can be set to amazon or filesystem -STORAGE_PROVIDER=filesystem -# If filesystem provider is used, this is the path where files will be stored -STORAGE_PATH=data -# If amazon provider is used, these are the credentials for the S3 bucket -# STORAGE_KEY= -# STORAGE_SECRET= -# STORAGE_REGION=us-east-1 -# STORAGE_BUCKET=my-bucket - -# ------------------------------------------------------------------- -# OAuth2 client credentials for authenticating with end-system -# ------------------------------------------------------------------- -# CONSUMER_CLIENT_ID=app_dev_id -# CONSUMER_CLIENT_SECRET=app_dev_secret -# CONSUMER_CLIENT_TOKEN_HOST=http://127.0.0.1:3000 -# CONSUMER_CLIENT_TOKEN_PATH=/api/oauth2/token - -# ------------------------------------------------------------------- -# Open AI configuration, optional -# ------------------------------------------------------------------- -# Backend -AI_MODEL_ID=gpt-4o -AI_SECRET_KEY= - -# ------------------------------------------------------------------- -# Test configuration -# ------------------------------------------------------------------- -# Warning: Enabling test routes will expose data manipulation endpoints -# including the ability to reset the database. -# ENABLE_TEST_API_ENDPOINTS= - -# Force color output (for logs) -# https://nodejs.org/api/tty.html#writestreamgetcolordepthenv -FORCE_COLOR=1 + +# JWT issuer - identifies who issued the token +# Default: App +AUTH_JWT_ISSUER=App + +# JWT secret key - MUST be changed in production +# Used to sign and verify JWT tokens +# Default: auth-jwt-secret +AUTH_JWT_SECRET=auth-jwt-secret + +# JWT token expiration time +# Uses ms library format: 7d, 24h, 2h, 1m, etc. +# Default: 7d +AUTH_JWT_EXPIRES_IN=7d + +# Name of the authentication cookie +# Default: access_token +AUTH_COOKIE_NAME=access_token + +# ----------------------------------------------------------------------------- +# MAIL CONFIGURATION +# From: apps/backend/src/config/mail.config.ts +# ----------------------------------------------------------------------------- + +# SMTP server host +# Default: email-smtp.us-east-1.amazonaws.com +MAIL_HOST=email-smtp.us-east-1.amazonaws.com + +# SMTP server port +# Common ports: 25 (unencrypted), 587 (TLS), 465 (SSL) +# Default: null (will use provider's default) +MAIL_PORT=587 + +# SMTP authentication username +# Leave empty if authentication is not required +# Default: empty +MAIL_USER= + +# SMTP authentication password +# Leave empty if authentication is not required +# Default: empty +MAIL_PASSWORD= + +# Use SSL/TLS for SMTP connection +# Accepts: true, false, yes, no, 1, 0 +# Default: false +MAIL_SECURE=false + +# Default sender name +# Default: App +MAIL_FROM_NAME=App + +# Default sender email address +# Default: noreply@example.com +MAIL_FROM_EMAIL=noreply@example.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index 687e8b8..54f37d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,102 @@ -# Node -node_modules - -# IDEs and editors -.classpath -.project -/.idea -*.launch -.c9/ -.settings/ -*.sublime-workspace - -# IDE - VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/extensions.json -!.vscode/launch.json - -# OS -.DS_Store +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs +dist/ +build/ +.output/ +.next/ +.nuxt/ +.cache/ +.parcel-cache/ -# Config +# Environment files .env +.env.* +!.env.example +**/.env +**/.env.* +!**/.env.example + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov +.nyc_output/ + +# Database files +*.db +*.sqlite +*.sqlite3 +database.sqlite +prisma/dev.db* + +# Uploads and temporary files +uploads/ +tmp/ +temp/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.fleet/ + +# Package manager files +.pnpm-debug.log* +.yarn/ +.pnp.* +yarn-error.log + +# Test results +test-results/ +playwright-report/ +playwright/.cache/ + +# TypeScript +*.tsbuildinfo + +# Optional caches +.eslintcache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Backup files +*.backup +*.bak +*.tmp + +# Local development +.local/ +local/ + +# Generated documentation +docs/build/ diff --git a/apps/backend/app.ts b/apps/backend/app.ts deleted file mode 100644 index ea9bdb5..0000000 --- a/apps/backend/app.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; -import bodyParser from 'body-parser'; -import cookieParser from 'cookie-parser'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; -import history from 'connect-history-api-fallback'; -import qs from 'qs'; - -import auth from './shared/auth/index.js'; -import origin from './shared/origin.js'; -import router from './router.js'; -import { createHttpLogger } from '#logger'; -import config from '#config'; - -const { STORAGE_PATH } = process.env; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const app = express(); - -const configCookie = JSON.stringify( - Object.fromEntries( - Object.entries(process.env).filter(([key]) => - key.startsWith('NUXT_PUBLIC_'), - ), - ), -); - -if (config.general.reverseProxyPolicy) - app.set('trust proxy', config.general.reverseProxyPolicy); - -// eslint-disable-next-line @typescript-eslint/no-unused-expressions -config.auth.oidc.enabled - && (await (async () => { - const { default: consolidate } = await import('consolidate'); - const { default: session } = await import('express-session'); - app.engine('mustache', consolidate.mustache); - app.set('view engine', 'mustache'); - app.use(session(config.auth.session)); - })()); - -app.use( - helmet({ - // TODO: Reevaluate and enable, for now, disabled as it breaks a lot of things - contentSecurityPolicy: false, - }), -); - -// Patch Express 5 query parser (not parsing arrays properly). -app.use((req, _res, next) => { - const { query } = req; - if (query) Object.defineProperty(req, 'query', { value: qs.parse(query) }); - next(); -}); - -app.use(cors({ origin: config.auth.corsAllowedOrigins, credentials: true })); -app.use(cookieParser(config.auth.jwt.cookie.secret)); -app.use(bodyParser.json({ limit: '50mb' })); -app.use(bodyParser.urlencoded({ extended: true })); -app.use(auth.initialize()); -app.use(origin()); -app.use( - history({ - rewrites: [ - { - from: /^\/api\/.*$/, - to: (context) => context.parsedUrl.path, - }, - ], - }), -); -app.use( - express.static(path.join(__dirname, '../frontend/.output/public'), { - setHeaders: (res, path) => { - if (!path.endsWith('/public/index.html')) return; - return res.cookie('config', configCookie); - }, - }), -); - -if (STORAGE_PATH) app.use(express.static(STORAGE_PATH)); - -// Mount main router. -app.use('/api', createHttpLogger(), router); - -// Global error handler. -app.use(errorHandler); - -// Handle non-existing routes. -app.use((_req, res) => res.status(404).end()); - -export default app; - -function errorHandler(err, req, res, _next) { - if (!err.status || err.status === 500) { - req.log.error({ err }); - res.status(500).end(); - return; - } - const { status, message } = err; - res.status(err.status).json({ error: { status, message } }); -} diff --git a/apps/backend/config/ai.js b/apps/backend/config/ai.js deleted file mode 100644 index 1523829..0000000 --- a/apps/backend/config/ai.js +++ /dev/null @@ -1,3 +0,0 @@ -export const secretKey = process.env.AI_SECRET_KEY; -export const modelId = process.env.AI_MODEL_ID; -export const isEnabled = secretKey && modelId; diff --git a/apps/backend/config/auth.js b/apps/backend/config/auth.js deleted file mode 100644 index 1ac8402..0000000 --- a/apps/backend/config/auth.js +++ /dev/null @@ -1,48 +0,0 @@ -import { role as roleConfig } from '@app/config'; -import yn from 'yn'; - -const { env } = process; -const { user: role } = roleConfig; - -export const corsAllowedOrigins = (env.CORS_ALLOWED_ORIGINS || '') - .split(',') - .filter((s) => s) - .map((s) => s.trim()); - -export const saltRounds = parseInt(env.AUTH_SALT_ROUNDS, 10) || 10; - -export const jwt = { - cookie: { - name: env.AUTH_JWT_COOKIE_NAME || 'access_token', - secret: env.AUTH_JWT_COOKIE_SECRET, - signed: !!env.AUTH_JWT_COOKIE_SECRET, - secure: env.PROTOCOL === 'https' && env.HOSTNAME !== 'localhost', - httpOnly: true, - }, - secret: env.AUTH_JWT_SECRET, - issuer: env.AUTH_JWT_ISSUER, -}; - -export const oidc = { - enabled: yn(env.NUXT_PUBLIC_OIDC_ENABLED), - clientID: env.OIDC_CLIENT_ID, - clientSecret: env.OIDC_CLIENT_SECRET, - issuer: env.OIDC_ISSUER, - jwksURL: env.OIDC_JWKS_URL, - authorizationEndpoint: env.OIDC_AUTHORIZATION_ENDPOINT, - tokenEndpoint: env.OIDC_TOKEN_ENDPOINT, - userInfoEndpoint: env.OIDC_USERINFO_ENDPOINT, - logoutEndpoint: env.OIDC_LOGOUT_ENDPOINT, - postLogoutUriKey: env.OIDC_POST_LOGOUT_URI_KEY, - enableSignup: yn(env.OIDC_ALLOW_SIGNUP), - defaultRole: - Object.values(role).find((it) => it === env.OIDC_DEFAULT_ROLE) || role.USER, -}; - -export const session = { - resave: false, - saveUninitialized: false, - secret: env.OIDC_SESSION_SECRET, - proxy: true, - cookie: { secure: false }, -}; diff --git a/apps/backend/config/consumer.js b/apps/backend/config/consumer.js deleted file mode 100644 index 2d67d83..0000000 --- a/apps/backend/config/consumer.js +++ /dev/null @@ -1,20 +0,0 @@ -const { env } = process; - -export const clientId = env.CONSUMER_CLIENT_ID; - -export const clientSecret = env.CONSUMER_CLIENT_SECRET; - -export const tokenHost = env.CONSUMER_CLIENT_TOKEN_HOST; - -export const tokenPath = env.CONSUMER_CLIENT_TOKEN_PATH; - -export const isAuthConfigured = - clientId && clientSecret && tokenHost && tokenPath; - -export default { - isAuthConfigured, - clientId, - clientSecret, - tokenHost, - tokenPath, -}; diff --git a/apps/backend/config/database.js b/apps/backend/config/database.js deleted file mode 100644 index ceb3e61..0000000 --- a/apps/backend/config/database.js +++ /dev/null @@ -1,12 +0,0 @@ -import 'dotenv/config'; - -const config = { - url: process.env.POSTGRES_URI, - dialect: 'postgres', -}; - -export const development = config; - -export const test = config; - -export const production = config; diff --git a/apps/backend/config/general.js b/apps/backend/config/general.js deleted file mode 100644 index a3e4321..0000000 --- a/apps/backend/config/general.js +++ /dev/null @@ -1,19 +0,0 @@ -import yn from 'yn'; - -function isNumeric(input) { - if (typeof input !== 'string') return false; - return !isNaN(input) && !isNaN(parseFloat(input)); -} - -function parseProxyPolicy(policy) { - if (isNumeric(policy)) return parseInt(policy, 10); - const parsedBoolean = yn(policy); - if (parsedBoolean !== undefined) return parsedBoolean; - // If the policy is not a boolean or a number, return the original value - return policy; -} - -const env = process.env; - -export const enableRateLimiting = yn(env.ENABLE_RATE_LIMITING); -export const reverseProxyPolicy = parseProxyPolicy(env.REVERSE_PROXY_TRUST); diff --git a/apps/backend/config/index.js b/apps/backend/config/index.js deleted file mode 100644 index 1784e74..0000000 --- a/apps/backend/config/index.js +++ /dev/null @@ -1,53 +0,0 @@ -import resolveUrl from '@app/config/src/url.js'; - -import * as ai from './ai.js'; -import * as auth from './auth.js'; -import * as consumer from './consumer.js'; -import * as general from './general.js'; -import * as mail from './mail.js'; -import * as storage from './storage.js'; -import * as kvStore from './kvStore.js'; -import * as test from './test.js'; - -const env = process.env; -const isProduction = env.NODE_ENV === 'production'; -const packageName = env.npm_package_name; -const packageVersion = env.npm_package_version; - -const { hostname, protocol, port, origin } = resolveUrl(env); - -export { - packageName, - packageVersion, - isProduction, - ai, - auth, - consumer, - general, - hostname, - kvStore, - mail, - origin, - port, - protocol, - storage, - test, -}; - -export default { - packageName, - packageVersion, - isProduction, - ai, - auth, - consumer, - general, - hostname, - kvStore, - mail, - origin, - port, - protocol, - storage, - test, -}; diff --git a/apps/backend/config/kvStore.js b/apps/backend/config/kvStore.js deleted file mode 100644 index 016b654..0000000 --- a/apps/backend/config/kvStore.js +++ /dev/null @@ -1,11 +0,0 @@ -import KeyvRedis from '@keyv/redis'; - -export const providerUrl = process.env.KV_STORE_URL || undefined; -export const ttl = parseInt(process.env.KV_STORE_DEFAULT_TTL, 10) || 0; - -export const store = - providerUrl && providerUrl.startsWith('redis://') - ? new KeyvRedis(providerUrl) - : undefined; - -export const keyvDefaultConfig = { ttl, ...(store && { store }) }; diff --git a/apps/backend/config/mail.js b/apps/backend/config/mail.js deleted file mode 100644 index 60bdcfd..0000000 --- a/apps/backend/config/mail.js +++ /dev/null @@ -1,18 +0,0 @@ -import yn from 'yn'; - -export const sender = { - name: process.env.EMAIL_SENDER_NAME, - address: process.env.EMAIL_SENDER_ADDRESS, -}; - -export const user = process.env.EMAIL_USER; - -export const password = process.env.EMAIL_PASSWORD; - -export const host = process.env.EMAIL_HOST; - -export const port = process.env.EMAIL_PORT || null; - -export const ssl = yn(process.env.EMAIL_SSL); - -export const tls = yn(process.env.EMAIL_TLS); diff --git a/apps/backend/config/storage.js b/apps/backend/config/storage.js deleted file mode 100644 index 42e0508..0000000 --- a/apps/backend/config/storage.js +++ /dev/null @@ -1,14 +0,0 @@ -export const protocol = 'storage://'; -export const provider = process.env.STORAGE_PROVIDER; - -export const amazon = { - endpoint: process.env.STORAGE_ENDPOINT, - region: process.env.STORAGE_REGION, - bucket: process.env.STORAGE_BUCKET, - key: process.env.STORAGE_KEY, - secret: process.env.STORAGE_SECRET, -}; - -export const filesystem = { - path: process.env.STORAGE_PATH, -}; diff --git a/apps/backend/config/test.js b/apps/backend/config/test.js deleted file mode 100644 index 02d3056..0000000 --- a/apps/backend/config/test.js +++ /dev/null @@ -1,3 +0,0 @@ -import yn from 'yn'; - -export const isSeedApiEnabled = yn(process.env.ENABLE_TEST_API_ENDPOINTS); diff --git a/apps/backend/index.ts b/apps/backend/index.ts deleted file mode 100644 index 3b175ea..0000000 --- a/apps/backend/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable */ -import boxen, { type Options as BoxenOptions } from 'boxen'; -import Promise from 'bluebird'; - -import app from './app.ts'; -import config from '#config'; -import { createLogger } from '#logger'; - - -// This needs to be done before db models get loaded! -Promise.config({ longStackTraces: !config.isProduction }); -import database from './shared/database/index.js'; -/* eslint-enable */ - -const logger = createLogger(); -database - .initialize() - .then(() => logger.info('Database initialized')) - .then(() => app.listen(config.port)) - .then(() => { - logger.info(`Server listening on port ${config.port}`); - welcome(config.packageName, config.packageVersion); - }) - .catch((err) => logger.error({ err })); - -const message = (name, version) => `${name} v${version} 🚀`.trim(); - -function welcome(name, version) { - const options = { - padding: 2, - margin: 1, - borderStyle: 'double', - borderColor: 'blue', - align: 'left', - } as BoxenOptions; - console.log(boxen(message(name, version), options)); -} diff --git a/apps/backend/mikro-orm.config.ts b/apps/backend/mikro-orm.config.ts new file mode 100644 index 0000000..eedb7a4 --- /dev/null +++ b/apps/backend/mikro-orm.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, PostgreSqlDriver } from '@mikro-orm/postgresql'; +import mikroOrmConfig from './src/config/mikro-orm.config'; +import dbConfig from './src/config/db.config'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// Load environment variables +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +// Get the config functions +const mikroConfig = mikroOrmConfig(); +const databaseConfig = dbConfig(); + +// Export using defineConfig for proper typing and validation +export default defineConfig({ + driver: PostgreSqlDriver, + ...mikroConfig, + ...databaseConfig, +} as Parameters[0]); diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json new file mode 100644 index 0000000..fb1dbfc --- /dev/null +++ b/apps/backend/nest-cli.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "webpack": false, + "tsConfigPath": "tsconfig.json", + "assets": [ + { + "include": "modules/mail/templates/**/*", + "outDir": "dist" + } + ] + } +} \ No newline at end of file diff --git a/apps/backend/nodemon.json b/apps/backend/nodemon.json deleted file mode 100644 index 56f2e1f..0000000 --- a/apps/backend/nodemon.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "watch": ["*.*"], - "ignore": ["build", "data", "dist", "*.config.js"], - "env": { - "NODE_ENV": "development" - } -} diff --git a/apps/backend/oidc/index.js b/apps/backend/oidc/index.js deleted file mode 100644 index 53707b9..0000000 --- a/apps/backend/oidc/index.js +++ /dev/null @@ -1,61 +0,0 @@ -import express from 'express'; -import { errors as OIDCError } from 'openid-client'; -import auth from '../shared/auth/index.js'; - -import { requestLimiter } from '../shared/request/mw.js'; -import { origin } from '#config'; - -const router = express.Router(); -const { authenticate, logout } = auth; - -const ACCESS_DENIED_ROUTE = `${origin}/auth?accessDenied=`; - -const OIDCErrors = [OIDCError.OPError, OIDCError.RPError]; -const scope = ['openid', 'profile', 'email'].join(' '); - -const isResign = ({ query }) => query.resign === 'true'; -const isLogoutRequest = ({ query }) => query.action === 'logout'; -const getPromptParams = (req) => (isResign(req) ? { prompt: 'login' } : {}); - -const isOIDCError = (err) => OIDCErrors.some((Ctor) => err instanceof Ctor); - -router - .use(requestLimiter()) - .get('/', authRequestHandler) - .get('/callback', idpCallbackHandler, (_, res) => res.redirect(origin)) - .use(accessDeniedHandler); - -export default { - path: '/oidc', - router, -}; - -// Initiate login and logout actions -function authRequestHandler(req, res, next) { - const strategy = req.passport.strategy('oidc'); - if (isLogoutRequest(req)) return strategy.logout()(req, res, next); - const params = { - session: true, - scope, - ...getPromptParams(req), - }; - return authenticate('oidc', params)(req, res, next); -} - -// Triggered upon OIDC provider response -function idpCallbackHandler(req, res, next) { - if (!isLogoutRequest(req)) return login(req, res, next); - return logout({ middleware: true })(req, res, next); -} - -function accessDeniedHandler(err, _req, res, next) { - if (!isOIDCError(err)) return res.redirect(ACCESS_DENIED_ROUTE + err.email); - return next(err); -} - -function login(req, res, next) { - const params = { session: true, setCookie: true }; - authenticate('oidc', params)(req, res, (err) => - err ? next(err) : res.redirect(origin), - ); -} diff --git a/apps/backend/package.json b/apps/backend/package.json index 9b3b2c8..2e0dbbd 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,117 +1,125 @@ { - "name": "app-backend", - "version": "0.0.1", - "codename": "X", - "type": "module", - "imports": { - "#config": "./config/index.js", - "#config/*": "./config/*", - "#logger": "./shared/logger.js" - }, + "name": "app-starter/backend", + "version": "1.0.0", + "description": "NestJS backend with MikroORM", + "author": "", + "private": true, + "license": "MIT", "scripts": { - "dev": "nodemon --import ./script/preflight.js --experimental-strip-types ./index.ts || exit 0", - "start": "node --import ./script/preflight.js --experimental-strip-types ./index.ts", - "debug:server": "nodemon --inspect --import ./script/preflight.js --experimental-strip-types ./index.ts ./index.js", - "db": "node --import ./script/preflight.js ./script/sequelize.js", - "db:reset": "pnpm db drop && pnpm db create && pnpm db migrate", - "db:seed": "pnpm db seed:all", - "add:admin": "node --import ./script/preflight.js ./script/addAdmin.js", - "invite:admin": "node --import ./script/preflight.js ./script/inviteAdmin.js", - "integration:add": "node --import ./script/preflight.js ./script/addIntegration.js", - "integration:token": "node --import ./script/preflight.js ./script/generateIntegrationToken.js" + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "migration:create": "mikro-orm migration:create", + "migration:up": "mikro-orm migration:up", + "migration:down": "mikro-orm migration:down", + "migration:list": "mikro-orm migration:list", + "migration:check": "mikro-orm migration:check", + "schema:create": "mikro-orm schema:create --run", + "schema:drop": "mikro-orm schema:drop --run", + "schema:update": "mikro-orm schema:update --run", + "schema:fresh": "mikro-orm schema:fresh --run", + "seeder:run": "mikro-orm seeder:run", + "cache:clear": "mikro-orm cache:clear" }, "dependencies": { - "@app/config": "workspace:*", - "@app/seed": "workspace:*", - "@aws-sdk/client-s3": "^3.687.0", - "@aws-sdk/lib-storage": "^3.687.0", - "@aws-sdk/s3-request-presigner": "^3.687.0", - "@aws-sdk/util-format-url": "^3.686.0", - "@faker-js/faker": "^8.4.1", - "@keyv/redis": "^3.0.1", - "@paralleldrive/cuid2": "^2.2.2", - "JSONStream": "^1.3.5", - "auto-bind": "^5.0.1", - "axios": "^1.7.7", + "@mikro-orm/cli": "^6.4.1", + "@mikro-orm/core": "^6.4.1", + "@mikro-orm/migrations": "^6.4.1", + "@mikro-orm/nestjs": "^6.0.2", + "@mikro-orm/postgresql": "^6.4.1", + "@mikro-orm/reflection": "^6.4.1", + "@mikro-orm/seeder": "^6.4.1", + "@mikro-orm/sql-highlighter": "^1.0.1", + "@nestjs/common": "^10.4.8", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.8", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.4.8", + "@nestjs/swagger": "^8.0.7", + "@nestjs/throttler": "^6.2.1", + "@types/handlebars": "^4.1.0", "bcrypt": "^5.1.1", - "bluebird": "^3.7.2", - "body-parser": "^1.20.3", - "boxen": "^7.1.1", - "change-case": "^5.4.4", - "cheerio": "1.0.0-rc.12", - "connect-history-api-fallback": "^2.0.0", - "consolidate": "^1.0.4", - "cookie-parser": "^1.4.7", - "cors": "^2.8.5", - "date-fns": "^2.30.0", - "dotenv": "^16.4.5", - "emailjs": "^4.0.3", - "express": "5.0.0", - "express-rate-limit": "^7.4.1", - "express-session": "^1.18.1", - "fecha": "^4.2.3", - "fs-blob-store": "^6.0.0", - "gravatar": "^1.8.2", - "hash-obj": "^4.0.0", - "hashids": "^2.3.0", - "helmet": "^7.1.0", - "html-to-text": "^9.0.5", - "http-errors": "^2.0.0", - "http-status-codes": "^2.3.0", - "jsonwebtoken": "^9.0.2", - "jszip": "^3.10.1", - "keyv": "^5.1.3", - "lodash": "^4.17.21", - "luxon": "^3.5.0", - "mime-types": "^2.1.35", - "minimist": "^1.2.8", - "mjml": "^4.15.3", - "mkdirp": "^3.0.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "common-tags": "^1.8.2", + "cookie-parser": "^1.4.6", + "handlebars": "^4.7.8", + "helmet": "^8.0.0", + "joi": "^17.13.3", + "ms": "^2.1.3", "multer": "^1.4.5-lts.1", - "mustache": "^4.2.0", - "openai": "^4.71.1", - "openid-client": "^5.7.0", - "passport": "^0.6.0", + "nodemailer": "^6.9.15", + "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", - "path-exists": "^5.0.0", - "pg": "^8.13.1", - "pg-query-stream": "^4.7.1", - "pino": "^9.5.0", - "pino-http": "^10.3.0", - "pino-pretty": "^12.0.0", - "pkg-dir": "^8.0.0", - "promise-queue": "^2.2.5", - "qs": "^6.13.0", - "randomstring": "^1.3.0", - "read-pkg-up": "^10.1.0", - "safe-require": "^1.0.4", - "semver": "^7.6.3", - "sequelize": "^6.37.5", - "sequelize-replace-enum-postgres": "^1.6.0", - "simple-oauth2": "^5.1.0", - "to-case": "^2.0.0", - "untildify": "^5.0.0", - "url-join": "^5.0.0", - "url-parse": "^1.5.10", - "uuid": "^10.0.0", - "yn": "^5.0.0", - "yup": "^1.4.0" + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1", + "uuid": "^11.0.3", + "yn": "^5.1.0" }, "devDependencies": { - "@types/bluebird": "^3.5.42", + "@nestjs/cli": "^10.4.8", + "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.8", + "@types/bcrypt": "^5.0.2", + "@types/cookie-parser": "^1.4.7", "@types/express": "^5.0.0", - "dargs": "^8.1.0", - "del-cli": "^5.1.0", - "dotenv-cli": "^7.4.2", - "nodemon": "^3.1.7", - "sequelize-cli": "^6.6.2", - "terser": "^5.36.0", - "umzug": "^3.8.2" + "@types/jest": "^29.5.14", + "@types/multer": "^1.4.12", + "@types/node": "^22.10.1", + "@types/nodemailer": "^6.4.16", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.17.0", + "@typescript-eslint/parser": "^8.17.0", + "eslint": "^9.16.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "jest": "^29.7.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.2" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" }, - "engines": { - "node": ">= 22.6.0", - "npm": ">= 9.6.0", - "postgres": ">= 9.4" + "mikro-orm": { + "useTsNode": true, + "configPaths": [ + "./src/mikro-orm.config.ts", + "./dist/mikro-orm.config.js" + ] } } diff --git a/apps/backend/router.js b/apps/backend/router.js deleted file mode 100644 index 8237f34..0000000 --- a/apps/backend/router.js +++ /dev/null @@ -1,44 +0,0 @@ -import express from 'express'; -import ai from './shared/ai/index.js'; -import authenticator from './shared/auth/index.js'; -import { extractAuthData } from './shared/auth/mw.js'; -import seedRouter from './tests/api/index.js'; -import user from './user/index.js'; -import { - ai as aiConfig, - auth as authConfig, - test as testConfig, -} from '#config'; - -const { authenticate } = authenticator; -const router = express.Router(); -router.use(processBody); -router.use(extractAuthData); - -// Public routes: -router.get('/healthcheck', (_req, res) => { - res.status(200).json({ status: 'ok' }); -}); - -router.use(user.path, user.router); - -// SSO routes: -authConfig.oidc.enabled && - (await (async () => { - const { default: oidc } = await import('./oidc/index.js'); - router.use(oidc.path, oidc.router); - })()); - -// Protected routes: -router.use(authenticate('jwt')); - -if (aiConfig.isEnabled) router.use(ai.path, ai.router); -if (testConfig.isSeedApiEnabled) router.use(seedRouter.path, seedRouter.router); - -export default router; - -function processBody(req, _res, next) { - const { body } = req; - if (body && body.email) body.email = body.email.toLowerCase(); - next(); -} diff --git a/apps/backend/script/addAdmin.js b/apps/backend/script/addAdmin.js deleted file mode 100644 index 97442c9..0000000 --- a/apps/backend/script/addAdmin.js +++ /dev/null @@ -1,28 +0,0 @@ -import 'dotenv/config'; - -import roleConfig from '@app/config/src/role.js'; - -// Dynamic import is needed in order for the `enabled` flag to be respected -const { default: db } = await import('../shared/database/index.js'); - -const { User } = db; -const { user: role } = roleConfig; - -const args = process.argv.slice(2); -if (args.length !== 2) { - console.error('You must supply two arguments - email and password'); - process.exit(1); -} - -const email = args[0]; -const password = args[1]; - -User.create({ email, password, role: role.ADMIN }) - .then((user) => { - console.log(`Administrator created: ${user.email}`); - process.exit(0); - }) - .catch((err) => { - console.error(err.message); - process.exit(1); - }); diff --git a/apps/backend/script/addIntegration.js b/apps/backend/script/addIntegration.js deleted file mode 100644 index f776c22..0000000 --- a/apps/backend/script/addIntegration.js +++ /dev/null @@ -1,25 +0,0 @@ -import 'dotenv/config'; - -import roleConfig from '@app/config/src/role.js'; - -// Dynamic import is needed in order for the `enabled` flag to be respected -const { default: db } = await import('../shared/database/index.js'); - -const { User } = db; -const { user: role } = roleConfig; - -User.findOne({ where: { role: role.INTEGRATION } }) - .then((user) => { - if (!user) return true; - console.log('Integration already exists'); - process.exit(0); - }) - .then(() => User.create({ role: role.INTEGRATION })) - .then((user) => { - console.log(`Integration user created: ${user.id}`); - process.exit(0); - }) - .catch((err) => { - console.error(err.message); - process.exit(1); - }); diff --git a/apps/backend/script/generateIntegrationToken.js b/apps/backend/script/generateIntegrationToken.js deleted file mode 100644 index 30c9a17..0000000 --- a/apps/backend/script/generateIntegrationToken.js +++ /dev/null @@ -1,17 +0,0 @@ -import 'dotenv/config'; - -// Dynamic import is needed in order for the `enabled` flag to be respected -const { default: db } = await import('../shared/database/index.js'); - -const { User } = db; - -User.findOne({ where: { role: 'INTEGRATION' } }) - .then((user) => user.createToken({})) - .then((token) => { - console.log(`Integration token generated: ${token}`); - process.exit(0); - }) - .catch((err) => { - console.error(err.message || err); - process.exit(1); - }); diff --git a/apps/backend/script/inviteAdmin.js b/apps/backend/script/inviteAdmin.js deleted file mode 100644 index 36a9542..0000000 --- a/apps/backend/script/inviteAdmin.js +++ /dev/null @@ -1,30 +0,0 @@ -import 'dotenv/config'; - -import roleConfig from '@app/config/src/role.js'; -import Deferred from '../shared/util/Deferred.js'; - -// Dynamic import is needed in order for the `enabled` flag to be respected -const { default: db } = await import('../shared/database/index.js'); - -const { User } = db; -const { user: role } = roleConfig; - -const args = process.argv.slice(2); -if (args.length !== 1) { - console.error('You must supply email'); - process.exit(1); -} - -const email = args[0]; -const mailing = new Deferred(); - -User.invite({ email, role: role.ADMIN }, mailing.callback) - .then((user) => Promise.all([user, mailing.promise])) - .then(([user]) => { - console.log(`Invitation sent to ${user.email} for Admin role.`); - process.exit(0); - }) - .catch((err) => { - console.error(err.message); - process.exit(1); - }); diff --git a/apps/backend/script/preflight.js b/apps/backend/script/preflight.js deleted file mode 100644 index a33e280..0000000 --- a/apps/backend/script/preflight.js +++ /dev/null @@ -1,41 +0,0 @@ -import path from 'node:path'; -import boxen from 'boxen'; -import dotenv from 'dotenv'; -import { packageDirectory } from 'pkg-dir'; -import { readPackageUpSync } from 'read-pkg-up'; -import semver from 'semver'; - -const { packageJson: pkg } = readPackageUpSync(); - -// App root -const appDirectory = await packageDirectory(); -// Monorepo root -const projectDirectory = await packageDirectory({ - cwd: path.join(appDirectory, '..'), -}); - -const dotenvLocation = path.join(projectDirectory, '.env'); -dotenv.config({ path: dotenvLocation }); - -(function preflight() { - const engines = pkg.engines || {}; - if (!engines.node) return; - const checkPassed = semver.satisfies(process.versions.node, engines.node); - if (checkPassed) return; - warn(engines.node); - console.error(' ✋ Exiting due to engine requirement check failure...\n'); - process.exit(1); -})(); - -function warn(range, current = process.version, name = pkg.name) { - const options = { - borderColor: 'red', - borderStyle: 'single', - padding: 1, - margin: 1, - float: 'left', - align: 'center', - }; - const message = `🚨 ${name} requires node ${range}\n current version is ${current}`; - console.error(boxen(message, options)); -} diff --git a/apps/backend/script/sequelize.js b/apps/backend/script/sequelize.js deleted file mode 100644 index f7ce6d3..0000000 --- a/apps/backend/script/sequelize.js +++ /dev/null @@ -1,67 +0,0 @@ -import { createRequire } from 'node:module'; -import path from 'node:path'; -import dargs from 'dargs'; -import dotenv from 'dotenv'; -import minimist from 'minimist'; -import { packageDirectory } from 'pkg-dir'; -import safeRequire from 'safe-require'; - -// App root -const appDirectory = await packageDirectory(); -// Monorepo root -const projectDirectory = await packageDirectory({ - cwd: path.join(appDirectory, '..'), -}); -const dotenvLocation = path.join(projectDirectory, '.env'); -dotenv.config({ path: dotenvLocation }); - -const require = createRequire(import.meta.url); - -const actions = ['migrate', 'seed', 'create', 'drop']; -const isAction = (cmd) => actions.some((it) => cmd.startsWith(it)); - -// Load config. -const config = safeRequire(path.join(process.cwd(), 'sequelize.config.cjs')); -if (!config) { - console.error('Error: `sequelize.config.cjs` not found'); - process.exit(1); -} - -const argv = minimist(process.argv.slice(2)); -process.argv.length = 2; - -// Resolve command with arguments. -const args = getArgs(argv); -process.argv.push(...args); - -// Resolve options. -const options = Object.assign({}, config, getOptions(argv)); -process.argv.push(...dargs(options)); - -// Make it rain! - -require('sequelize-cli/lib/sequelize'); - -function getArgs(argv) { - let [cmd, ...args] = argv._; - if (!cmd) return args; - if (isAction(cmd)) cmd = `db:${cmd}`; - return [cmd, ...args]; -} - -function getOptions(argv) { - return reduce( - argv, - (acc, val, key) => { - if (['_', '--'].includes(key)) return acc; - return Object.assign(acc, { [key]: val }); - }, - {}, - ); -} - -function reduce(obj, callback, initialValue) { - return Object.keys(obj).reduce((acc, key) => { - return callback(acc, obj[key], key); - }, initialValue); -} diff --git a/apps/backend/sequelize.config.cjs b/apps/backend/sequelize.config.cjs deleted file mode 100644 index ad18ecc..0000000 --- a/apps/backend/sequelize.config.cjs +++ /dev/null @@ -1,10 +0,0 @@ -'use strict'; - -require('dotenv').config({ path: process.env.DOTENV_CONFIG_PATH }); -const path = require('path'); - -module.exports = { - config: path.join(__dirname, './shared/database/config.js'), - seedersPath: path.join(__dirname, './shared/database/seeds'), - migrationsPath: path.join(__dirname, './shared/database/migrations'), -}; diff --git a/apps/backend/shared/ai/ai.controller.js b/apps/backend/shared/ai/ai.controller.js deleted file mode 100644 index cd71e3a..0000000 --- a/apps/backend/shared/ai/ai.controller.js +++ /dev/null @@ -1,10 +0,0 @@ -import AIService from './ai.service.js'; - -async function prompt(req, res) { - const data = await AIService.requestCompletion(req.body?.input); - res.json({ data }); -} - -export default { - prompt, -}; diff --git a/apps/backend/shared/ai/ai.service.js b/apps/backend/shared/ai/ai.service.js deleted file mode 100644 index 4ce103e..0000000 --- a/apps/backend/shared/ai/ai.service.js +++ /dev/null @@ -1,67 +0,0 @@ -import isString from 'lodash/isString.js'; -import OpenAI from 'openai'; - -import { createLogger } from '#logger'; -import { ai as aiConfig } from '#config'; - -const logger = createLogger('ai'); - -const systemPrompt = ` - The following is a conversation with an AI assistant. - The assistant is helpful, creative, clever, and very friendly. - - Rules: - - Use the User rules to generate the content - - Generated content should have a friendly tone and be easy to understand - - Generated content should not include any offensive language or content - - Only return JSON objects`; - -const parseResponse = (val) => { - const content = val?.choices?.[0]?.message?.content; - logger.info('Response content', content); - try { - if (!isString(content)) return content; - return JSON.parse(content); - } catch { - logger.info('Unable to parse response', content); - throw new Error('Invalid AI response', content); - } -}; - -class AIService { - #openai; - - constructor() { - this.#openai = new OpenAI({ apiKey: aiConfig.secretKey }); - } - - async requestCompletion(prompt) { - logger.info('Completion request', prompt); - const completion = await this.#openai.chat.completions.create({ - model: aiConfig.modelId, - temperature: 0.5, - response_format: { type: 'json_object' }, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: prompt }, - ], - }); - logger.info('Completion response', completion); - return parseResponse(completion); - } - - async generateImage(prompt) { - const { data } = await this.#openai.images.generate({ - prompt, - model: 'dall-e-3', - n: 1, // amount of images, max 1 for dall-e-3 - quality: 'hd', // 'standard' | 'hd', - size: '1024x1024', - style: 'natural', - }); - const url = new URL(data[0].url); - return url; - } -} - -export default aiConfig.secretKey ? new AIService() : {}; diff --git a/apps/backend/shared/ai/index.js b/apps/backend/shared/ai/index.js deleted file mode 100644 index 3ba6085..0000000 --- a/apps/backend/shared/ai/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express'; -import ctrl from './ai.controller.js'; - -const router = express.Router(); - -router.post('/prompt', ctrl.prompt); - -export default { - path: '/ai', - router, -}; diff --git a/apps/backend/shared/auth/audience.js b/apps/backend/shared/auth/audience.js deleted file mode 100644 index 4160240..0000000 --- a/apps/backend/shared/auth/audience.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - Scope: { - Access: 'scope:access', - Setup: 'scope:setup', - }, -}; diff --git a/apps/backend/shared/auth/authenticator.js b/apps/backend/shared/auth/authenticator.js deleted file mode 100644 index 2e74862..0000000 --- a/apps/backend/shared/auth/authenticator.js +++ /dev/null @@ -1,83 +0,0 @@ -import { IncomingMessage } from 'http'; -import addDays from 'date-fns/addDays/index.js'; -import { Authenticator } from 'passport'; -import autobind from 'auto-bind'; -import Audience from './audience.js'; -import { auth as config } from '#config'; - -const isFunction = (arg) => typeof arg === 'function'; - -class Auth extends Authenticator { - constructor() { - super(); - autobind(this); - } - - initialize(options = {}) { - Object.defineProperty(IncomingMessage.prototype, 'passport', { - get: () => this, - }); - return super.initialize(options); - } - - authenticate(strategy, ...args) { - const [options, callback] = parseAuthenticateOptions(args); - // The passport started to be explicit about the session from v0.6.0, and since most of our - // authentication strategies are stateless, we are opting to avoid session by default - if (options.session === undefined) options.session = false; - // NOTE: Setup passport to forward errors down the middleware chain - // https://github.com/jaredhanson/passport/blob/ad5fe1df/lib/middleware/authenticate.js#L171 - if (options.failWithError !== false) options.failWithError = true; - const authenticateUser = super.authenticate(strategy, options, callback); - return (req, res, next) => { - const authenticateCallback = options.setCookie - ? this._wrapAuthenticateCallback(req, res, next, strategy) - : next; - return authenticateUser(req, res, authenticateCallback); - }; - } - - _wrapAuthenticateCallback(req, res, next, strategy) { - return (...args) => { - if (args.length > 0) return next(args[0]); - const { user } = req; - const token = user.createToken({ - audience: Audience.Scope.Access, - expiresIn: '5 days', - }); - const { name, signed, secure, httpOnly } = config.jwt.cookie; - const expires = addDays(new Date(), 5); - const options = { signed, secure, expires, httpOnly }; - res.cookie(name, token, options); - const authData = { strategy, [strategy]: user.authData }; - res.cookie('auth', authData, options); - req.authData = authData; - return next(); - }; - } - - logout({ middleware = false } = {}) { - return (_, res, next) => { - res.clearCookie(config.jwt.cookie.name); - res.clearCookie('auth'); - return middleware ? next() : res.end(); - }; - } - - strategy(strategyName) { - const strategy = this._strategy(strategyName); - if (!strategy) { - throw new Error( - `Error: Unknown authentication strategy "${strategyName}"`, - ); - } - return strategy; - } -} - -export default new Auth(); - -function parseAuthenticateOptions(args) { - if (isFunction(args[0])) return [{}, args[0]]; - return [args[0] || {}, args[1]]; -} diff --git a/apps/backend/shared/auth/index.js b/apps/backend/shared/auth/index.js deleted file mode 100644 index cfdb82f..0000000 --- a/apps/backend/shared/auth/index.js +++ /dev/null @@ -1,115 +0,0 @@ -import path from 'node:path'; -import { ExtractJwt, Strategy as JwtStrategy } from 'passport-jwt'; -import get from 'lodash/get.js'; -import jwt from 'jsonwebtoken'; -import LocalStrategy from 'passport-local'; -import db from '../database/index.js'; -import Audience from './audience.js'; -import auth from './authenticator.js'; -import OIDCStrategy from './oidc.js'; -import { auth as config, origin } from '#config'; - -const { User } = db; -const options = { - usernameField: 'email', - session: false, -}; - -auth.use( - new LocalStrategy(options, (email, password, done) => { - return User.unscoped() - .findOne({ where: { email } }) - .then((user) => user && user.authenticate(password)) - .then((user) => done(null, user || false)) - .error((err) => done(err, false)); - }), -); - -auth.use( - new JwtStrategy( - { - ...config.jwt, - audience: Audience.Scope.Access, - jwtFromRequest: ExtractJwt.fromExtractors([ - extractJwtFromCookie, - ExtractJwt.fromBodyField('token'), - ]), - secretOrKey: config.jwt.secret, - }, - verifyJWT, - ), -); - -auth.use( - 'token', - new JwtStrategy( - { - ...config.jwt, - audience: Audience.Scope.Setup, - jwtFromRequest: ExtractJwt.fromBodyField('token'), - secretOrKeyProvider, - }, - verifyJWT, - ), -); - -config.oidc.enabled && - auth.use( - 'oidc', - new OIDCStrategy( - { - ...config.oidc, - callbackURL: apiUrl('/oidc/callback'), - }, - verifyOIDC, - ), - ); - -auth.serializeUser((user, done) => done(null, user)); -auth.deserializeUser((user, done) => done(null, user)); - -export default auth; - -function verifyJWT(payload, done) { - return User.unscoped() - .findByPk(payload.id) - .then((user) => done(null, user || false)) - .error((err) => done(err, false)); -} - -function verifyOIDC(tokenSet, profile, done) { - return findOrCreateOIDCUser(profile) - .then((user) => { - user.authData = { tokenSet }; - done(null, user); - }) - .catch((err) => done(Object.assign(err, { email: profile.email }), false)); -} - -function extractJwtFromCookie(req) { - const path = config.jwt.cookie.signed ? 'signedCookies' : 'cookies'; - return get(req[path], config.jwt.cookie.name, null); -} - -function secretOrKeyProvider(_, rawToken, done) { - const { id } = jwt.decode(rawToken) || {}; - return User.unscoped() - .findByPk(id, { rejectOnEmpty: true }) - .then((user) => user.getTokenSecret()) - .then((secret) => done(null, secret)) - .catch((err) => done(err)); -} - -function apiUrl(pathname) { - return new URL(path.join('/api', pathname), origin).href; -} - -function findOrCreateOIDCUser({ email, firstName, lastName }) { - if (!config.oidc.enableSignup) { - return User.findOne({ where: { email }, rejectOnEmpty: true }); - } - const defaults = { firstName, lastName, role: config.oidc.defaultRole }; - return User.findOrCreate({ where: { email }, defaults }).then( - ([user]) => user, - ); -} diff --git a/apps/backend/shared/auth/mw.js b/apps/backend/shared/auth/mw.js deleted file mode 100644 index cdb84fa..0000000 --- a/apps/backend/shared/auth/mw.js +++ /dev/null @@ -1,23 +0,0 @@ -import get from 'lodash/get.js'; -import roleConfig from '@app/config/src/role.js'; -import { StatusCodes } from 'http-status-codes'; -import { createError } from '../error/helpers.js'; -import { auth as authConfig } from '#config'; - -const { user: role } = roleConfig; - -function authorize(...allowed) { - allowed.push(role.ADMIN); - return ({ user }, _res, next) => { - if (user && allowed.includes(user.role)) return next(); - return createError(StatusCodes.UNAUTHORIZED, 'Access restricted'); - }; -} - -function extractAuthData(req, _res, next) { - const path = authConfig.jwt.cookie.signed ? 'signedCookies' : 'cookies'; - req.authData = get(req[path], 'auth', null); - return next(); -} - -export { authorize, extractAuthData }; diff --git a/apps/backend/shared/auth/oidc.js b/apps/backend/shared/auth/oidc.js deleted file mode 100644 index ed72af7..0000000 --- a/apps/backend/shared/auth/oidc.js +++ /dev/null @@ -1,86 +0,0 @@ -import { URL } from 'node:url'; -import { Strategy as BaseOIDCStrategy, Issuer } from 'openid-client'; - -export default class OIDCStrategy extends BaseOIDCStrategy { - constructor(options, verify) { - const issuer = createIssuer(options); - const client = createClient(issuer, options); - super({ client }, function (tokenSet, userInfo, done) { - const profile = parseUserInfo(userInfo); - return verify.call(this, tokenSet, profile, done); - }); - this.options = options; - } - - get client() { - return this._client; - } - - get issuer() { - return this._issuer; - } - - get isLogoutEnabled() { - return this.options.logoutEnabled; - } - - logoutUrl({ oidcData, ...params } = {}) { - const { client } = this; - const url = new URL( - client.endSessionUrl({ - ...params, - client_id: client.client_id, - id_token_hint: oidcData.tokenSet.id_token, - }), - ); - const customRedirectUriKey = this.options.postLogoutUriKey; - if (!customRedirectUriKey) return url.href; - const redirectUri = url.searchParams.get('post_logout_redirect_uri'); - url.searchParams.set(customRedirectUriKey, redirectUri); - return url.href; - } - - logout(params) { - return (req, res) => { - req.logout(() => { - const { oidc: oidcData } = req.authData || {}; - res.redirect(this.logoutUrl({ ...params, oidcData })); - }); - }; - } -} - -function createIssuer(options) { - return new Issuer({ - issuer: options.issuer, - jwks_uri: options.jwksURL, - authorization_endpoint: options.authorizationEndpoint, - token_endpoint: options.tokenEndpoint, - userinfo_endpoint: options.userInfoEndpoint, - end_session_endpoint: options.logoutEndpoint, - }); -} - -function createClient(issuer, { callbackURL, clientID, clientSecret }) { - const redirectUri = new URL(callbackURL); - const postLogoutRedirectUri = new URL(callbackURL); - postLogoutRedirectUri.searchParams.set('action', 'logout'); - return new issuer.Client({ - client_id: clientID, - client_secret: clientSecret, - redirect_uris: [redirectUri.href], - post_logout_redirect_uris: [postLogoutRedirectUri.href], - response_types: ['code'], - }); -} - -function parseUserInfo(userInfo) { - return { - id: userInfo.sub, - username: userInfo.username, - email: userInfo.email, - firstName: userInfo.given_name, - lastName: userInfo.family_name, - verified: userInfo.email_verified, - }; -} diff --git a/apps/backend/shared/database/config.js b/apps/backend/shared/database/config.js deleted file mode 100644 index 24668b6..0000000 --- a/apps/backend/shared/database/config.js +++ /dev/null @@ -1,46 +0,0 @@ -import 'dotenv/config'; -import yn from 'yn'; - -import { createLogger, Level } from '#logger'; - -const isProduction = process.env.NODE_ENV === 'production'; -const logger = createLogger('db', { level: Level.DEBUG }); - -const config = parseConfig(); -const migrationStorageTableName = 'sequelize_meta'; -const benchmark = !isProduction; - -function logging(query, time) { - const info = { query }; - if (time) info.duration = `${time}ms`; - return logger.debug(info); -} - -export default { - ...config, - migrationStorageTableName, - benchmark, - logging, -}; - -function parseConfig(config = process.env) { - const DATABASE_URI = config.DATABASE_URI || config.POSTGRES_URI; - if (DATABASE_URI) return { url: DATABASE_URI }; - if (!config.DATABASE_NAME) { - throw new TypeError( - `Invalid \`DATABASE_NAME\` provided: ${config.DATABASE_NAME}`, - ); - } - const dialectOptions = yn(config.DATABASE_SSL) - ? { ssl: { require: true, rejectUnauthorized: false } } - : {}; - return { - database: config.DATABASE_NAME, - username: config.DATABASE_USER, - password: config.DATABASE_PASSWORD, - host: config.DATABASE_HOST, - port: config.DATABASE_PORT, - dialect: config.DATABASE_ADAPTER || 'postgres', - dialectOptions, - }; -} diff --git a/apps/backend/shared/database/helpers.js b/apps/backend/shared/database/helpers.js deleted file mode 100644 index f72c56f..0000000 --- a/apps/backend/shared/database/helpers.js +++ /dev/null @@ -1,113 +0,0 @@ -import { Op, Sequelize, Utils } from 'sequelize'; -import get from 'lodash/get.js'; -import has from 'lodash/has.js'; -import inRange from 'lodash/inRange.js'; -import last from 'lodash/last.js'; -import mapValues from 'lodash/mapValues.js'; - -const { SequelizeMethod } = Utils; -const isFunction = (arg) => typeof arg === 'function'; -const notEmpty = (input) => input.length > 0; -const sql = { concat, where }; - -export { sql, getValidator, setLogging, wrapMethods, parsePath }; - -export function build(Model) { - return { - column: (col, model) => dbColumn(col, model || Model), - ...mapValues(sqlFunctions, (it) => buildSqlFunc(it, Model)), - }; -} - -const dbColumn = (col, Model) => { - if (col instanceof SequelizeMethod) return col; - const name = get(Model, `rawAttributes.${col}.field`, col); - return Sequelize.col(name); -}; - -function parsePath(path, Model) { - if (!path.includes('.')) return [dbColumn(path, Model)]; - const [alias, ...columns] = path.split('.'); - const { target: model } = Model.associations[alias]; - return [{ model, as: alias }, ...parsePath(columns.join('.'), model)]; -} - -const sqlFunctions = { - min: 'MIN', - max: 'MAX', - average: 'AVG', - count: 'COUNT', - distinct: 'DISTINCT', - sum: 'SUM', -}; - -function buildSqlFunc(name, Model) { - return (col, model) => Sequelize.fn(name, dbColumn(col, model || Model)); -} - -function getValidator(Model, attribute) { - return function validate(input) { - const validator = Model.prototype.validators[attribute]; - if (!validator || !validator.len) { - return notEmpty(input) || `"${attribute}" can not be empty`; - } - const [min, max] = validator.len; - return ( - inRange(input.length, min, max) || - `"${attribute}" must be between ${min} and ${max} characters long` - ); - }; -} - -function setLogging(Model, state) { - const { options } = Model.sequelize; - options.logging = state; - return options.logging; -} - -function concat(...args) { - const options = has(last(args), 'separator') ? args.pop() : {}; - if (!options.separator) return Sequelize.fn('concat', ...args); - return Sequelize.fn('concat_ws', options.separator, ...args); -} - -// NOTE: Fixes https://github.com/sequelize/sequelize/issues/6440 -function where(attribute, logic, options = {}) { - const { comparator = '=', scope = false } = options; - const where = Sequelize.where(attribute, comparator, logic); - return !scope ? where : { [Op.and]: [where] }; -} - -function wrapMethods(Model, Promise) { - let Ctor = Model; - do { - const methods = getMethods(Ctor.prototype); - const staticMethods = getMethods(Ctor); - [...methods, ...staticMethods].forEach((method) => - wrapMethod(method, Promise), - ); - Ctor = Object.getPrototypeOf(Ctor); - } while (Ctor !== Sequelize.Model && Ctor !== Function.prototype); - return Model; -} - -function wrapMethod({ key, value, target }, Promise) { - target[key] = function () { - const result = value.apply(this, arguments); - if (!result || !isFunction(result.catch)) return result; - return Promise.resolve(result); - }; -} - -function getMethods(object) { - return getProperties(object).filter( - ({ key, value }) => isFunction(value) && key !== 'constructor', - ); -} - -function getProperties(object) { - return Reflect.ownKeys(object).map((key) => { - const { value } = Reflect.getOwnPropertyDescriptor(object, key); - return { key, value, target: object }; - }); -} diff --git a/apps/backend/shared/database/hooks.js b/apps/backend/shared/database/hooks.js deleted file mode 100644 index a21756a..0000000 --- a/apps/backend/shared/database/hooks.js +++ /dev/null @@ -1,12 +0,0 @@ -import { hooks } from 'sequelize/lib/hooks'; -import mapValues from 'lodash/mapValues.js'; - -const Hooks = mapValues(hooks, (_, key) => key); - -Hooks.withType = (hookType, hook) => { - return function (...args) { - return hook.call(this, hookType, ...args); - }; -}; - -export default Hooks; diff --git a/apps/backend/shared/database/index.js b/apps/backend/shared/database/index.js deleted file mode 100644 index 4fe4ceb..0000000 --- a/apps/backend/shared/database/index.js +++ /dev/null @@ -1,167 +0,0 @@ -import { createRequire } from 'node:module'; -import path from 'node:path'; -import invoke from 'lodash/invoke.js'; -import forEach from 'lodash/forEach.js'; -import { SequelizeStorage, Umzug } from 'umzug'; -import pick from 'lodash/pick.js'; -import Promise from 'bluebird'; -import semver from 'semver'; -import Sequelize from 'sequelize'; -import sequelizeConfig from '../../sequelize.config.cjs'; - -// Require models. - -import User from '../../user/user.model.js'; -import Hooks from './hooks.js'; -import config from './config.js'; -import { wrapMethods } from './helpers.js'; -import { createLogger } from '#logger'; - -const require = createRequire(import.meta.url); -const pkg = require('../../package.json'); - -const logger = createLogger('db'); -const isProduction = process.env.NODE_ENV === 'production'; -const sequelize = createConnection(config); -const { migrationsPath } = sequelizeConfig; - -function initialize() { - const umzug = new Umzug({ - context: sequelize.getQueryInterface(), - storage: new SequelizeStorage({ - sequelize, - tableName: config.migrationStorageTableName, - }), - migrations: { - glob: path.join(migrationsPath, '*.js'), - resolve: ({ name, path, context }) => { - // Sequilize-CLI generates migrations that require - // two parameters be passed to the up and down methods - // but by default Umzug will only pass the first - const migration = require(path || ''); - return { - name, - up: async () => migration.up(context, Sequelize), - down: async () => migration.down(context, Sequelize), - }; - }, - }, - logger, - }); - - umzug.on('migrating', (m) => - logger.info({ migration: m }, '⬆️ Migrating:', m), - ); - umzug.on('migrated', (m) => - logger.info({ migration: m }, '⬆️ Migrated:', m), - ); - umzug.on('reverting', (m) => - logger.info({ migration: m }, '⬇️ Reverting:', m), - ); - umzug.on('reverted', (m) => - logger.info({ migration: m }, '⬇️ Reverted:', m), - ); - - return sequelize - .authenticate() - .then(() => logger.info(getConfig(sequelize), '🗄️ Connected to database')) - .then(() => checkPostgreVersion(sequelize)) - .then(() => !isProduction && umzug.up()) - .then(() => umzug.executed()) - .then((migrations) => { - const files = migrations.map((it) => it.name); - if (!files.length) return; - logger.info( - { migrations: files }, - '🗄️ Executed migrations:\n', - files.join('\n'), - ); - }); -} - -/** - * Revision needs to be before Content Element to ensure its hooks are triggered - * first. This is a temporary fix until a new system for setting up hooks is in - * place. - */ -const models = { - User: defineModel(User), -}; - -function defineModel(Model, connection = sequelize) { - const { DataTypes } = connection.Sequelize; - const fields = invoke(Model, 'fields', DataTypes, connection) || {}; - const options = invoke(Model, 'options') || {}; - Object.assign(options, { sequelize: connection }); - return Model.init(fields, options); -} - -forEach(models, (model) => { - invoke(model, 'associate', models); - addHooks(model, Hooks, models); - addScopes(model, models); - wrapMethods(model, Promise); -}); - -function addHooks(model, Hooks, models) { - const hooks = invoke(model, 'hooks', Hooks, models); - forEach(hooks, (it, type) => model.addHook(type, it)); -} - -function addScopes(model, models) { - const scopes = invoke(model, 'scopes', models); - forEach(scopes, (it, name) => model.addScope(name, it, { override: true })); -} - -const db = { - Sequelize, - sequelize, - initialize, - ...models, -}; - -wrapMethods(Sequelize.Model, Promise); -// Patch Sequelize#method to support getting models by class name. -sequelize.model = (name) => sequelize.models[name] || db[name]; - -function createConnection(config) { - if (!config.url) return new Sequelize(config); - return new Sequelize(config.url, config); -} - -function getConfig(sequelize) { - // NOTE: List public fields: https://git.io/fxVG2 - return pick(sequelize.config, [ - 'database', - 'username', - 'host', - 'port', - 'protocol', - 'pool', - 'native', - 'ssl', - 'replication', - 'dialectModulePath', - 'keepDefaultTimezone', - 'dialectOptions', - ]); -} - -function checkPostgreVersion(sequelize) { - return sequelize - .getQueryInterface() - .databaseVersion() - .then((version) => { - logger.info({ version }, 'PostgreSQL version:', version); - const range = pkg.engines && pkg.engines.postgres; - if (!range) return; - if (semver.satisfies(semver.coerce(version), range)) return; - const err = new Error(`"${pkg.name}" requires PostgreSQL ${range}`); - logger.error({ version, required: range }, err.message); - return Promise.reject(err); - }); -} - -export { Sequelize, sequelize, initialize, models }; - -export default db; diff --git a/apps/backend/shared/database/migrations/20181115140901-create-user.js b/apps/backend/shared/database/migrations/20181115140901-create-user.js deleted file mode 100644 index 1834274..0000000 --- a/apps/backend/shared/database/migrations/20181115140901-create-user.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -const TABLE_NAME = 'user'; - -module.exports = { - up: async (qi, Sequelize) => { - await qi.sequelize.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";'); - await qi.createTable(TABLE_NAME, { - id: { - type: Sequelize.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - uid: { - type: Sequelize.UUID, - unique: true, - allowNull: false, - defaultValue: Sequelize.literal('uuid_generate_v4()'), - }, - email: { - type: Sequelize.STRING, - unique: true, - }, - password: { - type: Sequelize.STRING, - }, - role: { - type: Sequelize.ENUM('ADMIN', 'USER', 'INTEGRATION'), - }, - first_name: { type: Sequelize.STRING(50) }, - last_name: { type: Sequelize.STRING(50) }, - img_url: { type: Sequelize.TEXT }, - createdAt: { - type: Sequelize.DATE, - field: 'created_at', - allowNull: false, - }, - updatedAt: { - type: Sequelize.DATE, - field: 'updated_at', - allowNull: false, - }, - deletedAt: { - type: Sequelize.DATE, - field: 'deleted_at', - }, - }); - }, - down: (queryInterface) => queryInterface.dropTable(TABLE_NAME), -}; diff --git a/apps/backend/shared/database/migrations/package.json b/apps/backend/shared/database/migrations/package.json deleted file mode 100644 index 5bbefff..0000000 --- a/apps/backend/shared/database/migrations/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} diff --git a/apps/backend/shared/database/pagination.js b/apps/backend/shared/database/pagination.js deleted file mode 100644 index 5710d44..0000000 --- a/apps/backend/shared/database/pagination.js +++ /dev/null @@ -1,23 +0,0 @@ -import pick from 'lodash/pick.js'; -import { parsePath } from './helpers.js'; - -const parseOptions = ({ limit, offset, sortOrder }) => ({ - limit: parseInt(limit, 10) || 100, - offset: parseInt(offset, 10) || 0, - sortOrder: sortOrder || 'ASC', -}); - -function processPagination(Model) { - return (req, _, next) => { - const options = parseOptions(req.query); - Object.assign(req.query, options); - req.options = pick(options, ['limit', 'offset']); - const { sortBy } = req.query; - if (sortBy) { - req.options.order = [[...parsePath(sortBy, Model), options.sortOrder]]; - } - next(); - }; -} - -export { processPagination }; diff --git a/apps/backend/shared/database/seeds/20181115140901-insert-users.js b/apps/backend/shared/database/seeds/20181115140901-insert-users.js deleted file mode 100644 index 741f628..0000000 --- a/apps/backend/shared/database/seeds/20181115140901-insert-users.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -const bcrypt = require('bcrypt'); -const Promise = require('bluebird'); -const users = require('@app/seed/user.json'); - -module.exports = { - up(queryInterface) { - const now = new Date(); - const rows = users.map((user) => ({ - ...user, - created_at: now, - updated_at: now, - })); - return import('../../../config/index.js') - .then(({ auth: config }) => - Promise.map(rows, (user) => encryptPassword(user, config.saltRounds)), - ) - .then((users) => queryInterface.bulkInsert('user', users)); - }, - down(queryInterface) { - return queryInterface.bulkDelete('user'); - }, -}; - -function encryptPassword(user, saltRounds) { - return bcrypt - .hash(user.password, saltRounds) - .then((password) => (user.password = password)) - .then(() => user); -} diff --git a/apps/backend/shared/database/seeds/package.json b/apps/backend/shared/database/seeds/package.json deleted file mode 100644 index 5bbefff..0000000 --- a/apps/backend/shared/database/seeds/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} diff --git a/apps/backend/shared/error/helpers.js b/apps/backend/shared/error/helpers.js deleted file mode 100644 index dc682dd..0000000 --- a/apps/backend/shared/error/helpers.js +++ /dev/null @@ -1,12 +0,0 @@ -import httpError from 'http-errors'; - -function validationError(err) { - const code = 400; - return Promise.reject(httpError(code, err.message, { validation: true })); -} - -function createError(code = 400, message = 'An error has occured') { - return Promise.reject(httpError(code, message, { custom: true })); -} - -export { createError, validationError }; diff --git a/apps/backend/shared/logger.js b/apps/backend/shared/logger.js deleted file mode 100644 index 7f2ac7f..0000000 --- a/apps/backend/shared/logger.js +++ /dev/null @@ -1,31 +0,0 @@ -import pino from 'pino'; -import pinoHttp from 'pino-http'; - -import { isProduction } from '#config'; - -export const Level = { - Fatal: 'fatal', - Error: 'error', - Warn: 'warn', - Info: 'info', - Debug: 'debug', - Trace: 'trace', - Silent: 'silent', -}; - -const prettyTransport = { - target: 'pino-pretty', - options: { colorize: true }, -}; - -const transport = isProduction ? undefined : prettyTransport; - -export const createLogger = (name, opts = {}) => - pino({ - name, - level: opts?.level || Level.Info, - transport, - }); - -export const createHttpLogger = () => - pinoHttp({ transport }); diff --git a/apps/backend/shared/mail/formatters.js b/apps/backend/shared/mail/formatters.js deleted file mode 100644 index fa043fb..0000000 --- a/apps/backend/shared/mail/formatters.js +++ /dev/null @@ -1,7 +0,0 @@ -import { htmlToText } from 'html-to-text'; - -function html() { - return (text, render) => htmlToText(render(text)); -} - -export { html }; diff --git a/apps/backend/shared/mail/index.js b/apps/backend/shared/mail/index.js deleted file mode 100644 index 6abc9c4..0000000 --- a/apps/backend/shared/mail/index.js +++ /dev/null @@ -1,95 +0,0 @@ -import { fileURLToPath, URL } from 'node:url'; -import path from 'node:path'; -import pick from 'lodash/pick.js'; -import { SMTPClient } from 'emailjs'; -import urlJoin from 'url-join'; -import { renderHtml, renderText } from './render.js'; -import { createLogger, Level } from '#logger'; -import { mail as config, origin } from '#config'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const logger = createLogger('mailer', { level: Level.DEBUG }); - -const from = `${config.sender.name} <${config.sender.address}>`; -const client = new SMTPClient(config); -// NOTE: Enable SMTP tracing if DEBUG is set. -client.smtp.debug(Number(Boolean(process.env.DEBUG))); -logger.info(getConfig(client), '📧 SMTP client created'); - -const send = async (...args) => { - try { - const msg = await client.sendAsync(...args); - logger.debug('📧 Email sent', msg); - return msg; - } catch (error) { - logger.error('📧 Failed to send email', error); - } -}; - -const templatesDir = path.join(__dirname, './templates/'); - -const resetUrl = (token) => - urlJoin(origin, '/auth/reset-password/', token, '/'); - -export default { - send, - invite, - resetPassword, -}; - -function invite(user, token) { - const href = resetUrl(token); - const { hostname } = new URL(href); - const recipient = user.email; - const recipientName = user.firstName || user.email; - const data = { href, origin, hostname, recipientName }; - const html = renderHtml(path.join(templatesDir, 'welcome.mjml'), data); - const text = renderText(path.join(templatesDir, 'welcome.txt'), data); - logger.info( - { recipient, sender: from }, - '📧 Sending invite email to:', - recipient, - ); - return send({ - from, - to: recipient, - subject: 'Invite', - text, - attachment: [{ data: html, alternative: true }], - }); -} - -function resetPassword(user, token) { - const href = resetUrl(token); - const recipient = user.email; - const recipientName = user.firstName || user.email; - const data = { href, recipientName, origin }; - const html = renderHtml(path.join(templatesDir, 'reset.mjml'), data); - const text = renderText(path.join(templatesDir, 'reset.txt'), data); - logger.info( - { recipient, sender: from }, - '📧 Sending reset password email to:', - recipient, - ); - return send({ - from, - to: recipient, - subject: 'Reset password', - text, - attachment: [{ data: html, alternative: true }], - }); -} - -function getConfig(client) { - // NOTE: List public keys: - // https://github.com/eleith/emailjs/blob/7fddabe/smtp/smtp.js#L86 - return pick(client.smtp, [ - 'host', - 'port', - 'domain', - 'authentication', - 'ssl', - 'tls', - 'timeout', - ]); -} diff --git a/apps/backend/shared/mail/render.js b/apps/backend/shared/mail/render.js deleted file mode 100644 index 0442136..0000000 --- a/apps/backend/shared/mail/render.js +++ /dev/null @@ -1,38 +0,0 @@ -import * as fs from 'node:fs'; -import cheerio from 'cheerio'; -import map from 'lodash/map.js'; -import mapKeys from 'lodash/mapKeys.js'; -import mjml2html from 'mjml'; -import mustache from 'mustache'; -import { kebabCase } from 'change-case'; -import { html } from './formatters.js'; - -export { renderHtml, renderText }; - -function renderHtml(templatePath, data, style) { - const template = fs.readFileSync(templatePath, 'utf8'); - const $ = cheerio.load(template, { xmlMode: true }); - const $style = $('mj-attributes'); - $style.append(getAttributes($, style)); - const opts = { filePath: templatePath, minify: true }; - const mustacheOutput = mustache.render($.html(), data); - const output = mjml2html(mustacheOutput, opts).html; - // NOTE: Additional `mustache.render` call handles mustache syntax within mjml - // subcomponents. Subcomponents' mustache syntax is removed by `mjml2html` if - // placed outside of tag attribute or mj-text tag. - return mustache.render(output, data); -} - -function renderText(templatePath, data) { - const template = fs.readFileSync(templatePath, 'utf8'); - return mustache.render(template, { ...data, html }); -} - -function getAttributes($, style = {}) { - return map(style, (declarations, name) => - $('').attr({ - name, - ...mapKeys(declarations, (_, key) => kebabCase(key)), - }), - ); -} diff --git a/apps/backend/shared/mail/templates/components/footer.mjml b/apps/backend/shared/mail/templates/components/footer.mjml deleted file mode 100644 index 28691cc..0000000 --- a/apps/backend/shared/mail/templates/components/footer.mjml +++ /dev/null @@ -1,4 +0,0 @@ - - - If you didn't request this, please ignore this email. - diff --git a/apps/backend/shared/mail/templates/components/head.mjml b/apps/backend/shared/mail/templates/components/head.mjml deleted file mode 100644 index c564c3d..0000000 --- a/apps/backend/shared/mail/templates/components/head.mjml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - strong { - font-weight: bold; - } - - a { - color: #c2185b; - text-decoration: none; - } - - .content { - color: #222222; - font-size: 16px; - font-family: 'Helvetica Neue', helvetica, arial, sans-serif; - } - - .label { - font-size: 14px; - font-weight: 700; - } - - .avatar { - display: block; - width: 36px; - height: 36px; - color: #fff; - line-height: 36px; - font-weight: 100; - text-align: center; - border-radius: 50%; - background-color: #36474f; - } - - diff --git a/apps/backend/shared/mail/templates/components/header.mjml b/apps/backend/shared/mail/templates/components/header.mjml deleted file mode 100644 index f5a965b..0000000 --- a/apps/backend/shared/mail/templates/components/header.mjml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - App name - - - - diff --git a/apps/backend/shared/mail/templates/reset.mjml b/apps/backend/shared/mail/templates/reset.mjml deleted file mode 100644 index 3210b06..0000000 --- a/apps/backend/shared/mail/templates/reset.mjml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - Hello {{recipientName}}, - - - You have requested password reset. - - - Please reset your password by clicking the button below: - - - Reset password - - - Or copy and paste this URL into your browser: - - {{href}} - - - - - diff --git a/apps/backend/shared/mail/templates/reset.txt b/apps/backend/shared/mail/templates/reset.txt deleted file mode 100644 index 5626edb..0000000 --- a/apps/backend/shared/mail/templates/reset.txt +++ /dev/null @@ -1,15 +0,0 @@ -App name -================================================= - -Hello {{recipientName}}, - -You have requested password reset. -Please reset your password by clicking the URL below: - -{{href}} - -Or copy and paste this URL into your browser. - - -------------------------------------------------- -If you didn't request this, please ignore this email. diff --git a/apps/backend/shared/mail/templates/welcome.mjml b/apps/backend/shared/mail/templates/welcome.mjml deleted file mode 100644 index 5d59f9c..0000000 --- a/apps/backend/shared/mail/templates/welcome.mjml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - Welcome {{recipientName}}, - - - An account has been created for you on {{hostname}}. - - - Please finish your registration by clicking the button below: - - - Complete registration - - - Or copy and paste this URL into your browser: - - {{href}} - - - - - diff --git a/apps/backend/shared/mail/templates/welcome.txt b/apps/backend/shared/mail/templates/welcome.txt deleted file mode 100644 index cd9afcb..0000000 --- a/apps/backend/shared/mail/templates/welcome.txt +++ /dev/null @@ -1,15 +0,0 @@ -App name -================================================= - -Welcome {{recipientName}}, - -An account has been created for you on {{hostname}}. -Please finish your registration by clicking the URL below: - -{{href}} - -Or copy and paste this URL into your browser. - - -------------------------------------------------- -If you didn't request this, please ignore this email. diff --git a/apps/backend/shared/oAuth2Provider.js b/apps/backend/shared/oAuth2Provider.js deleted file mode 100644 index c24cc1c..0000000 --- a/apps/backend/shared/oAuth2Provider.js +++ /dev/null @@ -1,54 +0,0 @@ -import { ClientCredentials } from 'simple-oauth2'; -import request from 'axios'; -import yup from 'yup'; -import oAuthConfig from '../config/consumer.js'; - -const schema = yup.object().shape({ - clientId: yup.string().required(), - clientSecret: yup.string().required(), - tokenHost: yup.string().url().required(), - tokenPath: yup.string().required(), -}); - -function createOAuth2Provider() { - if (!oAuthConfig.isAuthConfigured) { - return { isConfigured: false }; - } - const { clientId, clientSecret, tokenHost, tokenPath } = schema.validateSync( - oAuthConfig, - { stripUnknown: true }, - ); - - const client = new ClientCredentials({ - client: { id: clientId, secret: clientSecret }, - auth: { tokenHost, tokenPath }, - }); - - let accessToken; - - async function send(url, payload) { - if (!accessToken || accessToken.expired()) { - await getAccessToken(); - } - return request - .post(url, payload, { - headers: { - Authorization: `Bearer ${accessToken.token.access_token}`, - }, - }) - .catch((error) => console.error(error.message)); - } - - function getAccessToken() { - return client - .getToken() - .then((token) => { - accessToken = token; - }) - .catch((error) => console.error('Access Token Error', error.message)); - } - - return { send, isConfigured: true }; -} - -export default createOAuth2Provider(); diff --git a/apps/backend/shared/origin.js b/apps/backend/shared/origin.js deleted file mode 100644 index bc43949..0000000 --- a/apps/backend/shared/origin.js +++ /dev/null @@ -1,19 +0,0 @@ -import { createLogger } from '#logger'; -import { hostname } from '#config'; - -const logger = createLogger(); -const isProduction = process.env.NODE_ENV === 'production'; - -export default () => { - if (hostname) return middleware; - const message = 'Origin: "HOSTNAME" is not set, using "Host" HTTP header.'; - isProduction ? logger.warn('⚠️ ', message) : logger.info(message); - return middleware; -}; - -function middleware(req, _, next) { - Object.defineProperty(req, 'origin', { - get: () => `${req.protocol}://${hostname || req.get('host')}`, - }); - next(); -} diff --git a/apps/backend/shared/request/mw.js b/apps/backend/shared/request/mw.js deleted file mode 100644 index a6a5c1b..0000000 --- a/apps/backend/shared/request/mw.js +++ /dev/null @@ -1,56 +0,0 @@ -import Keyv from 'keyv'; -import rateLimit from 'express-rate-limit'; -import { - general as generalConfig, - kvStore as kvStoreConfig, -} from '#config'; - -const DEFAULT_WINDOW_MS = 1 * 60 * 1000; // every minute - -// Store must be implemented using the following interface: -// https://github.com/nfriedly/express-rate-limit/blob/master/README.md#store -class Store { - constructor() { - this.cache = new Keyv({ - ...kvStoreConfig.keyvDefaultConfig, - namespace: 'request-limiter', - }); - } - - async incr(key, cb) { - const initialState = { hits: 0 }; - const { hits, ...record } = (await this.cache.has(key)) - ? await this.cache.get(key) - : initialState; - const updatedHits = hits + 1; - await this.cache.set(key, { ...record, hits: updatedHits }); - cb(null, updatedHits); - } - - async decrement(key) { - const { hits, ...record } = (await this.cache.get(key)) || {}; - if (!hits) return; - return this.cache.set(key, { ...record, hits: hits - 1 }); - } - - resetKey(key) { - return this.cache.delete(key); - } -} - -const defaultStore = new Store(); - -function requestLimiter({ - limit = 30, - windowMs = DEFAULT_WINDOW_MS, - validate = false, - store = defaultStore, - ...opts -} = {}) { - const max = limit > 0 ? limit : 0; - const options = { limit: max, validate, windowMs, store, ...opts }; - if (!generalConfig.enableRateLimiting) options.skip = () => true; - return rateLimit(options); -} - -export { requestLimiter }; diff --git a/apps/backend/shared/sse/channels.js b/apps/backend/shared/sse/channels.js deleted file mode 100644 index 046cc41..0000000 --- a/apps/backend/shared/sse/channels.js +++ /dev/null @@ -1,59 +0,0 @@ -import { EventEmitter } from 'events'; - -const channels = new Map(); - -class Channel extends EventEmitter { - constructor(id) { - super(); - this._id = id; - this._connections = new Map(); - } - - get id() { - return this._id; - } - - add(connection) { - this._connections.set(connection.id, connection); - connection.prependOnceListener('close', () => this.remove(connection)); - return this; - } - - remove(connection) { - this._connections.delete(connection.id); - if (!this._connections.size) this.emit('close'); - return this; - } - - // eslint-disable-next-line no-unused-vars - send(_event, _data) { - this._connections.forEach((connection) => connection.send(...arguments)); - return this; - } -} - -export default { - getChannel, - addConnection, - removeConnection, -}; - -function getChannel(channelId) { - channelId = String(channelId); - return channels.get(channelId) || new Channel('\0dummy'); -} - -function addConnection(channelId, connection) { - channelId = String(channelId); - if (channels.has(channelId)) { - return channels.get(channelId).add(connection); - } - const channel = new Channel(channelId); - channel.prependOnceListener('close', () => channels.delete(channelId)); - channels.set(channelId, channel); - return channel.add(connection); -} - -function removeConnection(channelId, connection) { - return getChannel(channelId).remove(connection); -} diff --git a/apps/backend/shared/sse/index.js b/apps/backend/shared/sse/index.js deleted file mode 100644 index 8d97ef4..0000000 --- a/apps/backend/shared/sse/index.js +++ /dev/null @@ -1,125 +0,0 @@ -import { EventEmitter } from 'events'; -import { createId as cuid, isCuid } from '@paralleldrive/cuid2'; - -import channels from './channels.js'; - -const SSE_TIMEOUT_MARGIN = 0.1; -const SSE_DEFAULT_TIMEOUT = 60000; /* ms */ -const SSE_HEADERS = { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-transform', - 'Connection': 'keep-alive', - 'Transfer-Encoding': 'chunked', - // NOTE: This controls nginx proxy buffering - // https://nginx.com/resources/wiki/start/topics/examples/x-accel/#x-accel-buffering - 'X-Accel-Buffering': 'no', -}; - -const hasProp = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); - -class SSEConnection extends EventEmitter { - constructor(res) { - super(); - this._id = null; - this._res = res; - this._req = res.req; - this._lastEventId = 0; - this._heartbeat = null; - this.initialize(); - } - - static create(res) { - return new this(res); - } - - get id() { - return this._id; - } - - get socket() { - return this._res.socket; - } - - get query() { - return this._req.query; - } - - header(name) { - return this._req.header(name); - } - - get timeout() { - const connectionTimeout = parseInt(this.header('connection-timeout'), 10); - const timeout = connectionTimeout || SSE_DEFAULT_TIMEOUT; - return timeout * (1 - SSE_TIMEOUT_MARGIN); - } - - initialize() { - // Set socket properties. - this.socket.setTimeout(0); - this.socket.setNoDelay(true); - this.socket.setKeepAlive(true); - // Gracefully handle termination. - this._req.once('close', () => this.close()); - // Set event stream headers. - this._res.writeHead(200, SSE_HEADERS); - this._res.flushHeaders(); - // Ensure connection id is correctly set. - this._id = isCuid(this.query.id) ? this.query.id : cuid(); - // Setup heartbeat interval. - if (this.timeout > 0) { - this._heartbeat = setInterval(() => this.write(':ping'), this.timeout); - } - // Start stream. - return this.write(':ok'); - } - - write(payload = '') { - return this._res.write(`${payload}\n\n`); - } - - send(event, data = '') { - const id = (this._lastEventId += 1); - this.emit('data', { id, event, data }); - const json = JSON.stringify(data); - const payload = [`id: ${id}`, `event: ${event}`, `data: ${json}`].join( - '\n', - ); - this.write(payload); - if (hasProp(this.query, 'debug')) { - this.debug({ id, type: event, data }); - } - return this; - } - - debug(data = '') { - const json = JSON.stringify(data); - this.write(`data: ${json}`); - return this; - } - - close() { - if (this._heartbeat) clearInterval(this._heartbeat); - this._res.end(); - this.emit('close'); - } - - static channel(channelId) { - return channels.getChannel(channelId); - } - - join(channelId) { - return channels.addConnection(channelId, this); - } - - leave(channelId) { - return channels.removeConnection(channelId, this); - } -} - -export default SSEConnection; - -export function middleware(_req, res, next) { - res.sse = SSEConnection.create(res); - next(); -} diff --git a/apps/backend/shared/storage/index.js b/apps/backend/shared/storage/index.js deleted file mode 100644 index 78c52ea..0000000 --- a/apps/backend/shared/storage/index.js +++ /dev/null @@ -1,89 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; -import autobind from 'auto-bind'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -class Storage { - constructor(provider) { - this.provider = provider; - autobind(this); - } - - getFile(key, options = {}) { - return this.provider.getFile(key, options); - } - - createReadStream(key, options = {}) { - return this.provider.createReadStream(key, options); - } - - saveFile(key, data, options = {}) { - return this.provider.saveFile(key, data, options); - } - - createWriteStream(key, options = {}) { - return this.provider.createWriteStream(key, options); - } - - deleteFile(key, options = {}) { - return this.provider.deleteFile(key, options); - } - - deleteFiles(keys, options = {}) { - return this.provider.deleteFiles(keys, options); - } - - listFiles(options = {}) { - return this.provider.listFiles(options); - } - - fileExists(key, options = {}) { - return this.provider.fileExists(key, options); - } - - getFileUrl(key, options = {}) { - return this.provider.getFileUrl(key, options); - } - - moveFile(key, newKey, options = {}) { - return this.provider.moveFile(key, newKey, options); - } - - copyFile(key, newKey, options = {}) { - return this.provider.copyFile(key, newKey, options); - } - - static async create(config) { - const provider = await Storage.createProvider(config); - return new this(provider); - } - - static async createProvider(options) { - // Validate provider name. - const providerName = options.provider; - if (!options[providerName]) { - throw new Error('Provider should be defined in config'); - } - - // Load provider module & create provider instance. - const config = options[providerName]; - const Provider = await loadProvider(providerName); - return Provider.create(config); - } -} - -export default Storage; - -async function loadProvider(name) { - try { - const Provider = await import( - path.join(__dirname, './providers/', `${name}.js`) - ); - return Provider; - } catch (err) { - if (err.code === 'MODULE_NOT_FOUND') - throw new Error('Unsupported provider'); - throw err; - } -} diff --git a/apps/backend/shared/storage/providers/amazon.js b/apps/backend/shared/storage/providers/amazon.js deleted file mode 100644 index 3c3cded..0000000 --- a/apps/backend/shared/storage/providers/amazon.js +++ /dev/null @@ -1,199 +0,0 @@ -import { PassThrough } from 'node:stream'; -import path from 'node:path'; -import * as yup from 'yup'; -import { - CopyObjectCommand, - CreateBucketCommand, - DeleteObjectCommand, - DeleteObjectsCommand, - GetObjectCommand, - HeadBucketCommand, - HeadObjectCommand, - ListObjectsV2Command, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import mime from 'mime-types'; -import { Upload } from '@aws-sdk/lib-storage'; -import { validateConfig } from '../validation.js'; - -const noop = () => {}; -const isNotFound = (err) => err.Code === 'NoSuchKey'; -const DEFAULT_EXPIRATION_TIME = 3600; // seconds - -export const schema = yup.object().shape({ - endpoint: yup.string(), - region: yup.string().required(), - bucket: yup.string().required(), - key: yup.string().required(), - secret: yup.string().required(), -}); - -class Amazon { - constructor(config) { - config = validateConfig(config, schema); - - const s3Config = { - signatureVersion: 'v4', - region: config.region, - apiVersion: '2006-03-01', - maxRetries: 3, - }; - - if (config.endpoint) { - s3Config.endpoint = config.endpoint; - s3Config.forcePathStyle = true; - } - - if (config.key && config.secret) { - s3Config.credentials = { - accessKeyId: config.key, - secretAccessKey: config.secret, - }; - } - - this.bucket = config.bucket; - this.region = config.region; - this.client = new S3Client(s3Config); - this.initTestBucket(); - } - - static create(config) { - return new Amazon(config); - } - - async initTestBucket() { - const endpoint = await this.client.config.endpoint(); - if (!endpoint.hostname === 'localhost') return; - try { - await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })); - } catch { - await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })); - } - } - - path(...segments) { - segments = [this.bucket, ...segments]; - return path.join(...segments); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/getobjectcommand.html - getFile(key, options = {}) { - const params = Object.assign(options, { Bucket: this.bucket, Key: key }); - return this.client - .send(new GetObjectCommand(params)) - .then(({ Body: data }) => data.transformToByteArray()) - .then(Buffer.from) - .catch((err) => { - if (isNotFound(err)) return null; - return Promise.reject(err); - }); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/getobjectcommand.html - async createReadStream(key, options = {}) { - const throughStream = new PassThrough(); - const params = Object.assign(options, { - Bucket: this.bucket, - Key: key, - Body: throughStream, - ContentType: options.ContentType || mime.lookup(key), - }); - const s3Item = await this.client.send(new GetObjectCommand(params)); - s3Item.Body.pipe(throughStream); - return throughStream; - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/putobjectcommand.html - saveFile(key, data, options = {}) { - const params = Object.assign(options, { - Bucket: this.bucket, - Key: key, - Body: data, - }); - return this.client.send(new PutObjectCommand(params)); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/classes/_aws_sdk_lib_storage.Upload.html - createWriteStream(key, options = {}) { - const throughStream = new PassThrough(); - const params = Object.assign(options, { - Bucket: this.bucket, - Key: key, - Body: throughStream, - ContentType: options.ContentType || mime.lookup(key), - }); - const upload = new Upload({ client: this.client, params }); - upload.done().catch(noop); - return throughStream; - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/copyobjectcommand.html - copyFile(key, newKey, options = {}) { - const { base, ...rest } = path.parse(key); - const encodedSource = path.format({ - base: encodeURIComponent(base), - ...rest, - }); - const params = Object.assign( - options, - { Bucket: this.bucket }, - { - CopySource: this.path(`/${encodedSource}`), - Key: newKey, - }, - ); - return this.client.send(new CopyObjectCommand(params)); - } - - moveFile(key, newKey, options = {}) { - return this.copyFile(key, newKey, options).then((result) => - this.deleteFile(key).then(() => result), - ); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/deleteobjectcommand.html - deleteFile(key, options = {}) { - const params = Object.assign(options, { Bucket: this.bucket, Key: key }); - return this.client.send(new DeleteObjectCommand(params)); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/deleteobjectscommand.html - deleteFiles(keys, options = {}) { - const objects = keys.map((key) => ({ Key: key })); - if (!keys.length) return Promise.resolve(); - const params = Object.assign(options, { - Bucket: this.bucket, - Delete: { Objects: objects }, - }); - return this.client.send(new DeleteObjectsCommand(params)); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/listobjectsv2command.html - listFiles(key, options = {}) { - const params = Object.assign(options, { Bucket: this.bucket, Prefix: key }); - return this.client - .send(new ListObjectsV2Command(params)) - .then(({ Contents: files }) => files.map((file) => file.Key)); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/headobjectcommand.html - fileExists(key) { - const params = { Bucket: this.bucket, Key: key }; - return this.client.send(new HeadObjectCommand(params)).catch((err) => { - if (isNotFound(err)) return null; - return Promise.reject(err); - }); - } - - // API docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/getobjectcommand.html - getFileUrl(key, options = {}) { - const expires = options.expires || DEFAULT_EXPIRATION_TIME; - const params = Object.assign(options, { Bucket: this.bucket, Key: key }); - const command = new GetObjectCommand(params); - return getSignedUrl(this.client, command, { expiresIn: expires }); - } -} - -export const create = Amazon.create.bind(Amazon); diff --git a/apps/backend/shared/storage/providers/filesystem.js b/apps/backend/shared/storage/providers/filesystem.js deleted file mode 100644 index ffa51a9..0000000 --- a/apps/backend/shared/storage/providers/filesystem.js +++ /dev/null @@ -1,99 +0,0 @@ -import * as fs from 'node:fs'; -import * as fsp from 'node:fs/promises'; -import path from 'node:path'; -import expandPath from 'untildify'; -import { mkdirp } from 'mkdirp'; -import * as yup from 'yup'; -import { pathExists } from 'path-exists'; -import Promise from 'bluebird'; -import { validateConfig } from '../validation.js'; -import config from '#config'; - -const isNotFound = (err) => err.code === 'ENOENT'; -const resolvePath = (str) => path.resolve(expandPath(str)); - -export const schema = yup.object().shape({ - path: yup.string().required(), -}); - -class FilesystemStorage { - constructor(config) { - config = validateConfig(config, schema); - this.root = resolvePath(config.path); - } - - static create(config) { - return new FilesystemStorage(config); - } - - path(...segments) { - segments = [this.root, ...segments]; - return path.join(...segments); - } - - getFile(key, options = {}) { - return fsp.readFile(this.path(key), options).catch((err) => { - if (isNotFound(err)) return null; - return Promise.reject(err); - }); - } - - createReadStream(key, options = {}) { - return fs.createReadStream(this.path(key), options); - } - - saveFile(key, data, options = {}) { - const filePath = this.path(key); - return mkdirp(path.dirname(filePath)).then(() => - fsp.writeFile(filePath, data, options), - ); - } - - createWriteStream(key, options = {}) { - const filepath = this.path(key); - const dirname = path.dirname(filepath); - // TODO: Replace with async mkdir - fs.mkdirSync(dirname, { recursive: true }); - return fs.createWriteStream(filepath, options); - } - - copyFile(key, newKey) { - const src = this.path(key); - const dest = this.path(newKey); - return mkdirp(path.dirname(dest)).then(() => fsp.copyFile(src, dest)); - } - - moveFile(key, newKey) { - return this.copyFile(key, newKey).then((file) => - this.deleteFile(key).then(() => file), - ); - } - - deleteFile(key) { - return fsp.unlink(this.path(key)); - } - - deleteFiles(keys) { - return Promise.map(keys, (key) => this.deleteFile(key)); - } - - listFiles(key, options = {}) { - return fsp - .readdir(this.path(key), options) - .map((fileName) => `${key}/${fileName}`) - .catch((err) => { - if (isNotFound(err)) return null; - return Promise.reject(err); - }); - } - - fileExists(key) { - return pathExists(this.path(key)); - } - - getFileUrl(key) { - return Promise.resolve(`${config.origin}/${key}`); - } -} - -export const create = FilesystemStorage.create.bind(FilesystemStorage); diff --git a/apps/backend/shared/storage/storage.controller.js b/apps/backend/shared/storage/storage.controller.js deleted file mode 100644 index bd54fe7..0000000 --- a/apps/backend/shared/storage/storage.controller.js +++ /dev/null @@ -1,29 +0,0 @@ -import path from 'node:path'; -import fecha from 'fecha'; -import StorageService from './storage.service.js'; - -const { getFileUrl } = Storage; - -function getUrl(req, res) { - const { - query: { key }, - } = req; - return getFileUrl(key).then((url) => res.json({ url })); -} - -async function upload({ file, body, user }, res) { - const { name } = path.parse(file.originalname); - if (body.unpack) { - const timestamp = fecha.format(new Date(), 'YYYY-MM-DDTHH:mm:ss'); - const root = `${timestamp}__${user.id}__${name}`; - const assets = await StorageService.uploadArchiveContent(file, root); - return res.json({ root, assets }); - } - const asset = await StorageService.uploadFile(file, name); - return res.json(asset); -} - -export default { - getUrl, - upload, -}; diff --git a/apps/backend/shared/storage/storage.router.js b/apps/backend/shared/storage/storage.router.js deleted file mode 100644 index db52513..0000000 --- a/apps/backend/shared/storage/storage.router.js +++ /dev/null @@ -1,13 +0,0 @@ -import express from 'express'; -import multer from 'multer'; -import ctrl from './storage.controller.js'; - -const router = express.Router(); -const upload = multer({ storage: multer.memoryStorage() }); - -router.get('/', ctrl.getUrl).post('/', upload.single('file'), ctrl.upload); - -export default { - path: '/assets', - router, -}; diff --git a/apps/backend/shared/storage/storage.service.js b/apps/backend/shared/storage/storage.service.js deleted file mode 100644 index 14732f8..0000000 --- a/apps/backend/shared/storage/storage.service.js +++ /dev/null @@ -1,58 +0,0 @@ -import path from 'node:path'; -import fromPairs from 'lodash/fromPairs.js'; -import JSZip from 'jszip'; -import mime from 'mime-types'; -import pickBy from 'lodash/pickBy.js'; -import request from 'axios'; -import { v4 as uuidv4 } from 'uuid'; - -import Storage from '../../repository/storage.js'; -import { readFile, sha256 } from './util.js'; -import { storage as config } from '#config'; - -const { getFileUrl, getPath, saveFile } = Storage; -const getStorageUrl = (key) => `${config.protocol}${key}`; - -class StorageService { - // Prefix key with custom protocol. e.g. storage://sample_key.ext - getStorageUrl = (key) => `${config.protocol}${key}`; - - async uploadFile(file, name) { - const buffer = await readFile(file); - const hash = sha256(file.originalname, buffer); - const extension = path.extname(file.originalname); - const fileName = `${hash}___${name}${extension}`; - const key = path.join(getPath(), fileName); - await saveFile(key, buffer, { ContentType: file.mimetype }); - const publicUrl = await getFileUrl(key); - return { key, publicUrl, url: getStorageUrl(key) }; - } - - async uploadArchiveContent(archive, name) { - const buffer = await readFile(archive); - const content = await JSZip.loadAsync(buffer); - const files = pickBy(content.files, (it) => !it.dir); - const keys = await Promise.all( - Object.keys(files).map(async (src) => { - const key = path.join(getPath(), name, src); - const file = await content.file(src).async('uint8array'); - const mimeType = mime.lookup(src); - await saveFile(key, Buffer.from(file), { ContentType: mimeType }); - return [key, getStorageUrl(key)]; - }), - ); - return fromPairs(keys); - } - - async downloadToStorage(url) { - const res = await request.get(url, { responseType: 'arraybuffer' }); - const filename = path.join( - getPath(), - `${uuidv4()}__${url.pathname.split('/').pop()}`, - ); - await Storage.saveFile(filename, res.data); - return getStorageUrl(filename); - } -} - -export default new StorageService(); diff --git a/apps/backend/shared/storage/util.js b/apps/backend/shared/storage/util.js deleted file mode 100644 index 16e3b95..0000000 --- a/apps/backend/shared/storage/util.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as fsp from 'node:fs/promises'; -import crypto from 'node:crypto'; - -function sha256(...args) { - const hash = crypto.createHash('sha256'); - args.forEach((arg) => hash.update(arg)); - return hash.digest('hex'); -} - -function readFile(file) { - if (file.buffer) return Promise.resolve(file.buffer); - return fsp.readFile(file.path); -} - -export { sha256, readFile }; diff --git a/apps/backend/shared/storage/validation.js b/apps/backend/shared/storage/validation.js deleted file mode 100644 index 683d988..0000000 --- a/apps/backend/shared/storage/validation.js +++ /dev/null @@ -1,26 +0,0 @@ -import * as yup from 'yup'; - -const { ValidationError } = yup; - -yup.addMethod(yup.string, 'pkcs1', function () { - function isValid(value) { - if (!value) return false; - const isValidStart = value.startsWith('-----BEGIN RSA PRIVATE KEY-----'); - const isValidEnd = value.endsWith('-----END RSA PRIVATE KEY-----'); - return isValidStart && isValidEnd; - } - return this.test('format', 'Invalid private key format', isValid); -}); - -export { validateConfig }; - -function validateConfig(config, schema) { - try { - return schema.validateSync(config, { stripUnknown: true }); - } catch (error) { - if (!ValidationError.isError(error)) throw error; - const err = new Error('Unsupported config structure'); - err.cause = error; - throw err; - } -} diff --git a/apps/backend/shared/util/Deferred.js b/apps/backend/shared/util/Deferred.js deleted file mode 100644 index 5aa0ad1..0000000 --- a/apps/backend/shared/util/Deferred.js +++ /dev/null @@ -1,9 +0,0 @@ -export default function Deferred() { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - this.callback = (err, ...args) => { - return err ? this.reject(err) : this.resolve(...args); - }; -} diff --git a/apps/backend/shared/util/processListQuery.js b/apps/backend/shared/util/processListQuery.js deleted file mode 100644 index 74d0b1d..0000000 --- a/apps/backend/shared/util/processListQuery.js +++ /dev/null @@ -1,38 +0,0 @@ -import assign from 'lodash/assign.js'; -import defaultsDeep from 'lodash/defaultsDeep.js'; -import { Op } from 'sequelize'; -import pick from 'lodash/pick.js'; - -const filter = { - where: {}, - offset: 0, - limit: null, - order: [['id', 'ASC']], - paranoid: true, -}; - -export default function (defaults) { - return function (req, _res, next) { - const order = [[req.query.sortBy, req.query.sortOrder]]; - const query = assign(pick(req.query, ['offset', 'limit', 'paranoid']), { - order, - }); - const options = defaultsDeep({}, query, defaults, filter); - - if (query.integration) { - options.paranoid = false; - } - - if (query.syncedAt) { - const condition = { $gte: query.syncedAt }; - options.where[Op.or] = [ - { updatedAt: condition }, - { deletedAt: condition }, - ]; - } - - req.opts = options; - - next(); - }; -} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts new file mode 100644 index 0000000..2747c6f --- /dev/null +++ b/apps/backend/src/app.module.ts @@ -0,0 +1,48 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthModule } from './modules/auth/auth.module'; +import { CommonModule } from './common/common.module'; +import { HealthModule } from './modules/health/health.module'; +import { MailModule } from './modules/mail/mail.module'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { UserModule } from './modules/user/user.module'; +import dbConfig, { DbConfig } from './config/db.config'; +import authConfig from './config/auth.config'; +import generalConfig from './config/general.config'; +import { validationSchema } from './config/validation'; +import mailConfig from './config/mail.config'; +import mikroOrmConfig from './config/mikro-orm.config'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '../../.env', + load: [authConfig, dbConfig, generalConfig, mailConfig, mikroOrmConfig], + validationSchema, + isGlobal: true, + cache: true, + expandVariables: true, + }), + MikroOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const mikroOrmConfig = configService.get('mikroORM'); + const database = configService.get('database'); + const saltRounds = configService.get('auth.saltRounds'); + return { + ...mikroOrmConfig, + ...database, + saltRounds, + }; + }, + }), + ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), + AuthModule, + CommonModule, + HealthModule, + MailModule, + UserModule, + ], +}) +export class AppModule {} diff --git a/apps/backend/src/common/common.module.ts b/apps/backend/src/common/common.module.ts new file mode 100644 index 0000000..624a7f4 --- /dev/null +++ b/apps/backend/src/common/common.module.ts @@ -0,0 +1,27 @@ +import { Module, Global } from '@nestjs/common'; +import { ResponseInterceptor } from './interceptors/response.interceptor'; +import { LoggingInterceptor } from './interceptors/logging.interceptor'; +import { HttpExceptionFilter } from './filters/http-exception.filter'; +import { AllExceptionsFilter } from './filters/all-exceptions.filter'; +import { ValidationExceptionFilter } from './filters/validation-exception.filter'; + +@Global() +@Module({ + providers: [ + // Note: Some of these are registered globally in main.ts + // Keeping them here for module completeness + AllExceptionsFilter, + HttpExceptionFilter, + LoggingInterceptor, + ResponseInterceptor, + ValidationExceptionFilter, + ], + exports: [ + AllExceptionsFilter, + HttpExceptionFilter, + LoggingInterceptor, + ResponseInterceptor, + ValidationExceptionFilter, + ], +}) +export class CommonModule {} diff --git a/apps/backend/src/common/decorators/.gitkeep b/apps/backend/src/common/decorators/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/src/common/filters/all-exceptions.filter.ts b/apps/backend/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..2b083ce --- /dev/null +++ b/apps/backend/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,73 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { sanitizeUser } from '../utils/sanitize-user.util'; +import { sanitizeRequestBody } from '../utils/sanitize-request-body.util'; + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + + // Skip HttpExceptions and BadRequestExceptions as they're + // handled by specific filters + if ( + exception instanceof BadRequestException || + exception instanceof HttpException + ) { + throw exception; + } + + // Handle non-HTTP exceptions (database errors, network errors, etc.) + if (exception instanceof Error) { + message = exception.message; + // Don't expose internal error details in production + if (process.env.NODE_ENV === 'production') { + message = 'Internal server error'; + } + } + + // Sanitize user data for logging + const sanitizedUser = sanitizeUser(request.user); + const sanitizedBody = sanitizeRequestBody(request.body); + + this.logger.error( + `Unhandled Exception: ${request.method} ${request.url} - ${status} - ${message}`, + { + user: sanitizedUser, + body: sanitizedBody, + stack: exception instanceof Error ? exception.stack : undefined, + error: + exception instanceof Error ? exception.message : String(exception), + }, + ); + + return response.status(status).json({ + success: false, + path: request.url, + method: request.method, + statusCode: status, + message, + timestamp: new Date().toISOString(), + ...(process.env.NODE_ENV === 'development' && { + stack: exception instanceof Error ? exception.stack : undefined, + originalError: + exception instanceof Error ? exception.message : String(exception), + }), + }); + } +} diff --git a/apps/backend/src/common/filters/http-exception.filter.ts b/apps/backend/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..19dc067 --- /dev/null +++ b/apps/backend/src/common/filters/http-exception.filter.ts @@ -0,0 +1,46 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { sanitizeUser } from '../utils/sanitize-user.util'; +import { sanitizeRequestBody } from '../utils/sanitize-request-body.util'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + const exceptionResponse = exception.getResponse(); + + const error = + typeof exceptionResponse === 'string' + ? { message: exceptionResponse } + : (exceptionResponse as object); + + // Sanitize user data for logging + const sanitizedUser = sanitizeUser(request.user); + const sanitizedBody = sanitizeRequestBody(request.body); + + const statusCode = exception.getStatus(); + this.logger.error( + `HTTP Exception: ${request.method} ${request.url} - ${statusCode} - ${JSON.stringify(error)}`, + { user: sanitizedUser, body: sanitizedBody }, + ); + + return response.status(statusCode).json({ + success: false, + statusCode, + path: request.url, + method: request.method, + ...error, + timestamp: new Date().toISOString(), + }); + } +} diff --git a/apps/backend/src/common/filters/validation-exception.filter.ts b/apps/backend/src/common/filters/validation-exception.filter.ts new file mode 100644 index 0000000..3d6d5b3 --- /dev/null +++ b/apps/backend/src/common/filters/validation-exception.filter.ts @@ -0,0 +1,49 @@ +import { + ArgumentsHost, + BadRequestException, + Catch, + ExceptionFilter, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { sanitizeUser } from '../utils/sanitize-user.util'; +import { sanitizeRequestBody } from '../utils/sanitize-request-body.util'; + +@Catch(BadRequestException) +export class ValidationExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(ValidationExceptionFilter.name); + + catch(exception: BadRequestException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + let errors = [] as any[]; + const exceptionResponse = exception.getResponse() as any; + if (exceptionResponse.message && Array.isArray(exceptionResponse.message)) { + errors = exceptionResponse.message; + } else if (typeof exceptionResponse.message === 'string') { + errors = [exceptionResponse?.message]; + } + + // Sanitize user data for logging + const sanitizedUser = sanitizeUser(request.user); + const sanitizedBody = sanitizeRequestBody(request.body); + + this.logger.warn( + `Validation Error: ${request.method} ${request.url} - ${JSON.stringify(errors)}`, + { user: sanitizedUser, body: sanitizedBody } + ); + + const statusCode = exception.getStatus(); + return response.status(statusCode).json({ + success: false, + statusCode, + path: request.url, + method: request.method, + message: 'Validation failed', + errors, + timestamp: new Date().toISOString(), + }); + } +} diff --git a/apps/backend/src/common/interceptors/logging.interceptor.ts b/apps/backend/src/common/interceptors/logging.interceptor.ts new file mode 100644 index 0000000..4c23166 --- /dev/null +++ b/apps/backend/src/common/interceptors/logging.interceptor.ts @@ -0,0 +1,50 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { sanitizeUser } from '../utils/sanitize-user.util'; +import { sanitizeRequestBody } from '../utils/sanitize-request-body.util'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger(LoggingInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url, body, user } = request; + const now = Date.now(); + + // Sanitize sensitive data before logging + const sanitizedBody = sanitizeRequestBody(body); + const sanitizedUser = sanitizeUser(user); + + this.logger.log( + `Incoming Request: ${method} ${url}${ + Object.keys(body || {}).length + ? ` - Body: ${JSON.stringify(sanitizedBody)}` + : '' + }${sanitizedUser?.id ? ` - User: ${JSON.stringify(sanitizedUser)}` : ''}`, + ); + + return next.handle().pipe( + tap({ + next: (data) => { + const response = context.switchToHttp().getResponse(); + const delay = Date.now() - now; + const msg = `${method} ${url} - ${response.statusCode} - ${delay}ms`; + this.logger.log(`Outgoing Response: ${msg}`); + }, + error: (error) => { + const delay = Date.now() - now; + const base = `${method} ${url} - ${error.status || 500} - ${delay}ms`; + this.logger.error(`Error Response: ${base} - ${error.message}`); + }, + }), + ); + } +} diff --git a/apps/backend/src/common/interceptors/response.interceptor.ts b/apps/backend/src/common/interceptors/response.interceptor.ts new file mode 100644 index 0000000..79abbdf --- /dev/null +++ b/apps/backend/src/common/interceptors/response.interceptor.ts @@ -0,0 +1,46 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + success: boolean; + path: string; + data: T; + duration?: number; + timestamp: string; +} + +@Injectable() +export class ResponseInterceptor implements NestInterceptor> { + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + const startTime = Date.now(); + const request = context.switchToHttp().getRequest(); + return next.handle().pipe( + map((data) => { + const duration = Date.now() - startTime; + const formatResponse = (data) => ({ + success: true, + ...data, + path: request.url, + duration, + timestamp: new Date().toISOString(), + }); + // If data is already formatted (e.g., from pagination) + // return as is + return formatResponse( + data && typeof data === 'object' && 'data' in data && 'total' in data + ? data + : { data }, + ); + }), + ); + } +} diff --git a/apps/backend/src/common/pipes/.gitkeep b/apps/backend/src/common/pipes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/src/common/utils/sanitize-request-body.util.ts b/apps/backend/src/common/utils/sanitize-request-body.util.ts new file mode 100644 index 0000000..d179b41 --- /dev/null +++ b/apps/backend/src/common/utils/sanitize-request-body.util.ts @@ -0,0 +1,100 @@ +/** + * Sanitizes request body for logging purposes by masking sensitive fields + * at any depth. Handles nested objects, arrays, and circular references safely. + */ +export function sanitizeRequestBody(body: any, visited = new WeakSet()): any { + // Handle primitives and null/undefined + if (body === null || body === undefined || typeof body !== 'object') { + return body; + } + + // Prevent infinite recursion with circular references + if (visited.has(body)) { + return '[Circular Reference]'; + } + visited.add(body); + + // Handle Arrays + if (Array.isArray(body)) { + return body.map(item => sanitizeRequestBody(item, visited)); + } + + // Handle Date objects + if (body instanceof Date) { + return body.toISOString(); + } + + // Handle other special objects (RegExp, etc.) + if (body.constructor !== Object) { + return '[Object: ' + body.constructor.name + ']'; + } + + // Handle plain objects + const sanitized: any = {}; + + // All potentially sensitive field names (including common variations) + const sensitiveFields = new Set([ + 'password', + 'currentPassword', + 'newPassword', + 'oldPassword', + 'confirmPassword', + 'passwordConfirmation', + 'refreshToken', + 'jwt', + 'token', + 'accessToken', + 'idToken', + 'authToken', + 'bearerToken', + 'sessionToken', + 'csrfToken', + 'secret', + 'clientSecret', + 'apiSecret', + 'privateKey', + 'publicKey', + 'apiKey', + 'sessionId', + 'cookie', + 'cookies', + 'authorization', + 'auth', + 'credentials', + 'credit_card', + 'creditCard', + 'card_number', + 'cardNumber', + 'cvv', + 'cvc', + 'pin', + 'ssn', + 'oib', + 'social_security', + 'socialSecurity', + 'passport', + 'license', + 'driverLicense' + ]); + + for (const key in body) { + if (!body.hasOwnProperty(key)) continue; + const lowerKey = key.toLowerCase(); + // Check if field name contains sensitive keywords + const isSensitive = sensitiveFields.has(lowerKey) || + lowerKey.includes('password') || + lowerKey.includes('token') || + lowerKey.includes('secret') || + lowerKey.includes('key') || + lowerKey.includes('auth') || + lowerKey.includes('credential'); + if (isSensitive) { + sanitized[key] = '[MASKED]'; + } else { + // Recursively sanitize nested objects/arrays + sanitized[key] = sanitizeRequestBody(body[key], visited); + } + } + + return sanitized; +} diff --git a/apps/backend/src/common/utils/sanitize-user.util.ts b/apps/backend/src/common/utils/sanitize-user.util.ts new file mode 100644 index 0000000..c86bb74 --- /dev/null +++ b/apps/backend/src/common/utils/sanitize-user.util.ts @@ -0,0 +1,13 @@ +/** + * Sanitizes user data for logging purposes by only keeping the id field. + * This approach is more secure than trying to mask arbitrary sensitive fields. + */ +export function sanitizeUser(user: any): { id?: number | string } | null { + if (!user || typeof user !== 'object') { + return null; + } + + return { + id: user.id || user.sub || null, + }; +} diff --git a/apps/backend/src/config/auth.config.ts b/apps/backend/src/config/auth.config.ts new file mode 100644 index 0000000..346d2f1 --- /dev/null +++ b/apps/backend/src/config/auth.config.ts @@ -0,0 +1,47 @@ +import * as Joi from 'joi'; +import ms from 'ms'; +import { registerAs } from '@nestjs/config'; + +const env = process.env; + +export const authValidationSchema = { + AUTH_SALT_ROUNDS: Joi.number().default(10), + AUTH_JWT_ISSUER: Joi.string().default('App'), + AUTH_JWT_SECRET: Joi.string().default('auth-jwt-secret'), + AUTH_JWT_EXPIRES_IN: Joi.string().default('7d'), + AUTH_COOKIE_NAME: Joi.string().default('access_token'), +}; + +export interface JwtConfig { + issuer: string; + secret: string; + expiresInMs: number; +} + +export interface JwtCookieConfig { + name: string; + signed: boolean; + secure: boolean; + httpOnly: boolean; +} + +export interface AuthConfig { + saltRounds: number; + jwt: JwtConfig; + cookie: JwtCookieConfig; +} + +export default registerAs('auth', () => ({ + saltRounds: parseInt(env.AUTH_SALT_ROUNDS as string, 10), + jwt: { + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + expiresInMs: ms(env.AUTH_JWT_EXPIRES_IN), + }, + cookie: { + name: env.AUTH_COOKIE_NAME, + httpOnly: true, + secure: true, + signed: true, + }, +})); diff --git a/apps/backend/src/config/db.config.ts b/apps/backend/src/config/db.config.ts new file mode 100644 index 0000000..8fc8e8f --- /dev/null +++ b/apps/backend/src/config/db.config.ts @@ -0,0 +1,35 @@ +import * as Joi from 'joi'; +import yn from 'yn'; +import { registerAs } from '@nestjs/config'; + +const env = process.env; + +export const dbValidationSchema = { + DATABASE_HOST: Joi.string().default('localhost'), + DATABASE_PORT: Joi.number().default(5432), + DATABASE_NAME: Joi.string().default('app'), + DATABASE_USERNAME: Joi.string().default('dev'), + DATABASE_PASSWORD: Joi.string().default('dev'), + DATABASE_SSL: Joi.boolean().default(false), + DATABASE_LOGGING: Joi.boolean().default(false), +}; + +export interface DbConfig { + host: string; + port: number; + name: string; + username: string; + password: string; + ssl: boolean; + logging: boolean; +} + +export default registerAs('database', () => ({ + host: env.DATABASE_HOST, + port: parseInt(env.DATABASE_PORT as string, 10), + user: env.DATABASE_USERNAME, + password: env.DATABASE_PASSWORD, + dbName: env.DATABASE_NAME, + ssl: yn(env.DATABASE_SSL), + debug: yn(env.DATABASE_LOGGING), +})); diff --git a/apps/backend/src/config/general.config.ts b/apps/backend/src/config/general.config.ts new file mode 100644 index 0000000..10a5f57 --- /dev/null +++ b/apps/backend/src/config/general.config.ts @@ -0,0 +1,28 @@ +import * as Joi from 'joi'; + +const env = process.env; + +export const generalValidationSchema = { + PORT: Joi.number().default(3000), + NODE_ENV: Joi.string() + .valid('development', 'production', 'test', 'staging') + .default('development'), + CORS_ALLOWED_ORIGINS: Joi.string().default('http://localhost:3000'), +}; + +export interface GeneralConfig { + port: number; + nodeEnv: string; + isProduction: boolean; + corsAllowedOrigins: string[]; +} + +export default () => ({ + port: parseInt(env.PORT as string, 10), + nodeEnv: env.NODE_ENV, + isProduction: env.NODE_ENV === 'production', + corsAllowedOrigins: (env.CORS_ALLOWED_ORIGINS as string) + .split(',') + .filter((s) => s) + .map((s) => s.trim()), +}); diff --git a/apps/backend/src/config/index.ts b/apps/backend/src/config/index.ts new file mode 100644 index 0000000..550c889 --- /dev/null +++ b/apps/backend/src/config/index.ts @@ -0,0 +1,6 @@ +export * from './auth.config'; +export * from './db.config'; +export * from './general.config'; +export * from './mail.config'; +export * from './mikro-orm.config'; +export * from './validation'; diff --git a/apps/backend/src/config/mail.config.ts b/apps/backend/src/config/mail.config.ts new file mode 100644 index 0000000..3b713ae --- /dev/null +++ b/apps/backend/src/config/mail.config.ts @@ -0,0 +1,43 @@ +import * as Joi from 'joi'; +import yn from 'yn'; +import { registerAs } from '@nestjs/config'; + +const env = process.env; + +export const mailValidationSchema = { + MAIL_HOST: Joi.string().default('email-smtp.us-east-1.amazonaws.com'), + MAIL_PORT: Joi.number().default(null), + MAIL_USER: Joi.string().allow(''), + MAIL_PASSWORD: Joi.string().allow(''), + MAIL_SECURE: Joi.boolean().default(false), + MAIL_FROM_NAME: Joi.string().default('App'), + MAIL_FROM_EMAIL: Joi.string().email().default('noreply@example.com'), +}; + +export interface MailConfig { + host: string; + port?: number; + secure: boolean; + auth: { + user?: string; + pass?: string; + }; + from: { + name: string; + email: string; + }; +} + +export default registerAs('mail', () => ({ + host: env.MAIL_HOST, + port: parseInt(env.MAIL_PORT as string, 10), + secure: yn(env.MAIL_SECURE), + auth: { + user: env.MAIL_USER, + pass: env.MAIL_PASSWORD, + }, + from: { + name: env.MAIL_FROM_NAME || 'App Starter', + email: env.MAIL_FROM_EMAIL || 'noreply@appstarter.com', + }, +})); diff --git a/apps/backend/src/config/mikro-orm.config.ts b/apps/backend/src/config/mikro-orm.config.ts new file mode 100644 index 0000000..0a122cd --- /dev/null +++ b/apps/backend/src/config/mikro-orm.config.ts @@ -0,0 +1,44 @@ +import kebabCase from 'lodash/kebabCase'; +import { registerAs } from '@nestjs/config'; +import { MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { Migrator } from '@mikro-orm/migrations'; +import { SeedManager } from '@mikro-orm/seeder'; +import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; +import { TsMorphMetadataProvider } from '@mikro-orm/reflection'; +import { PostgreSqlDriver } from '@mikro-orm/postgresql'; + +export default registerAs('mikroORM', (): MikroOrmModuleSyncOptions => ({ + driver: PostgreSqlDriver, + entities: ['./dist/**/*.entity.js'], + entitiesTs: ['./src/**/*.entity.ts'], + highlighter: new SqlHighlighter(), + metadataProvider: TsMorphMetadataProvider, + seeder: { + fileName: (className: string) => kebabCase(className), + path: './dist/database/seeders', + pathTs: './src/database/seeders', + defaultSeeder: 'DatabaseSeeder', + glob: '!(*.d).{js,ts}', + emit: 'ts', + }, + migrations: { + fileName: (timestamp) => `${timestamp}-new-migration`, + path: './dist/database/migrations', + pathTs: './src/database/migrations', + snapshot: true, + transactional: true, + disableForeignKeys: true, + allOrNothing: true, + dropTables: true, + safe: false, + emit: 'ts', + }, + extensions: [Migrator, SeedManager], + pool: { min: 2, max: 10 }, + forceUtcTimezone: true, + discovery: { + warnWhenNoEntities: true, + requireEntitiesArray: false, + alwaysAnalyseProperties: true, + }, +})); diff --git a/apps/backend/src/config/validation.ts b/apps/backend/src/config/validation.ts new file mode 100644 index 0000000..64f56f1 --- /dev/null +++ b/apps/backend/src/config/validation.ts @@ -0,0 +1,13 @@ +import * as Joi from 'joi'; + +import { authValidationSchema } from './auth.config'; +import { dbValidationSchema } from './db.config'; +import { generalValidationSchema } from './general.config'; +import { mailValidationSchema } from './mail.config'; + +export const validationSchema = Joi.object({ + ...authValidationSchema, + ...dbValidationSchema, + ...generalValidationSchema, + ...mailValidationSchema, +}); diff --git a/apps/backend/src/database/entities/base.entity.ts b/apps/backend/src/database/entities/base.entity.ts new file mode 100644 index 0000000..53f4bdc --- /dev/null +++ b/apps/backend/src/database/entities/base.entity.ts @@ -0,0 +1,27 @@ +import { Property, PrimaryKey } from '@mikro-orm/core'; + +export abstract class BaseEntity { + @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' }) + id!: string; + + @Property({ onCreate: () => new Date() }) + createdAt: Date = new Date(); + + @Property({ onUpdate: () => new Date() }) + updatedAt: Date = new Date(); + + @Property({ nullable: true }) + deletedAt?: Date; + + softDelete(): void { + this.deletedAt = new Date(); + } + + restore(): void { + this.deletedAt = undefined; + } + + get isDeleted(): boolean { + return !!this.deletedAt; + } +} diff --git a/apps/backend/src/database/entities/index.ts b/apps/backend/src/database/entities/index.ts new file mode 100644 index 0000000..c3421a4 --- /dev/null +++ b/apps/backend/src/database/entities/index.ts @@ -0,0 +1,2 @@ +export * from './base.entity'; +export * from './user.entity'; diff --git a/apps/backend/src/database/entities/user.entity.ts b/apps/backend/src/database/entities/user.entity.ts new file mode 100644 index 0000000..25823a4 --- /dev/null +++ b/apps/backend/src/database/entities/user.entity.ts @@ -0,0 +1,103 @@ +import * as bcrypt from 'bcrypt'; +import { + BeforeCreate, + BeforeUpdate, + Entity, + Enum, + EventArgs, + Index, + Property, +} from '@mikro-orm/core'; +import { Exclude } from 'class-transformer'; +import { BaseEntity } from './base.entity'; +import { UserRepository } from '@/modules/user/user.repository'; + +export enum UserRole { + ADMIN = 'ADMIN', + USER = 'USER', +} + +@Entity({ tableName: 'users', repository: () => UserRepository }) +export class User extends BaseEntity { + @Property({ unique: true, nullable: false }) + @Index() + email!: string; + + @Property({ hidden: true, nullable: false }) + @Exclude() + password!: string; + + @Enum(() => UserRole) + role: UserRole = UserRole.USER; + + @Property({ nullable: true, length: 200 }) + firstName?: string; + + @Property({ nullable: true, length: 200 }) + lastName?: string; + + @Property({ nullable: true, type: 'text' }) + imgUrl?: string; + + @Property({ nullable: true }) + lastLoginAt?: Date; + + @Property({ persist: false }) + get isAdmin(): boolean { + return this.role === UserRole.ADMIN; + } + + @Property({ persist: false }) + get fullName(): string | null { + const { firstName, lastName } = this; + return firstName && lastName + ? `${firstName} ${lastName}` + : firstName || lastName || null; + } + + @Property({ persist: false }) + get label(): string { + return this.fullName || this.email; + } + + @Property({ persist: false }) + get profile() { + return { + id: this.id, + email: this.email, + role: this.role, + label: this.label, + imgUrl: this.imgUrl, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } + + @BeforeCreate() + @BeforeUpdate() + private async hashPassword(args: EventArgs) { + // Only hash if password is being set/changed and isn't already hashed + if (this.password && !this.password.startsWith('$2')) { + // Get saltRounds from config or use default + const saltRounds = (args.em.config.getAll() as any).saltRounds || 10; + this.password = await bcrypt.hash(this.password, saltRounds); + } + } + + @BeforeCreate() + @BeforeUpdate() + normalizeEmail() { + if (!this.email) return; + this.email = this.email.toLowerCase().trim(); + } + + async validatePassword(password: string): Promise { + if (!this.password) return false; + return bcrypt.compare(password, this.password); + } + + toJSON() { + const { password, refreshToken, ...user } = this as any; + return user; + } +} diff --git a/apps/backend/src/database/seeders/DatabaseSeeder.ts b/apps/backend/src/database/seeders/DatabaseSeeder.ts new file mode 100644 index 0000000..9604384 --- /dev/null +++ b/apps/backend/src/database/seeders/DatabaseSeeder.ts @@ -0,0 +1,41 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; +import { User, UserRole } from '../entities/user.entity'; + +export class DatabaseSeeder extends Seeder { + async run(em: EntityManager): Promise { + if (process.env.NODE_ENV === 'development') { + await em.nativeDelete(User, {}); + } + const users = [ + { + email: 'admin@example.com', + password: 'test123!', + role: UserRole.ADMIN, + firstName: 'Admin', + lastName: 'User', + }, + { + email: 'john.doe@example.com', + password: 'admin123!', + role: UserRole.USER, + firstName: 'John', + lastName: 'Doe', + }, + { + email: 'jane.smith@example.com', + password: 'test123!', + role: UserRole.USER, + firstName: 'Jane', + lastName: 'Smith', + }, + ]; + for (const userData of users) { + em.create(User, userData as any); + } + await em.flush(); + console.log('✅ Database seeded successfully'); + console.log('📧 Admin email: admin@example.com'); + console.log('🔑 Admin password: test123'); + } +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts new file mode 100644 index 0000000..74ee160 --- /dev/null +++ b/apps/backend/src/main.ts @@ -0,0 +1,87 @@ +import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { NestFactory, Reflector } from '@nestjs/core'; +import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; +import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ResponseInterceptor } from './common/interceptors/response.interceptor'; +import { ValidationExceptionFilter } from './common/filters/validation-exception.filter'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const config = app.get(ConfigService); + const reflector = app.get(Reflector); + app.use(cookieParser(config.get('auth.jwt.secret', 'cookie-secret'))); + app.use(helmet({ contentSecurityPolicy: false })); + app.enableCors({ + origin: config.get('corsAllowedOrigins'), + credentials: true, + }); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + validateCustomDecorators: true, + }), + ); + app.useGlobalInterceptors( + new ClassSerializerInterceptor(reflector, { + excludeExtraneousValues: false, + enableImplicitConversion: false, + strategy: 'exposeAll', + }), + new ResponseInterceptor(), + ); + // Exception filters (registered in reverse order of execution) + app.useGlobalFilters( + new AllExceptionsFilter(), + new HttpExceptionFilter(), + new ValidationExceptionFilter(), + ); + if (!config.get('isProduction')) { + const apiDocConfig = new DocumentBuilder() + .setTitle('API') + .setDescription('API documentation') + .setVersion('1.0') + .addBearerAuth() + .addTag('auth', 'Authentication endpoints') + .addTag('health', 'Health check endpoints') + .addTag('users', 'User management endpoints') + .build(); + const document = SwaggerModule.createDocument(app, apiDocConfig); + SwaggerModule.setup('api/docs', app, document, { + swaggerOptions: { + persistAuthorization: true, + docExpansion: 'none', + filter: true, + showRequestDuration: true, + }, + }); + } + // Serve static files and set config cookie + app.use((req, res, next) => { + if (req.path === '/' || req.path === '/index.html') { + const configCookie = JSON.stringify( + Object.fromEntries( + Object.entries(process.env).filter(([key]) => + key.startsWith('NUXT_PUBLIC_'), + ), + ), + ); + res.cookie('config', configCookie); + } + next(); + }); + + const port = config.get('port') as number; + await app.listen(port); + console.log(`🚀 Application is running on: http://localhost:${port}/api`); + console.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`); +} + +bootstrap(); diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..85d4492 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,121 @@ +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Res, +} from '@nestjs/common'; +import { + ChangePasswordDto, + ForgotPasswordDto, + LoginDto, + ResetPasswordDto, +} from './dto'; +import { CurrentUser, Public } from './decorators'; +import { AuthService } from './auth.service'; +import { Response } from 'express'; +import { ConfigService } from '@nestjs/config'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + ) {} + + @ApiOperation({ summary: 'User login' }) + @ApiBody({ type: LoginDto }) + @ApiResponse({ status: 200, description: 'User logged in successfully' }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) + @Public() + @Post('login') + async login( + @Body() loginDto: LoginDto, + @Res({ passthrough: true }) response: Response, + ) { + const { user, accessToken, expiresInMs } = + await this.authService.login(loginDto); + const cookieConfig = this.configService.get('auth.cookie'); + response.cookie(cookieConfig.name, accessToken, { + httpOnly: cookieConfig.httpOnly, + signed: cookieConfig.signed, + maxAge: expiresInMs, + secure: this.configService.get('isProduction'), + }); + return { user, accessToken, expiresIn: expiresInMs }; + } + + @Get('logout') + @ApiOperation({ summary: 'User logout' }) + @ApiResponse({ status: 200, description: 'User logged out successfully' }) + @HttpCode(HttpStatus.OK) + async logout( + @CurrentUser() user: any, + @Res({ passthrough: true }) response: Response, + ): Promise { + await this.authService.logout(user); + const cookieConfig = this.configService.get('auth.cookie'); + response.clearCookie(cookieConfig.name); + } + + @ApiOperation({ summary: 'Change current password' }) + @ApiBody({ type: ChangePasswordDto }) + @ApiResponse({ status: 200, description: 'Password changed successfully' }) + @ApiResponse({ status: 401, description: 'Current password is incorrect!' }) + @Post('change-password') + @HttpCode(HttpStatus.OK) + async changePassword( + @CurrentUser() user: any, + @Body() changePasswordDto: ChangePasswordDto, + ): Promise<{ message: string }> { + return this.authService.changePassword(user, changePasswordDto); + } + + @ApiOperation({ summary: 'Request password reset' }) + @ApiBody({ type: ForgotPasswordDto }) + @ApiResponse({ + status: 200, + description: 'Password reset email sent if user exists', + }) + @Public() + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + async forgotPassword( + @Body() forgotPasswordDto: ForgotPasswordDto, + ): Promise { + await this.authService.sendResetPasswordToken(forgotPasswordDto); + } + + @ApiOperation({ summary: 'Reset password with token' }) + @ApiBody({ type: ResetPasswordDto }) + @ApiResponse({ status: 204, description: 'Password reset successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + @Public() + @Post('reset-password') + @HttpCode(HttpStatus.NO_CONTENT) + async resetPassword( + @Body() resetPasswordDto: ResetPasswordDto, + ): Promise { + await this.authService.resetPassword(resetPasswordDto); + } + + @ApiOperation({ summary: 'Validate reset token' }) + @ApiResponse({ status: 202, description: 'Token is valid' }) + @Public() + @Post('reset-password/token-status') + @HttpCode(HttpStatus.ACCEPTED) + async validateResetToken(@Body('token') token: string): Promise { + await this.authService.validateResetPasswordToken(token); + } + + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'Current user profile' }) + @Get('me') + async getProfile(@CurrentUser() user: any) { + return { user: user.toJSON() }; + } +} diff --git a/apps/backend/src/modules/auth/auth.module.ts b/apps/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..7022540 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,44 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtAuthGuard, RolesGuard } from './guards'; +import { JwtStrategy, LocalStrategy } from './strategies'; +import { forwardRef, Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtModule } from '@nestjs/jwt'; +import { MailModule } from '../mail/mail.module'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import ms from 'ms'; +import { PassportModule } from '@nestjs/passport'; +import { User } from '@/database/entities'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [ + forwardRef(() => UserModule), + MailModule, + MikroOrmModule.forFeature([User]), + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('auth.jwt.secret'), + signOptions: { + expiresIn: ms(configService.get('auth.jwt.expiresInMs')), + }, + }), + inject: [ConfigService], + }), + ], + controllers: [AuthController], + providers: [ + AuthService, + LocalStrategy, + JwtStrategy, + // All routes protected by default & global role-based access control + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..6cbd8fc --- /dev/null +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,171 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { + ChangePasswordDto, + ForgotPasswordDto, + LoginDto, + ResetPasswordDto, +} from './dto'; +import * as bcrypt from 'bcrypt'; +import type { AuthConfig } from '@/config'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { MailService } from '@/modules/mail/mail.service'; +import { SqlEntityManager } from '@mikro-orm/postgresql'; +import { User } from '@/database/entities'; +import { UserRepository } from '@/modules/user/user.repository'; +import { JwtPayload } from './strategies/jwt.strategy'; +import ms from 'ms'; + +export enum Audience { + ACCESS = 'scope:access', + INVITATION = 'scope:invitation', + RESET = 'scope:reset', +} + +@Injectable() +export class AuthService { + private config: AuthConfig; + + constructor( + @InjectRepository(User) + private readonly userRepository: UserRepository, + private em: SqlEntityManager, + private configService: ConfigService, + private mailService: MailService, + private jwtService: JwtService, + ) { + this.config = configService.get('auth') as AuthConfig; + } + + async login({ email, password }: LoginDto) { + const user = await this.validateCredentials(email, password); + if (!user) throw new UnauthorizedException('Invalid credentials'); + const { accessToken, expiresInMs } = await this.createAccessToken(user); + return { user: user.toJSON(), accessToken, expiresInMs }; + } + + async logout(user: User): Promise { + // TODO: Figure out better name for this field + user.lastLoginAt = new Date(); + await this.em.flush(); + } + + async validateCredentials(email: string, pw: string): Promise { + const user = await this.userRepository.findByEmail(email); + if (!user || user.isDeleted) return null; + return (await user.validatePassword(pw)) ? user : null; + } + + async changePassword( + user: User, + { currentPassword, newPassword }: ChangePasswordDto, + ): Promise<{ message: string }> { + const isValid = await user.validatePassword(currentPassword); + if (!isValid) throw new UnauthorizedException('Password is incorrect'); + user.password = await this.hashPassword(newPassword); + await this.em.flush(); + return { message: 'Password has been changed successfully' }; + } + + async resetPassword({ token, newPassword }: ResetPasswordDto): Promise { + const user = await this.validateResetPasswordToken(token); + user.password = await this.hashPassword(newPassword); + await this.em.flush(); + } + + async validateResetPasswordToken(token: string): Promise { + try { + const payload: JwtPayload = this.jwtService.decode(token); + if (!payload?.sub || payload.aud !== Audience.RESET) + throw new BadRequestException('Invalid reset token'); + + const user = await this.userRepository.get(payload.sub); + if (!user || user.isDeleted) + throw new BadRequestException('Invalid reset token'); + + const secret = this.getTokenSecret(user); + await this.jwtService.verify(token, { secret }); + return user; + } catch (error) { + throw new BadRequestException('Invalid or expired reset token'); + } + } + + async sendResetPasswordToken({ email }: ForgotPasswordDto): Promise { + const user = await this.userRepository.findByEmail(email); + if (!user || user.isDeleted) return; + const token = await this.createToken(user, Audience.RESET, { + secret: this.getTokenSecret(user), + expiresInMs: ms('1h'), + }); + try { + await this.mailService.sendPasswordResetEmail(user, token); + } catch (err) { + console.error('Failed to send password reset email:', err); + } + } + + async sendInvitationEmail(user: User): Promise { + const token = await this.createInvitationToken(user); + return this.mailService.sendInvitationEmail(user, token); + } + + private async createAccessToken(user: User) { + user.lastLoginAt = new Date(); + const { expiresInMs } = this.config.jwt; + const accessToken = await this.createToken(user, Audience.ACCESS, { + expiresInMs, + }); + await this.em.flush(); + return { + user, + accessToken, + expiresInMs, + }; + } + + private async createInvitationToken(user: User): Promise { + const secret = this.getTokenSecret(user); + return this.createToken(user, Audience.INVITATION, { + secret, + expiresInMs: ms('7d'), + }); + } + + private async createToken( + user: User, + audience: string, + { secret, expiresInMs }: { secret?: string; expiresInMs?: number } = {}, + ): Promise { + const config = this.config.jwt; + expiresInMs = expiresInMs || config.expiresInMs; + const payload: JwtPayload = { + sub: user.id, + email: user.email, + role: user.role, + iat: Date.now(), + }; + return this.jwtService.signAsync(payload, { + secret: secret || config.secret, + expiresIn: ms(expiresInMs), + audience, + issuer: config.issuer, + }); + } + + // Use a combination of JWT secret, user pw hash, and creation time + // This ensures the token becomes invalid if the password is changed + private getTokenSecret(user: User): string { + const baseSecret = this.config.jwt.secret; + return `${baseSecret}-${user.password}-${user.createdAt.getTime()}`; + } + + private async hashPassword(password: string): Promise { + return bcrypt.hash(password, this.config.saltRounds); + } +} diff --git a/apps/backend/src/modules/auth/decorators/current-user.decorator.ts b/apps/backend/src/modules/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..4062449 --- /dev/null +++ b/apps/backend/src/modules/auth/decorators/current-user.decorator.ts @@ -0,0 +1,24 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +/** + * Custom parameter decorator to extract the current user from the request. + * + * @param dataKey - Optional key to extract a specific property from the user object + * @returns The user object or a specific property if dataKey is provided + * + * @example + * async getProfile(@CurrentUser() user: any) { } + * Get a specific property + * async getEmail(@CurrentUser('email') email: string) { } + * Note: Do not use User entity as the type annotation, as it will + * cause serialization issues. + */ +export const CurrentUser = createParamDecorator( + (dataKey: string | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + if (!user) return null; + if (dataKey) return user[dataKey]; + return user; + }, +); diff --git a/apps/backend/src/modules/auth/decorators/index.ts b/apps/backend/src/modules/auth/decorators/index.ts new file mode 100644 index 0000000..e84af29 --- /dev/null +++ b/apps/backend/src/modules/auth/decorators/index.ts @@ -0,0 +1,3 @@ +export * from './current-user.decorator'; +export * from './public.decorator'; +export * from './roles.decorator'; \ No newline at end of file diff --git a/apps/backend/src/modules/auth/decorators/public.decorator.ts b/apps/backend/src/modules/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..a7b2022 --- /dev/null +++ b/apps/backend/src/modules/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_FLAG_KEY = 'isPublicRoute'; +export const Public = () => SetMetadata(IS_PUBLIC_FLAG_KEY, true); diff --git a/apps/backend/src/modules/auth/decorators/roles.decorator.ts b/apps/backend/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..ddf01e2 --- /dev/null +++ b/apps/backend/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '@/database/entities'; + +export const ALLOWED_ROLES_KEY = 'allowedUserRoles'; +export const Roles = (...roles: UserRole[]) => + SetMetadata(ALLOWED_ROLES_KEY, roles); diff --git a/apps/backend/src/modules/auth/dto/index.ts b/apps/backend/src/modules/auth/dto/index.ts new file mode 100644 index 0000000..8dd2e3d --- /dev/null +++ b/apps/backend/src/modules/auth/dto/index.ts @@ -0,0 +1,2 @@ +export * from './login.dto'; +export * from './reset-password.dto'; diff --git a/apps/backend/src/modules/auth/dto/login.dto.ts b/apps/backend/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..6cdb306 --- /dev/null +++ b/apps/backend/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class LoginDto { + @ApiProperty({ + description: 'User email address', + example: 'user@example.com', + }) + @IsEmail() + @IsNotEmpty() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; + + @ApiProperty({ + description: 'User password', + example: 'password123', + minLength: 8, + }) + @IsString() + @IsNotEmpty() + @MinLength(8) + password: string; +} diff --git a/apps/backend/src/modules/auth/dto/reset-password.dto.ts b/apps/backend/src/modules/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..adfb0b4 --- /dev/null +++ b/apps/backend/src/modules/auth/dto/reset-password.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEmail, + IsJWT, + IsNotEmpty, + IsString, + Matches, + MinLength, +} from 'class-validator'; +import { oneLine } from 'common-tags'; +import { Transform } from 'class-transformer'; + +const PW_REGEX = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/; + + const PW_REGEX_MESSAGE = oneLine` + Password must contain at least one + uppercase letter, one lowercase letter, one number, + and one special character`; + +export class ForgotPasswordDto { + @ApiProperty({ + description: 'Email address to send reset link to', + example: 'user@example.com', + }) + @IsEmail() + @IsNotEmpty() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; +} + +export class ResetPasswordDto { + @ApiProperty({ + description: 'Password reset token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsJWT() + @IsNotEmpty() + token: string; + + @ApiProperty({ + description: 'New password', + example: 'NewSecureP@ssw0rd', + minLength: 8, + }) + @IsString() + @IsNotEmpty() + @MinLength(8) + @Matches(PW_REGEX, { message: PW_REGEX_MESSAGE }) + newPassword: string; +} + +export class ChangePasswordDto { + @ApiProperty({ + description: 'Current password', + example: 'OldPassword123', + }) + @IsString() + @IsNotEmpty() + currentPassword: string; + + @ApiProperty({ + description: 'New password', + minLength: 8, + example: 'NewSecureP@ssw0rd', + }) + @IsString() + @IsNotEmpty() + @MinLength(8) + @Matches(PW_REGEX, { message: PW_REGEX_MESSAGE }) + newPassword: string; +} diff --git a/apps/backend/src/modules/auth/guards/index.ts b/apps/backend/src/modules/auth/guards/index.ts new file mode 100644 index 0000000..63b8cec --- /dev/null +++ b/apps/backend/src/modules/auth/guards/index.ts @@ -0,0 +1,3 @@ +export * from './jwt-auth.guard'; +export * from './local-auth.guard'; +export * from './roles.guard'; diff --git a/apps/backend/src/modules/auth/guards/jwt-auth.guard.ts b/apps/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2172b11 --- /dev/null +++ b/apps/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,30 @@ +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_FLAG_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride( + IS_PUBLIC_FLAG_KEY, + [context.getHandler(), context.getClass()], + ); + if (isPublic) return true; + return super.canActivate(context); + } + + handleRequest(err: any, user: any, _info: any) { + if (err || !user) + throw err || new UnauthorizedException('Authentication required'); + return user; + } +} diff --git a/apps/backend/src/modules/auth/guards/local-auth.guard.ts b/apps/backend/src/modules/auth/guards/local-auth.guard.ts new file mode 100644 index 0000000..fb638ae --- /dev/null +++ b/apps/backend/src/modules/auth/guards/local-auth.guard.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +// Used only in AuthController for login route +@Injectable() +export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/apps/backend/src/modules/auth/guards/roles.guard.ts b/apps/backend/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..d01866d --- /dev/null +++ b/apps/backend/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,20 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ALLOWED_ROLES_KEY } from '../decorators/roles.decorator'; +import { UserRole } from '@/database/entities'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const allowedRoles = this.reflector.getAllAndOverride( + ALLOWED_ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + if (!allowedRoles) return true; + const { user } = context.switchToHttp().getRequest(); + if (!user) return false; + return allowedRoles.some((role) => user.role === role); + } +} diff --git a/apps/backend/src/modules/auth/strategies/index.ts b/apps/backend/src/modules/auth/strategies/index.ts new file mode 100644 index 0000000..e8ecb68 --- /dev/null +++ b/apps/backend/src/modules/auth/strategies/index.ts @@ -0,0 +1,2 @@ +export * from './local.strategy'; +export * from './jwt.strategy'; diff --git a/apps/backend/src/modules/auth/strategies/jwt.strategy.ts b/apps/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..05b4423 --- /dev/null +++ b/apps/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,50 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { EntityRepository } from '@mikro-orm/postgresql'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { PassportStrategy } from '@nestjs/passport'; +import { Request } from 'express'; +import { User } from '@/database/entities'; + +export interface JwtPayload { + sub: string; + email: string; + role?: string; + iss?: string; + aud?: string; + iat?: number; + exp?: number; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private configService: ConfigService, + @InjectRepository(User) + private userRepository: EntityRepository, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (req: Request) => { + const { cookies, signedCookies } = req || {}; + return cookies?.access_token || signedCookies?.access_token || null; + }, + ExtractJwt.fromAuthHeaderAsBearerToken(), + ]), + ignoreExpiration: false, + secretOrKey: configService.get('auth.jwt.secret'), + }); + } + + async validate({ sub, email }: JwtPayload): Promise { + const user = await this.userRepository.findOne( + { id: sub, email }, + { populate: false, refresh: true} + ); + if (!user) throw new UnauthorizedException('User not found'); + if (user.isDeleted) + throw new UnauthorizedException('User account has been deactivated'); + return user; + } +} diff --git a/apps/backend/src/modules/auth/strategies/local.strategy.ts b/apps/backend/src/modules/auth/strategies/local.strategy.ts new file mode 100644 index 0000000..f146332 --- /dev/null +++ b/apps/backend/src/modules/auth/strategies/local.strategy.ts @@ -0,0 +1,18 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { AuthService } from '../auth.service'; +import { User } from '@/database/entities'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor(private authService: AuthService) { + super({ usernameField: 'email', passwordField: 'password' }); + } + + async validate(email: string, password: string): Promise { + const user = await this.authService.validateCredentials(email, password); + if (!user) throw new UnauthorizedException('Invalid credentials'); + return user; + } +} diff --git a/apps/backend/src/modules/health/health.controller.ts b/apps/backend/src/modules/health/health.controller.ts new file mode 100644 index 0000000..b6df138 --- /dev/null +++ b/apps/backend/src/modules/health/health.controller.ts @@ -0,0 +1,49 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { Public } from '@/modules/auth/decorators'; + +@ApiTags('health') +@Controller() +export class HealthController { + constructor(private readonly em: EntityManager) {} + + @ApiOperation({ summary: 'Basic health check' }) + @ApiResponse({ status: 200, description: 'Service is healthy' }) + @Public() + @Get('healthcheck') + healthCheck() { + return { status: 'ok', timestamp: new Date().toISOString() }; + } + + @ApiOperation({ summary: 'Liveness probe' }) + @ApiResponse({ status: 200, description: 'Service is alive' }) + @Public() + @Get('health/live') + liveness() { + return { status: 'alive', timestamp: new Date().toISOString() }; + } + + @ApiOperation({ summary: 'Readiness probe' }) + @ApiResponse({ status: 200, description: 'Service is ready' }) + @ApiResponse({ status: 503, description: 'Service is not ready' }) + @Public() + @Get('health/ready') + async readiness() { + try { + await this.em.execute('SELECT 1'); + return { + status: 'ready', + services: { database: 'connected' }, + timestamp: new Date().toISOString(), + }; + } catch (error) { + throw new ServiceUnavailableException({ + status: 'not ready', + services: { database: 'disconnected' }, + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown db error', + }); + } + } +} diff --git a/apps/backend/src/modules/health/health.module.ts b/apps/backend/src/modules/health/health.module.ts new file mode 100644 index 0000000..e161404 --- /dev/null +++ b/apps/backend/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} \ No newline at end of file diff --git a/apps/backend/src/modules/mail/mail.module.ts b/apps/backend/src/modules/mail/mail.module.ts new file mode 100644 index 0000000..251288f --- /dev/null +++ b/apps/backend/src/modules/mail/mail.module.ts @@ -0,0 +1,10 @@ +import { Module, Global } from '@nestjs/common'; +import { MailService } from './mail.service'; +import { TemplateService } from './template.service'; + +@Global() +@Module({ + providers: [MailService, TemplateService], + exports: [MailService], +}) +export class MailModule {} diff --git a/apps/backend/src/modules/mail/mail.service.ts b/apps/backend/src/modules/mail/mail.service.ts new file mode 100644 index 0000000..d0add24 --- /dev/null +++ b/apps/backend/src/modules/mail/mail.service.ts @@ -0,0 +1,133 @@ +import * as nodemailer from 'nodemailer'; +import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; +import { MailConfig } from '@/config/mail.config'; +import { TemplateService } from './template.service'; +import { User } from '@/database/entities'; + +@Injectable() +export class MailService { + private transporter: nodemailer.Transporter; + private fromName: string; + private fromEmail: string; + + constructor( + private configService: ConfigService, + private readonly templateService: TemplateService, + ) { + const mailConfig = this.configService.get('mail') as MailConfig; + this.fromName = mailConfig.from.name; + this.fromEmail = mailConfig.from.email; + this.transporter = nodemailer.createTransport({ + host: mailConfig.host, + port: mailConfig.port, + secure: mailConfig.secure, + auth: mailConfig.auth.user ? mailConfig.auth : undefined, + }); + } + + async sendPasswordResetEmail(user: User, token: string): Promise { + const resetUrl = `${this.configService.get('FRONTEND_URL')}/auth/reset-password/${token}`; + const variables = { + resetUrl, + title: 'Password Reset Request', + headerIcon: '🔐', + headerTitle: 'Password Reset Request', + headerGradientStart: '#667eea', + headerGradientEnd: '#764ba2', + buttonColor: '#007bff', + firstName: user.firstName || 'User', + fromName: this.fromName, + footerNote: + 'This is an automated message. Please do not reply to this email.', + }; + + const html = await this.templateService.renderTemplate( + 'password-reset.html', + variables, + ); + const text = await this.templateService.renderTemplate( + 'password-reset', + variables, + ); + + const mailOptions = { + from: `"${this.fromName}" <${this.fromEmail}>`, + to: user.email, + subject: 'Password Reset Request', + html, + text, + }; + await this.transporter.sendMail(mailOptions); + } + + async sendInvitationEmail(user: User, token: string): Promise { + const inviteUrl = `${this.configService.get('FRONTEND_URL')}/auth/setup/${token}`; + const variables = { + inviteUrl, + title: "You're Invited!", + headerIcon: '🎉', + headerTitle: 'Welcome!', + headerGradientStart: '#28a745', + headerGradientEnd: '#20c997', + buttonColor: '#28a745', + firstName: user.firstName || 'there', + fromName: this.fromName, + footerNote: + 'This invitation was sent to you by an administrator of our platform.', + }; + + const html = await this.templateService.renderTemplate( + 'invitation.html', + variables, + ); + const text = await this.templateService.renderTemplate( + 'invitation', + variables, + ); + + const mailOptions = { + from: `"${this.fromName}" <${this.fromEmail}>`, + to: user.email, + subject: 'You have been invited to join', + html, + text, + }; + + await this.transporter.sendMail(mailOptions); + } + + async sendWelcomeEmail(user: User): Promise { + const variables = { + title: 'Welcome to Our Platform!', + headerIcon: '🎉', + headerTitle: `Welcome ${user.firstName || 'aboard'}!`, + headerGradientStart: '#667eea', + headerGradientEnd: '#764ba2', + buttonColor: '#667eea', + firstName: user.firstName || 'aboard', + fromName: this.fromName, + footerNote: + "You're receiving this email because account was created on our platform.", + }; + + const html = await this.templateService.renderTemplate( + 'welcome.html', + variables, + ); + const text = await this.templateService.renderTemplate( + 'welcome', + variables, + ); + + const mailOptions = { + from: `"${this.fromName}" <${this.fromEmail}>`, + to: user.email, + subject: 'Welcome to our platform!', + html, + text, + }; + + await this.transporter.sendMail(mailOptions); + } +} diff --git a/apps/backend/src/modules/mail/template.service.ts b/apps/backend/src/modules/mail/template.service.ts new file mode 100644 index 0000000..f083a69 --- /dev/null +++ b/apps/backend/src/modules/mail/template.service.ts @@ -0,0 +1,98 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import * as Handlebars from 'handlebars'; + +interface TemplateVars { + [key: string]: any; +} + +@Injectable() +export class TemplateService { + private readonly logger = new Logger(TemplateService.name); + private handlebars: typeof Handlebars; + private templateCache = new Map(); + + constructor() { + this.handlebars = Handlebars.create(); + } + + async renderTemplate(name: string, vars: TemplateVars): Promise { + if ( + !this.handlebars.partials || + Object.keys(this.handlebars.partials).length === 0 + ) { + await this.registerPartials(); + } + // Check if this is a layout-based template + if (name.includes('.html')) + return this.renderHTML(name.replace('.html', ''), vars); + // For text templates, render directly from content directory + const templatePath = `content/${name}.template.txt`; + const template = await this.getCompliedTemplate(templatePath); + return template(vars); + } + + private async renderHTML(name: string, vars: TemplateVars): Promise { + const templatePath = `content/${name}.template.html`; + const contentTemplate = await this.getCompliedTemplate(templatePath); + const content = contentTemplate(vars); + // Load the base layout and render with content + const layout = await this.getCompliedTemplate('layouts/base.template.html'); + const layoutVariables = { ...vars, content }; + return layout(layoutVariables); + } + + private async getCompliedTemplate( + name: string, + ): Promise { + if (this.templateCache.has(name)) return this.templateCache.get(name)!; + try { + const templateContent = await this.loadTemplateFile(name); + const compiledTemplate = this.handlebars.compile(templateContent); + this.templateCache.set(name, compiledTemplate); + this.logger.debug(`Template compiled and cached: ${name}`); + return compiledTemplate; + } catch (error) { + this.logger.error(`Failed to compile template ${name}:`, error); + throw new Error(`Failed to compile template ${name}: ${error.message}`); + } + } + + // Load template file from filesystem + private async loadTemplateFile(templateName: string): Promise { + // In dev: __dirname is src/modules/mail + // In prod: __dirname is dist/modules/mail (templates are copied there by + // nest-cli.json assets config) + const templatePath = join(__dirname, 'templates', templateName); + try { + const content = await fs.readFile(templatePath, 'utf-8'); + this.logger.debug(`Template loaded from: ${templatePath}`); + return content; + } catch (err) { + throw new Error( + `Template file not found: ${templateName} at ${templatePath}. + Ensure nest-cli.json includes templates in assets.`, + ); + } + } + + // Register partials from the partials directory + private async registerPartials(): Promise { + const partialsDir = join(__dirname, 'templates', 'partials'); + try { + const files = await fs.readdir(partialsDir); + for (const file of files) { + if (file.endsWith('.template.html')) { + const partialName = file.replace('.template.html', ''); + const partialPath = join(partialsDir, file); + const partialContent = await fs.readFile(partialPath, 'utf-8'); + this.handlebars.registerPartial(partialName, partialContent); + this.logger.debug(`Registered partial: ${partialName}`); + } + } + } catch (error) { + this.logger.warn(`Could not load partials: ${error.message}`); + } + } +} diff --git a/apps/backend/src/modules/mail/templates/content/invitation.template.html b/apps/backend/src/modules/mail/templates/content/invitation.template.html new file mode 100644 index 0000000..369e9b8 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/content/invitation.template.html @@ -0,0 +1,25 @@ +

Hello {{firstName}},

+ +

+ You have been invited to join our platform. We're excited to have you as part + of our community! +

+ +

Click the button below to set up your account and get started:

+ + + +

Or copy and paste this link in your browser:

+
{{inviteUrl}}
+ +
+ 📅 Note: This invitation link will expire in 7 days. Please + set up your account before then. +
+ +

+ If you have any questions or need assistance, feel free to reach out to our + support team. +

diff --git a/apps/backend/src/modules/mail/templates/content/invitation.template.txt b/apps/backend/src/modules/mail/templates/content/invitation.template.txt new file mode 100644 index 0000000..3fb662c --- /dev/null +++ b/apps/backend/src/modules/mail/templates/content/invitation.template.txt @@ -0,0 +1,20 @@ +Welcome! +======== + +Hello {{firstName}}, + +You have been invited to join our platform. We're excited to have you as part of our community! + +Click the link below to set up your account and get started: + +{{inviteUrl}} + +📅 Note: This invitation link will expire in 7 days. Please set up your account before then. + +If you have any questions or need assistance, feel free to reach out to our support team. + +Best regards, +{{fromName}} + +--- +This invitation was sent to you by an administrator of our platform. diff --git a/apps/backend/src/modules/mail/templates/content/password-reset.template.html b/apps/backend/src/modules/mail/templates/content/password-reset.template.html new file mode 100644 index 0000000..5da7633 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/content/password-reset.template.html @@ -0,0 +1,29 @@ +

Hello {{firstName}},

+ +

+ You requested to reset your password. Click the button below to proceed with + resetting your password: +

+ + + +

Or copy and paste this link in your browser:

+
{{resetUrl}}
+ +
+ ⏰ Important: This link will expire in 60 mins for security + reasons. +
+ +

+ If you didn't request this password reset, please ignore this email. Your + password will remain unchanged. +

+ +

For security reasons, we recommend:

+
    +
  • Using a strong, unique password
  • +
  • Not sharing your password with anyone
  • +
diff --git a/apps/backend/src/modules/mail/templates/content/password-reset.template.txt b/apps/backend/src/modules/mail/templates/content/password-reset.template.txt new file mode 100644 index 0000000..6a5ed32 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/content/password-reset.template.txt @@ -0,0 +1,22 @@ +Password Reset Request +====================== + +Hello {{firstName}}, + +You requested to reset your password. Visit the link below to reset it: + +{{resetUrl}} + +⏰ Important: This link will expire in 2 hours for security reasons. + +If you didn't request this password reset, please ignore this email. Your password will remain unchanged. + +For security reasons, we recommend: +• Using a strong, unique password +• Not sharing your password with anyone + +Best regards, +{{fromName}} + +--- +This is an automated message. Please do not reply to this email. diff --git a/apps/backend/src/modules/mail/templates/content/welcome.template.html b/apps/backend/src/modules/mail/templates/content/welcome.template.html new file mode 100644 index 0000000..974b439 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/content/welcome.template.html @@ -0,0 +1,25 @@ +
Your account has been successfully created!
+ +

+ We're thrilled to have you on board. You can now log in and start exploring + all the features our platform has to offer. +

+ +
+

Getting Started

+

+ Here are a few things you can do to get the most out of your experience: +

+
    +
  • Complete your profile
  • +
  • Explore the dashboard
  • +
+
+ +

+ If you have any questions or need assistance, our support team is here to + help! +

diff --git a/apps/backend/src/modules/mail/templates/content/welcome.template.txt b/apps/backend/src/modules/mail/templates/content/welcome.template.txt new file mode 100644 index 0000000..f7f39a4 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/content/welcome.template.txt @@ -0,0 +1,20 @@ +Welcome {{firstName}}! +====================== + +Your account has been successfully created! + +We're thrilled to have you on board. You can now log in and start exploring all the features our platform has to offer. + +Getting Started +--------------- +Here are a few things you can do to get the most out of your experience: +• Complete your profile +• Explore the dashboard + +If you have any questions or need assistance, our support team is here to help! + +Best regards, +{{fromName}} + +--- +You're receiving this email because you recently created an account on our platform. diff --git a/apps/backend/src/modules/mail/templates/layouts/base.template.html b/apps/backend/src/modules/mail/templates/layouts/base.template.html new file mode 100644 index 0000000..bd24ad2 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/layouts/base.template.html @@ -0,0 +1,16 @@ + + + + + + {{title}} + {{> styles}} + + +
+ {{> header}} +
{{content}}
+ {{> footer}} +
+ + diff --git a/apps/backend/src/modules/mail/templates/partials/footer.template.html b/apps/backend/src/modules/mail/templates/partials/footer.template.html new file mode 100644 index 0000000..3d0923b --- /dev/null +++ b/apps/backend/src/modules/mail/templates/partials/footer.template.html @@ -0,0 +1,12 @@ + diff --git a/apps/backend/src/modules/mail/templates/partials/header.template.html b/apps/backend/src/modules/mail/templates/partials/header.template.html new file mode 100644 index 0000000..5ddae16 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/partials/header.template.html @@ -0,0 +1,6 @@ +
+

{{headerIcon}} {{headerTitle}}

+ {{#if headerSubtitle}} +

{{headerSubtitle}}

+ {{/if}} +
diff --git a/apps/backend/src/modules/mail/templates/partials/styles.template.html b/apps/backend/src/modules/mail/templates/partials/styles.template.html new file mode 100644 index 0000000..664e3a9 --- /dev/null +++ b/apps/backend/src/modules/mail/templates/partials/styles.template.html @@ -0,0 +1,112 @@ + diff --git a/apps/backend/src/modules/user/dto/create-user.dto.ts b/apps/backend/src/modules/user/dto/create-user.dto.ts new file mode 100644 index 0000000..f34d03c --- /dev/null +++ b/apps/backend/src/modules/user/dto/create-user.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsNotEmpty, + IsString, + MinLength, + MaxLength, + IsOptional, + IsEnum, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { UserRole } from '@/database/entities'; + +export class CreateUserDto { + @ApiProperty({ + description: 'User email address', + example: 'user@example.com', + }) + @IsEmail() + @IsNotEmpty() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; + + @ApiPropertyOptional({ + description: 'User first name', + example: 'John', + }) + @IsString() + @IsOptional() + @MinLength(2) + @MaxLength(50) + firstName?: string; + + @ApiPropertyOptional({ + description: 'User last name', + example: 'Doe', + }) + @IsString() + @IsOptional() + @MinLength(2) + @MaxLength(50) + lastName?: string; + + @ApiPropertyOptional({ + description: 'User role', + enum: UserRole, + example: UserRole.USER, + }) + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; +} diff --git a/apps/backend/src/modules/user/dto/index.ts b/apps/backend/src/modules/user/dto/index.ts new file mode 100644 index 0000000..873e1d2 --- /dev/null +++ b/apps/backend/src/modules/user/dto/index.ts @@ -0,0 +1,4 @@ +export * from './create-user.dto'; +export * from './update-user.dto'; +export * from './query-user.dto'; +export * from './user-response.dto'; \ No newline at end of file diff --git a/apps/backend/src/modules/user/dto/query-user.dto.ts b/apps/backend/src/modules/user/dto/query-user.dto.ts new file mode 100644 index 0000000..be63799 --- /dev/null +++ b/apps/backend/src/modules/user/dto/query-user.dto.ts @@ -0,0 +1,89 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsOptional, + IsString, + IsEnum, + IsInt, + Min, + Max, + IsBoolean +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { UserRole } from '@/database/entities'; + +export enum UserSortField { + ID = 'id', + EMAIL = 'email', + FIRST_NAME = 'firstName', + LAST_NAME = 'lastName', + CREATED_AT = 'createdAt', + UPDATED_AT = 'updatedAt', +} + +export enum SortOrder { + ASC = 'ASC', + DESC = 'DESC', +} + +export class QueryUserDto { + @ApiPropertyOptional({ + description: 'Search term for email, first name, or last name', + example: 'john', + }) + @IsString() + @IsOptional() + @Transform(({ value }) => value?.trim()) + search?: string; + + @ApiPropertyOptional({ + description: 'Include soft-deleted users', + example: false, + default: false, + }) + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + includeArchived?: boolean; + + @ApiPropertyOptional({ + description: 'Page number', + minimum: 1, + default: 1, + }) + @IsInt() + @Type(() => Number) + @Min(1) + @IsOptional() + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Number of items per page', + minimum: 1, + maximum: 100, + default: 20, + }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + @IsOptional() + limit?: number = 20; + + @ApiPropertyOptional({ + description: 'Field to sort by', + enum: UserSortField, + default: UserSortField.CREATED_AT, + }) + @IsEnum(UserSortField) + @IsOptional() + sortBy?: UserSortField = UserSortField.CREATED_AT; + + @ApiPropertyOptional({ + description: 'Sort order', + default: SortOrder.DESC, + enum: SortOrder, + }) + @IsEnum(SortOrder) + @IsOptional() + sortOrder?: SortOrder = SortOrder.DESC; +} diff --git a/apps/backend/src/modules/user/dto/update-user.dto.ts b/apps/backend/src/modules/user/dto/update-user.dto.ts new file mode 100644 index 0000000..7b940a7 --- /dev/null +++ b/apps/backend/src/modules/user/dto/update-user.dto.ts @@ -0,0 +1,66 @@ +import { ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger'; +import { + IsOptional, + IsString, + MinLength, + MaxLength, + IsEnum, + IsUrl, + IsEmail, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { UserRole } from '@/database/entities'; + +export class UpdateUserDto { + @ApiPropertyOptional({ + description: 'User email address', + example: 'user@example.com', + }) + @IsEmail() + @IsOptional() + @Transform(({ value }) => value?.toLowerCase().trim()) + email?: string; + + @ApiPropertyOptional({ + description: 'User first name', + example: 'John', + }) + @IsString() + @IsOptional() + @MinLength(2) + @MaxLength(50) + firstName?: string; + + @ApiPropertyOptional({ + description: 'User last name', + example: 'Doe', + }) + @IsString() + @IsOptional() + @MinLength(2) + @MaxLength(50) + lastName?: string; + + @ApiPropertyOptional({ + description: 'User avatar base64 image URL', + example: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=', + }) + @IsUrl() + @IsOptional() + imgUrl?: string; + + @ApiPropertyOptional({ + description: 'User role (admin only)', + enum: UserRole, + example: UserRole.USER, + }) + @IsEnum(UserRole) + @IsOptional() + role?: UserRole; +} + +export class UpdateProfileDto extends OmitType(UpdateUserDto, [ + 'role', + 'email', +] as const) {} diff --git a/apps/backend/src/modules/user/dto/user-response.dto.ts b/apps/backend/src/modules/user/dto/user-response.dto.ts new file mode 100644 index 0000000..3874e21 --- /dev/null +++ b/apps/backend/src/modules/user/dto/user-response.dto.ts @@ -0,0 +1,74 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { UserRole } from '@/database/entities'; + +@Exclude() +export class UserDto { + @Expose() + @ApiProperty() + id: number; + + @Expose() + @ApiProperty() + email: string; + + @Expose() + @ApiProperty({ enum: UserRole }) + role: UserRole; + + @Expose() + @ApiProperty() + firstName?: string; + + @Expose() + @ApiProperty() + lastName?: string; + + @Expose() + @ApiProperty() + fullName?: string; + + @Expose() + @ApiProperty() + label?: string; + + @Expose() + @ApiProperty() + imgUrl?: string; + + @Expose() + @ApiProperty() + createdAt: Date; + + @Expose() + @ApiProperty() + updatedAt: Date; + + @Expose() + @ApiProperty() + deletedAt?: Date; +} + +export class PaginatedUsersDto { + @ApiProperty({ type: [UserDto] }) + @Type(() => UserDto) + data: UserDto[]; + + @ApiProperty({ description: 'Total number of items', example: 100 }) + total: number; + + @ApiProperty({ description: 'Number of items per page', example: 20 }) + limit: number; + + @ApiProperty({ description: 'Current page number', example: 1 }) + page: number; + + @ApiProperty({ description: 'Total number of pages', example: 5 }) + totalPages: number; + + @ApiProperty({ description: 'Has previous page', example: false }) + hasPrevious: boolean; + + @ApiProperty({ description: 'Has next page', example: true}) + hasNext: boolean; +} diff --git a/apps/backend/src/modules/user/user.controller.ts b/apps/backend/src/modules/user/user.controller.ts new file mode 100644 index 0000000..c86ebe4 --- /dev/null +++ b/apps/backend/src/modules/user/user.controller.ts @@ -0,0 +1,124 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { + CreateUserDto, + UserDto, + UpdateUserDto, + PaginatedUsersDto, + QueryUserDto, +} from './dto'; +import { User, UserRole } from '@/database/entities'; +import { UserService } from './user.service'; +import { Roles } from '@/modules/auth/decorators'; + +@ApiTags('users') +@Controller('users') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get() + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List users (Admin only)' }) + @ApiResponse({ + description: 'List of users', + status: 200, + type: PaginatedUsersDto, + }) + async findAll(@Query() queryDto: QueryUserDto) { + return this.userService.findAll(queryDto); + } + + @Post() + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Create or invite user (Admin only)' }) + @ApiResponse({ + description: 'User created successfully', + status: 201, + type: UserDto, + }) + @ApiResponse({ status: 409, description: 'User already exists' }) + async create(@Body() createUserDto: CreateUserDto) { + const user = await this.userService.create(createUserDto); + return user.profile; + } + + @Get(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get user by ID (Admin only)' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'User found', type: UserDto }) + @ApiResponse({ status: 404, description: 'User not found' }) + async findOne(@Param('id') id: string): Promise { + return this.userService.get(id); + } + + @Patch(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update user by ID' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ + description: 'User updated successfully', + status: 200, + type: UserDto, + }) + @ApiResponse({ status: 404, description: 'User not found' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async update( + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + ): Promise { + return this.userService.update(id, updateUserDto); + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete user (Admin only)' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ status: 204, description: 'User deleted successfully' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async remove(@Param('id') id: string): Promise { + return this.userService.remove(id); + } + + @Post(':id/restore') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Restore soft-deleted user (Admin only)' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ + status: 200, + description: 'User restored successfully', + type: UserDto, + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async restore(@Param('id') id: string): Promise { + return this.userService.restore(id); + } + + @Post(':id/reinvite') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Reinvite user (Admin only)' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ status: 202, description: 'Invitation sent' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async reinvite(@Param('id') id: string): Promise { + await this.userService.reinvite(id); + } +} diff --git a/apps/backend/src/modules/user/user.module.ts b/apps/backend/src/modules/user/user.module.ts new file mode 100644 index 0000000..e8466b4 --- /dev/null +++ b/apps/backend/src/modules/user/user.module.ts @@ -0,0 +1,17 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { User } from '@/database/entities'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; +import { AuthModule } from '../auth/auth.module'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; + +@Module({ + imports: [ + forwardRef(() => AuthModule), + MikroOrmModule.forFeature([User]), + ], + controllers: [UserController], + providers: [UserService], + exports: [UserService, MikroOrmModule], +}) +export class UserModule {} diff --git a/apps/backend/src/modules/user/user.repository.ts b/apps/backend/src/modules/user/user.repository.ts new file mode 100644 index 0000000..071f4b6 --- /dev/null +++ b/apps/backend/src/modules/user/user.repository.ts @@ -0,0 +1,12 @@ +import { EntityRepository } from '@mikro-orm/core'; +import { User } from '@/database/entities'; + +export class UserRepository extends EntityRepository { + async get(id: string): Promise { + return this.findOne(id, { filters: false }); + } + + async findByEmail(email: string): Promise { + return this.findOne({ email: email.toLowerCase() }, { filters: false }); + } +} diff --git a/apps/backend/src/modules/user/user.service.ts b/apps/backend/src/modules/user/user.service.ts new file mode 100644 index 0000000..e38b11f --- /dev/null +++ b/apps/backend/src/modules/user/user.service.ts @@ -0,0 +1,147 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + FilterQuery, + QueryOrder, + SqlEntityManager, +} from '@mikro-orm/postgresql'; +import { User, UserRole } from '@/database/entities'; +import { AuthService } from '@/modules/auth/auth.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { UserRepository } from './user.repository'; + +import { + CreateUserDto, + UpdateUserDto, + QueryUserDto, + PaginatedUsersDto, + UserSortField, + SortOrder, +} from './dto'; +import { randomBytes } from 'node:crypto'; + +@Injectable() +export class UserService { + constructor( + @InjectRepository(User) + private readonly userRepository: UserRepository, + private em: SqlEntityManager, + private authService: AuthService, + ) {} + + async get(id: string): Promise { + const user = await this.userRepository.get(id); + if (!user) throw new NotFoundException(`User with ID ${id} not found`); + return user; + } + + async create(payload: CreateUserDto): Promise { + const existingUser = await this.userRepository.findByEmail(payload.email); + if (existingUser) + throw new ConflictException( + existingUser.isDeleted + ? 'Email was previously used and is currently inactivated.' + : 'User with this email already exists', + ); + const user = this.userRepository.create({ + ...payload, + email: payload.email.toLowerCase(), + password: randomBytes(20).toString('hex'), + role: payload.role || UserRole.USER, + } as any); + await this.em.persistAndFlush(user); + try { + await this.authService.sendInvitationEmail(user); + } catch (error) { + console.error('Failed to send invitation email:', error); + } + return user; + } + + async findAll(query: QueryUserDto): Promise { + const { + search, + includeArchived, + page = 1, + limit = 20, + sortBy = UserSortField.CREATED_AT, + sortOrder = SortOrder.DESC, + } = query; + const where: FilterQuery = {}; + if (search) { + where.$or = [ + { email: { $ilike: `%${search}%` } }, + { firstName: { $ilike: `%${search}%` } }, + { lastName: { $ilike: `%${search}%` } }, + ]; + } + if (!includeArchived) where.deletedAt = null; + const sortFieldMap: Record = { + [UserSortField.ID]: 'id', + [UserSortField.EMAIL]: 'email', + [UserSortField.FIRST_NAME]: 'firstName', + [UserSortField.LAST_NAME]: 'lastName', + [UserSortField.CREATED_AT]: 'createdAt', + [UserSortField.UPDATED_AT]: 'updatedAt', + }; + const orderBy = { + [sortFieldMap[sortBy]]: + sortOrder === SortOrder.ASC ? QueryOrder.ASC : QueryOrder.DESC, + }; + const [users, total] = await this.userRepository.findAndCount(where, { + limit, + offset: (page - 1) * limit, + orderBy, + filters: includeArchived ? false : undefined, + }); + const totalPages = Math.ceil(total / limit); + return { + data: users.map((user) => user.toJSON()), + total, + page, + limit, + totalPages, + hasNext: page < totalPages, + hasPrevious: page > 1, + }; + } + + async update(id: string, payload: UpdateUserDto): Promise { + const user = await this.get(id); + if (payload.email && payload.email !== user.email) { + const existingUser = await this.userRepository.findByEmail(payload.email); + if (existingUser) throw new ConflictException('Email is already in use'); + } + Object.assign(user, payload); + await this.em.flush(); + return user; + } + + async remove(id: string): Promise { + const user = await this.get(id); + user.deletedAt = new Date(); + await this.em.flush(); + } + + async restore(id: string): Promise { + const user = await this.get(id); + if (!user.isDeleted) throw new BadRequestException('User is not archived'); + user.deletedAt = undefined; + await this.em.flush(); + return user; + } + + async reinvite(id: string): Promise { + const user = await this.get(id); + try { + await this.authService.sendInvitationEmail(user); + } catch (error) { + console.error('Failed to send reinvitation email:', error); + throw new BadRequestException('Failed to send invitation email'); + } + } +} diff --git a/apps/backend/tests/api/index.js b/apps/backend/tests/api/index.js deleted file mode 100644 index 8ddaa6f..0000000 --- a/apps/backend/tests/api/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import express from 'express'; - -import { authorize } from '../../shared/auth/mw.js'; -import ctrl from './seed.controller.js'; - -const router = express.Router(); - -router - .use(authorize()) - .post('/reset', ctrl.resetDatabase) - .post('/user', ctrl.seedUser); - -export default { - path: '/seed', - router, -}; diff --git a/apps/backend/tests/api/seed.controller.js b/apps/backend/tests/api/seed.controller.js deleted file mode 100644 index bf02385..0000000 --- a/apps/backend/tests/api/seed.controller.js +++ /dev/null @@ -1,16 +0,0 @@ -import SeedService from './seed.service.js'; - -async function resetDatabase(_req, res) { - await SeedService.resetDatabase(); - return res.status(200).send(); -} - -async function seedUser(_req, res) { - const user = await SeedService.createUser(); - return res.json({ data: user }); -} - -export default { - resetDatabase, - seedUser, -}; diff --git a/apps/backend/tests/api/seed.service.js b/apps/backend/tests/api/seed.service.js deleted file mode 100644 index ad2c809..0000000 --- a/apps/backend/tests/api/seed.service.js +++ /dev/null @@ -1,34 +0,0 @@ -import camelCase from 'lodash/camelCase.js'; -import { faker } from '@faker-js/faker'; -import mapKeys from 'lodash/mapKeys.js'; -import { role as roles } from '@app/config'; -import seedUsers from '@app/seed/user.json' with { type: 'json' }; -import db from '../../shared/database/index.js'; - -const { User } = db; - -class SeedService { - async resetDatabase() { - await db.sequelize.drop({}); - await db.initialize(); - await Promise.all( - seedUsers.map((it) => User.create(mapKeys(it, (_, k) => camelCase(k)))), - ); - return true; - } - - async createUser( - email = faker.internet.email(), - password = faker.internet.password(), - role = roles.ADMIN, - ) { - await User.create({ - email, - password, - role, - }); - return { email, password }; - } -} - -export default new SeedService(); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..35ca1fe --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true, + "paths": { + "@/*": ["src/*"], + "@modules/*": ["src/modules/*"], + "@common/*": ["src/common/*"], + "@config/*": ["src/config/*"] + } + } +} \ No newline at end of file diff --git a/apps/backend/user/index.js b/apps/backend/user/index.js deleted file mode 100644 index 93c0492..0000000 --- a/apps/backend/user/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import { StatusCodes } from 'http-status-codes'; -import express from 'express'; -import { authorize } from '../shared/auth/mw.js'; -import authService from '../shared/auth/index.js'; -import db from '../shared/database/index.js'; -import { processPagination } from '../shared/database/pagination.js'; -import { requestLimiter } from '../shared/request/mw.js'; -import ctrl from './user.controller.js'; -import { - loginRequestLimiter, - resetLoginAttempts, - setLoginLimitKey, -} from './mw.js'; - -const { User } = db; -const router = express.Router(); - -// Public routes: -router - .post( - '/login', - setLoginLimitKey, - loginRequestLimiter, - authService.authenticate('local', { setCookie: true }), - resetLoginAttempts, - ctrl.getProfile, - ) - .post('/forgot-password', ctrl.forgotPassword) - .use('/reset-password', requestLimiter(), authService.authenticate('token')) - .post('/reset-password', ctrl.resetPassword) - .post('/reset-password/token-status', (_, res) => - res.sendStatus(StatusCodes.ACCEPTED), - ); - -// Protected routes: -router - .use(authService.authenticate('jwt')) - .get('/', authorize(), processPagination(User), ctrl.list) - .post('/', authorize(), ctrl.upsert) - .get('/logout', authService.logout()) - .get('/me', ctrl.getProfile) - .patch('/me', ctrl.updateProfile) - .post('/me/change-password', ctrl.changePassword) - .delete('/:id', authorize(), ctrl.remove) - .post('/:id/reinvite', authorize(), ctrl.reinvite); - -export default { - path: '/users', - router, -}; diff --git a/apps/backend/user/mw.js b/apps/backend/user/mw.js deleted file mode 100644 index 92e1eea..0000000 --- a/apps/backend/user/mw.js +++ /dev/null @@ -1,22 +0,0 @@ -import crypto from 'crypto'; -import { requestLimiter } from '../shared/request/mw.js'; - -const ONE_HOUR_IN_MS = 60 * 60 * 1000; - -const loginRequestLimiter = requestLimiter({ - windowMs: ONE_HOUR_IN_MS, - limit: 15, - keyGenerator: (req) => req.userKey, -}); - -function setLoginLimitKey(req, _res, next) { - const key = [req.ip, req.body.email].join(':'); - req.userKey = crypto.createHash('sha256').update(key).digest('base64'); - return next(); -} - -function resetLoginAttempts(req, res, next) { - return loginRequestLimiter.resetKey(req.userKey).then(() => next()); -} - -export { loginRequestLimiter, setLoginLimitKey, resetLoginAttempts }; diff --git a/apps/backend/user/user.controller.js b/apps/backend/user/user.controller.js deleted file mode 100644 index 84e2817..0000000 --- a/apps/backend/user/user.controller.js +++ /dev/null @@ -1,100 +0,0 @@ -import map from 'lodash/map.js'; -import { Op } from 'sequelize'; -import { StatusCodes } from 'http-status-codes'; -import db from '../shared/database/index.js'; -import { createError, validationError } from '../shared/error/helpers.js'; - -const { User } = db; - -const createFilter = (q) => - map(['email', 'firstName', 'lastName'], (it) => ({ - [it]: { [Op.iLike]: `%${q}%` }, - })); - -function list({ query: { email, role, filter, archived }, options }, res) { - const where = { [Op.and]: [] }; - if (filter) where[Op.or] = createFilter(filter); - if (email) where[Op.and].push({ email }); - if (role) where[Op.and].push({ role }); - return User.findAndCountAll({ where, ...options, paranoid: !archived }).then( - ({ rows, count }) => { - return res.json({ data: { items: map(rows, 'profile'), total: count } }); - }, - ); -} - -function upsert({ body: { uid, email, firstName, lastName, role } }, res) { - return User.inviteOrUpdate({ uid, email, firstName, lastName, role }).then( - (data) => res.json({ data }), - ); -} - -function remove({ params: { id } }, res) { - return User.destroy({ where: { id } }).then(() => - res.sendStatus(StatusCodes.NO_CONTENT), - ); -} - -function forgotPassword({ body }, res) { - const { email } = body; - return User.unscoped() - .findOne({ where: { email } }) - .then( - (user) => user || createError(StatusCodes.NOT_FOUND, 'User not found'), - ) - .then((user) => user.sendResetToken()) - .then(() => res.end()); -} - -function resetPassword({ body, user }, res) { - const { password } = body; - return user - .update({ password }) - .then(() => res.sendStatus(StatusCodes.NO_CONTENT)); -} - -function getProfile({ user, authData }, res) { - return res.json({ user: user.profile, authData }); -} - -function updateProfile({ user, body }, res) { - const { email, firstName, lastName, imgUrl } = body; - return user - .update({ email, firstName, lastName, imgUrl }) - .then(({ profile }) => res.json({ user: profile })) - .catch(() => validationError(StatusCodes.CONFLICT)); -} - -function changePassword({ user, body }, res) { - const { currentPassword, newPassword } = body; - if (currentPassword === newPassword) - return res.sendStatus(StatusCodes.BAD_REQUEST); - return user - .authenticate(currentPassword) - .then((user) => user || createError(StatusCodes.BAD_REQUEST)) - .then((user) => user.update({ password: newPassword })) - .then(() => res.sendStatus(StatusCodes.NO_CONTENT)); -} - -function reinvite({ params }, res) { - return User.unscoped() - .findByPk(params.id) - .then( - (user) => - user || createError(StatusCodes.NOT_FOUND, 'User does not exist!'), - ) - .then((user) => User.sendInvitation(user)) - .then(() => res.status(StatusCodes.ACCEPTED).end()); -} - -export default { - list, - upsert, - remove, - forgotPassword, - resetPassword, - getProfile, - updateProfile, - changePassword, - reinvite, -}; diff --git a/apps/backend/user/user.model.js b/apps/backend/user/user.model.js deleted file mode 100644 index 35e6b7f..0000000 --- a/apps/backend/user/user.model.js +++ /dev/null @@ -1,217 +0,0 @@ -import bcrypt from 'bcrypt'; -import gravatar from 'gravatar'; -import jwt from 'jsonwebtoken'; -import map from 'lodash/map.js'; -import { Model } from 'sequelize'; -import omit from 'lodash/omit.js'; -import pick from 'lodash/pick.js'; -import Promise from 'bluebird'; -import randomstring from 'randomstring'; -import { role as roles } from '@app/config'; -import mail from '../shared/mail/index.js'; -import Audience from '../shared/auth/audience.js'; -import { auth as authConfig } from '#config'; - -const { - user: { ADMIN, INTEGRATION, USER }, -} = roles; - -const gravatarConfig = { size: 130, default: 'identicon' }; - -class User extends Model { - static fields({ DATE, ENUM, STRING, TEXT, UUID, UUIDV4, VIRTUAL }) { - return { - uid: { - type: UUID, - unique: true, - allowNull: false, - defaultValue: UUIDV4, - }, - email: { - type: STRING, - set(email) { - this.setDataValue('email', email.toLowerCase()); - }, - validate: { isEmail: true }, - unique: { msg: 'The specified email address is already in use.' }, - }, - password: { - type: STRING, - validate: { notEmpty: true, len: [5, 100] }, - defaultValue: () => randomstring.generate(), - }, - role: { - type: ENUM(ADMIN, INTEGRATION, USER), - defaultValue: USER, - }, - firstName: { - type: STRING, - field: 'first_name', - validate: { len: [2, 50] }, - }, - lastName: { - type: STRING, - field: 'last_name', - validate: { len: [2, 50] }, - }, - fullName: { - type: VIRTUAL, - get() { - return ( - [this.firstName, this.lastName].filter(Boolean).join(' ') || null - ); - }, - }, - label: { - type: VIRTUAL, - get() { - return this.fullName || this.email; - }, - }, - imgUrl: { - type: TEXT, - field: 'img_url', - get() { - const imgUrl = this.getDataValue('imgUrl'); - return imgUrl || gravatar.url(this.email, gravatarConfig, true); - }, - }, - profile: { - type: VIRTUAL, - get() { - return pick(this, [ - 'id', - 'email', - 'role', - 'firstName', - 'lastName', - 'fullName', - 'label', - 'imgUrl', - 'createdAt', - 'updatedAt', - 'deletedAt', - ]); - }, - }, - createdAt: { - type: DATE, - field: 'created_at', - }, - updatedAt: { - type: DATE, - field: 'updated_at', - }, - deletedAt: { - type: DATE, - field: 'deleted_at', - }, - }; - } - - static scopes() { - return { - defaultScope: { - attributes: { exclude: ['password'] }, - }, - }; - } - - static hooks(Hooks) { - return { - [Hooks.beforeCreate](user) { - return user.encryptPassword(); - }, - [Hooks.beforeUpdate](user) { - return user.changed('password') - ? user.encryptPassword() - : Promise.resolve(); - }, - [Hooks.beforeBulkCreate](users) { - const updates = []; - users.forEach((user) => updates.push(user.encryptPassword())); - return Promise.all(updates); - }, - }; - } - - static options() { - return { - modelName: 'user', - underscored: true, - timestamps: true, - paranoid: true, - freezeTableName: true, - }; - } - - static invite(user) { - return this.create(user).then((user) => { - this.sendInvitation(user); - return user.reload(); - }); - } - - static inviteOrUpdate(data) { - const { email } = data; - return User.findOne({ where: { email }, paranoid: false }).then((user) => { - if (!user) return User.invite(data); - const payload = omit(data, ['id', 'uid']); - map({ ...payload, deletedAt: null }, (v, k) => user.setDataValue(k, v)); - return user.save(); - }); - } - - static sendInvitation(user) { - const token = user.createToken({ - expiresIn: '5 days', - audience: Audience.Scope.Setup, - }); - mail.invite(user, token); - } - - isAdmin() { - return this.role === ADMIN || this.role === INTEGRATION; - } - - authenticate(password) { - if (!this.password) return Promise.resolve(false); - return bcrypt - .compare(password, this.password) - .then((match) => (match ? this : false)); - } - - encrypt(val) { - return bcrypt.hash(val, authConfig.saltRounds); - } - - encryptPassword() { - if (!this.password) return Promise.resolve(false); - return this.encrypt(this.password).then((pw) => (this.password = pw)); - } - - createToken(options = {}) { - const payload = { id: this.id, email: this.email }; - Object.assign(options, { - issuer: authConfig.jwt.issuer, - audience: options.audience || Audience.Scope.Access, - }); - return jwt.sign(payload, this.getTokenSecret(options.audience), options); - } - - sendResetToken() { - const token = this.createToken({ - audience: Audience.Scope.Setup, - expiresIn: '2 days', - }); - mail.resetPassword(this, token); - } - - getTokenSecret(audience) { - const { secret } = authConfig.jwt; - if (audience === Audience.Scope.Access) return secret; - return [secret, this.password, this.createdAt.getTime()].join(''); - } -} - -export default User; diff --git a/apps/frontend/api/ai.js b/apps/frontend/api/ai.js deleted file mode 100644 index 5f8c70a..0000000 --- a/apps/frontend/api/ai.js +++ /dev/null @@ -1,14 +0,0 @@ -import { extractData } from './helpers'; -import request from './request'; - -const urls = { - root: () => '/ai', -}; - -function prompt(payload) { - return request.post(urls.root(), payload).then(extractData); -} - -export default { - prompt, -}; diff --git a/apps/frontend/api/auth.js b/apps/frontend/api/auth.js index 447b5a8..6c2bc42 100644 --- a/apps/frontend/api/auth.js +++ b/apps/frontend/api/auth.js @@ -1,7 +1,8 @@ import request from './request'; +import { extractData } from './helpers'; const urls = { - root: '/users', + root: '/auth', login: () => `${urls.root}/login`, logout: () => `${urls.root}/logout`, forgotPassword: () => `${urls.root}/forgot-password`, @@ -12,31 +13,37 @@ const urls = { }; function login(credentials) { - return request.post(urls.login(), credentials); + return request.post(urls.login(), credentials).then(extractData); } function logout() { - return request.get(urls.logout()); + return request.get(urls.logout()).then(extractData); } function forgotPassword(email) { - return request.post(urls.forgotPassword(), { email }); + return request.post(urls.forgotPassword(), { email }).then(extractData); } function resetPassword(token, password) { - return request.post(urls.resetPassword(), { token, password }); + return request + .post(urls.resetPassword(), { token, password }) + .then(extractData); } function validateResetToken(token) { - return request.base.post(urls.resetTokenStatus(), { token }); + return request.base + .post(urls.resetTokenStatus(), { token }) + .then(extractData); } function changePassword(currentPassword, newPassword) { - return request.post(urls.changePassword(), { currentPassword, newPassword }); + return request + .post(urls.changePassword(), { currentPassword, newPassword }) + .then(extractData); } function getUserInfo() { - return request.get(urls.profile()); + return request.get(urls.profile()).then(extractData); } function updateUserInfo(userData) { diff --git a/apps/frontend/api/user.js b/apps/frontend/api/user.js index 6fb18b5..21f4f63 100644 --- a/apps/frontend/api/user.js +++ b/apps/frontend/api/user.js @@ -2,11 +2,15 @@ import { extractData } from './helpers'; import request from './request'; function fetch(params) { - return request.get('/users', { params }).then(extractData); + return request.get('/users', { params }).then((res) => res.data); } -function upsert(data) { - return request.post('/users', data).then(extractData); +function create(data) { + return request.post('/users', data).then((res) => res.data); +} + +function update(data) { + return request.patch(`/users/${data.id}`, data).then((res) => res.data); } function remove({ id }) { @@ -17,9 +21,15 @@ function reinvite({ id }) { return request.post(`/users/${id}/reinvite`); } +function restore({ id }) { + return request.post(`/users/${id}/restore`); +} + export default { fetch, - upsert, + create, + update, remove, reinvite, + restore }; diff --git a/apps/frontend/components/admin/UserDialog.vue b/apps/frontend/components/admin/UserDialog.vue index fe5e9cf..9424055 100644 --- a/apps/frontend/components/admin/UserDialog.vue +++ b/apps/frontend/components/admin/UserDialog.vue @@ -169,7 +169,7 @@ const close = () => { const submit = handleSubmit(async () => { const action = isNewUser.value ? 'create' : 'update'; - await api.upsert({ + await api[action]({ id: props.userData?.id, email: emailInput.value, firstName: firstNameInput.value, diff --git a/apps/frontend/components/common/AppBar.vue b/apps/frontend/components/common/AppBar.vue index 48a178a..dc6cdfb 100644 --- a/apps/frontend/components/common/AppBar.vue +++ b/apps/frontend/components/common/AppBar.vue @@ -14,10 +14,6 @@ /> App - - starter - v0.1 -