├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── .snyk ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── config └── example.env ├── docs ├── images │ └── deeper.jpg ├── index.html └── openapi.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── scripts └── generate-openapi.ts ├── src ├── accounts │ ├── accounts.controller.spec.ts │ ├── accounts.controller.ts │ ├── accounts.module.ts │ ├── accounts.service.spec.ts │ ├── accounts.service.ts │ ├── class │ │ ├── account-details.class.ts │ │ ├── account-list-item.class.ts │ │ ├── account.class.ts │ │ └── address.class.ts │ ├── dto │ │ ├── create-account.dto.ts │ │ ├── create-update-common.dto.ts │ │ └── update-account.dto.ts │ └── params │ │ ├── account-alias.params.ts │ │ └── account-id.params.ts ├── api-keys │ ├── api-key.entity.ts │ ├── api-keys.cli.ts │ ├── api-keys.controller.spec.ts │ ├── api-keys.controller.ts │ ├── api-keys.module.ts │ ├── api-keys.service.spec.ts │ ├── api-keys.service.ts │ ├── class │ │ └── api-key-access-token.ts │ └── params │ │ └── api-key-id.params.ts ├── app.module.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── class │ │ └── access-token.class.ts │ ├── dto │ │ └── login.dto.ts │ ├── jwt.strategy.ts │ └── local.strategy.ts ├── cli.ts ├── common │ ├── decorators │ │ ├── is-not-suspended.decorator.ts │ │ ├── req-user.decorator.ts │ │ └── roles.decorator.ts │ ├── filters │ │ └── unauthorized-exception.filter.ts │ ├── guards │ │ ├── forbid-api-key.guard.ts │ │ ├── is-not-suspended.guard.ts │ │ └── roles.guard.ts │ └── is-email-or-url.validator.ts ├── config │ ├── config.module.ts │ ├── config.service.spec.ts │ ├── config.service.ts │ └── ducky-api-config.class.ts ├── dkim │ ├── class │ │ └── dkim-key.class.ts │ ├── dkim.controller.spec.ts │ ├── dkim.controller.ts │ ├── dkim.module.ts │ ├── dkim.service.spec.ts │ ├── dkim.service.ts │ ├── dto │ │ └── add-dkim.dto.ts │ └── params │ │ └── dkim.params.ts ├── domains │ ├── class │ │ └── dns.class.ts │ ├── domain.entity.ts │ ├── domains.controller.spec.ts │ ├── domains.controller.ts │ ├── domains.module.ts │ ├── domains.service.spec.ts │ ├── domains.service.ts │ └── params │ │ ├── alias.params.ts │ │ └── domain.params.ts ├── filters │ ├── class │ │ ├── filter-details.class.ts │ │ ├── filter-list-item.class.ts │ │ └── filter.class.ts │ ├── dto │ │ └── create-update-filter.dto.ts │ ├── filters.controller.spec.ts │ ├── filters.controller.ts │ ├── filters.module.ts │ ├── filters.service.spec.ts │ ├── filters.service.ts │ └── params │ │ └── filter-id.params.ts ├── forwarders │ ├── class │ │ ├── forwarder-details.class.ts │ │ └── forwarder.class.ts │ ├── dto │ │ ├── create-forwarder.dto.ts │ │ ├── create-update-forwarder-common.dto.ts │ │ └── update-forwarder.dto.ts │ ├── forwarders.controller.spec.ts │ ├── forwarders.controller.ts │ ├── forwarders.module.ts │ ├── forwarders.service.spec.ts │ ├── forwarders.service.ts │ └── params │ │ └── forwarder-id.params.ts ├── main.ts ├── migrations │ ├── 1580519162771-AddIndexes.ts │ ├── 1580520383448-SetDefaultRole.ts │ └── 1589835792874-PackageToPackageId.ts ├── openapi-options.ts ├── packages │ ├── dto │ │ └── package-id.params.ts │ ├── package.entity.ts │ ├── packages.controller.spec.ts │ ├── packages.controller.ts │ ├── packages.module.ts │ ├── packages.service.spec.ts │ └── packages.service.ts ├── tasks │ ├── delete-for-domain │ │ ├── delete-for-domain-config.service.ts │ │ ├── delete-for-domain.interfaces.ts │ │ ├── delete-for-domain.module.ts │ │ └── delete-for-domain.processor.ts │ └── suspension │ │ ├── suspension-config.service.ts │ │ ├── suspension.interfaces.ts │ │ ├── suspension.module.ts │ │ └── suspension.processor.ts └── users │ ├── dto │ ├── create-user.dto.ts │ ├── delete-user.dto.ts │ ├── update-user-admin.dto.ts │ ├── update-user.dto.ts │ └── user-id-params.dto.ts │ ├── user.entity.ts │ ├── users.cli.ts │ ├── users.controller.spec.ts │ ├── users.controller.ts │ ├── users.module.ts │ ├── users.service.spec.ts │ └── users.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json ├── webpack.config.base.js ├── webpack.config.dev.js └── webpack.config.prod.js /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint', 'simple-import-sort'], 4 | extends: [ 5 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 6 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 7 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 11 | sourceType: 'module', // Allows for the use of imports 12 | }, 13 | rules: { 14 | '@typescript-eslint/no-explicit-any': 'off', 15 | 'prettier/prettier': 'warn', 16 | 'simple-import-sort/sort': 'warn', 17 | '@typescript-eslint/indent': 'off', 18 | '@typescript-eslint/no-parameter-properties': 'off', 19 | 'no-var': 'error', 20 | // "no-await-in-loop": "warn", 21 | 'prefer-const': 'warn', 22 | }, 23 | overrides: [ 24 | { 25 | files: ['*.js'], 26 | rules: { 27 | '@typescript-eslint/no-var-requires': 'off', 28 | }, 29 | }, 30 | ], 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Configuration files 6 | config/* 7 | !config/example.env 8 | 9 | # Duckypanel files 10 | duckypanel 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # OS 21 | .DS_Store 22 | 23 | # Tests 24 | /coverage 25 | /.nyc_output 26 | 27 | # IDEs and editors 28 | /.idea 29 | .project 30 | .classpath 31 | .c9/ 32 | *.launch 33 | .settings/ 34 | *.sublime-workspace 35 | 36 | # IDE - VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | tabWidth: 2, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | printWidth: 120, 7 | endOfLine: 'auto', 8 | } 9 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.14.1 3 | ignore: {} 4 | patch: {} 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "eg2.vscode-npm-script", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Debug Session", 9 | "type": "node", 10 | "request": "attach", 11 | "preLaunchTask": "npm: buildstart:debug", 12 | "port": 9229, 13 | "restart": true, 14 | "timeout": 60000, 15 | "stopOnEntry": false, 16 | "smartStep": true 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | "typescript", 6 | "typescriptreact" 7 | ], 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "editor.tabSize": 2, 10 | "workbench.colorCustomizations": { 11 | "titleBar.activeBackground": "#1857a4", 12 | "titleBar.inactiveBackground": "#1857a499", 13 | "titleBar.activeForeground": "#e7e7e7", 14 | "titleBar.inactiveForeground": "#e7e7e799" 15 | }, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.eslint": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "buildstart:dev", 7 | "isBackground": true, 8 | "presentation": { 9 | "focus": true, 10 | "panel": "dedicated" 11 | }, 12 | "group": { 13 | "kind": "build", 14 | "isDefault": true 15 | }, 16 | "problemMatcher": { 17 | "owner": "typescript", 18 | "source": "ts", 19 | "applyTo": "closedDocuments", 20 | "fileLocation": ["relative", "${cwd}"], 21 | "pattern": "$tsc", 22 | "background": { 23 | "activeOnStart": true, 24 | "beginsPattern": { 25 | "regexp": "(.*?)" 26 | }, 27 | "endsPattern": { 28 | "regexp": "Nest application successfully started" 29 | } 30 | } 31 | } 32 | }, 33 | { 34 | "type": "npm", 35 | "script": "buildstart:debug", 36 | "isBackground": true, 37 | "presentation": { 38 | "focus": true, 39 | "panel": "dedicated" 40 | }, 41 | "group": { 42 | "kind": "build", 43 | "isDefault": true 44 | }, 45 | "problemMatcher": { 46 | "owner": "typescript", 47 | "source": "ts", 48 | "applyTo": "closedDocuments", 49 | "fileLocation": ["relative", "${cwd}"], 50 | "pattern": "$tsc", 51 | "background": { 52 | "activeOnStart": true, 53 | "beginsPattern": { 54 | "regexp": "(.*?)" 55 | }, 56 | "endsPattern": { 57 | "regexp": "Nest application successfully started|Debugger listening on" 58 | } 59 | } 60 | } 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | __NOTE:__ This project is still a work in progress. You can already check it out if you're curious. The API may change in the future. 2 | 3 | # DuckyAPI 4 | 5 | API that interacts with the [WildDuck](https://github.com/nodemailer/wildduck) API. Mostly built as a backend to [DuckyPanel](https://github.com/louis-lau/DuckyPanel). 6 | 7 | 8 | ![We need to go deeper](docs/images/deeper.jpg) 9 | 10 | ## Why? 11 | In WildDuck a user is a single Email Account, using the api as an end-user you can add address aliases to that inbox. You can not add extra email accounts or manage domain level functionality like DKIM. The aim of DuckyAPI is to offer an end-user API that allows complete management of domains and email accounts within those domains. 12 | 13 | ## How? 14 | DuckyAPI stores its users in MongoDB, this can be the same instance that you're already using for WildDuck. Each user owns a list of domains, granting permission to manage dkim or add/edit email accounts and forwarders under that domain. Each user can be assigned a package, containing quotas and limits for the user. Currently nothing happens when quota is exceeded, this may change in the future. 15 | 16 | Like WildDuck, this application does not depend on memory for anything. Users etc are stored in mongodb, queue management is done in redis. This application is stateless. 17 | 18 | ## Features 19 | See [DuckyPanel features](https://github.com/louis-lau/DuckyPanel/blob/master/README.md#current-features). 20 | 21 | ## Dependencies 22 | * Node.js 23 | * MongoDB 24 | * Redis 25 | * WildDuck 26 | 27 | ## Installation 28 | ```bash 29 | $ git clone https://github.com/louis-lau/DuckyAPI.git 30 | $ cd DuckyAPI 31 | $ npm install 32 | ``` 33 | 34 | ## Configuration 35 | Copy `config/example.env` to `config/production.env` or `config/development.env` depending on your environment. You must change the configuration for the application to start. If you've misconfigured something the application should tell you on start. 36 | 37 | ## Usage 38 | ```bash 39 | $ npm run clean 40 | $ npm run build 41 | $ npm start 42 | 43 | # Create your first admin user, admin users are 44 | # only meant for adding and updating users/packages 45 | $ node dist/cli create-admin 46 | # Create an api key for your admin user 47 | $ node dist/cli create-apikey 48 | 49 | # Add a normal user using the api, be sure to replace 50 | # the access token with the one you just got from create-apikey 51 | curl -X POST "http://localhost:3000/users" \ 52 | -H "Authorization: Bearer YOUR-ACCESS-TOKEN-HERE" \ 53 | -H "Content-Type: application/json" \ 54 | -d '{"username":"johndoe", "password":"supersecret"}' 55 | 56 | # Now use the normal user to log in to DuckyPanel, 57 | # or request an access token from the /authentication endpoint 58 | ``` 59 | 👆 Instead of using curl you can also execute this request from [localhost:3000/swagger](http://localhost:3000/swagger) 60 | 61 | ## API documentation 62 | API documentation with code examples is available on [louis-lau.github.io/DuckyAPI](https://louis-lau.github.io/DuckyAPI). 63 | 64 | You can also visit [localhost:3000/swagger](http://localhost:3000/swagger) to try the api out live in your browser. Much nice than using curl! 65 | 66 | ## Integrated DuckyPanel 67 | DuckyApi can serve DuckyPanel on its integrated server. Just open your configuration and set `SERVE_DUCKYPANEL` to `true`. Then set a custom `BASE_URL` for the api, for example `/api`. 68 | 69 | Duckypanel will now be live at [localhost:3000](http://localhost:3000), and DuckyApi at [localhost:3000/api](http://localhost:3000/api). 70 | 71 | ## Task queue 72 | Any created background tasks and their progress can be viewed on [localhost:3000/queues](http://localhost:3000/queues) with basicauth if you've enabled this in the configuration. Removing a domain or suspending a user will trigger a background task to execute mass changes. -------------------------------------------------------------------------------- /config/example.env: -------------------------------------------------------------------------------- 1 | # Port the application runs on; Optional; Default 3000 2 | # PORT=3000 3 | 4 | # Serve DuckyPanel from DuckyApi; Optional; Default false 5 | # SERVE_DUCKYPANEL=true 6 | 7 | # Custom Base URL; Required if SERVE_DUCKYPANEL=true; Default "/" 8 | # BASE_URL=/api 9 | 10 | # JWT secret value. Set this to something safe and random. Should be the same accross all instances; Required 11 | TOKEN_SECRET=CHANGE-ME-PLEASE! 12 | 13 | # MongoDB connection string. This can include authentication, ports, database name, multiple hosts for replica sets etc 14 | # https://docs.mongodb.com/manual/reference/connection-string/ 15 | # https://www.iana.org/assignments/uri-schemes/prov/mongodb 16 | # MongoDB is used to store API users and their domains; Required 17 | MONGODB_URL=mongodb://localhost:27017/ducky-api 18 | 19 | # Redis connection string. Only supports a single redis instance, sentinel support will be added in the future. 20 | # https://www.iana.org/assignments/uri-schemes/prov/redis 21 | # Redis is used for task queue management; Required 22 | REDIS_URL=redis://localhost/10 23 | 24 | # URL for the WildDuck API; Required 25 | WILDDUCK_API_URL=http://localhost:5438 26 | 27 | # Token for authenticating against the WildDuck API; Optional 28 | WILDDUCK_API_TOKEN=yourverysecrettoken 29 | 30 | # If set to false WildDuck will check all new passwords against https://haveibeenpwned.com/Passwords; Optional; Default true 31 | # ALLOW_UNSAFE_ACCOUNT_PASSWORDS=true 32 | 33 | # Allow values such as *@example.com for forwarders and account aliases. user@* is never allowed; Optional; Default true 34 | # ALLOW_FORWARDER_WILDCARD=true 35 | # ALLOW_ACCOUNT_WILDCARD=true 36 | 37 | # Array of MX record objects. These are suggested and checked in dnscheck; Required 38 | MX_RECORDS=[{"exchange": "mx1.example.com", "priority": 10}, {"exchange": "mx2.example.com", "priority": 20}] 39 | 40 | # SPF value to suggest in dnscheck; Required 41 | SPF_CORRECT_VALUE=v=spf1 include:example.com -all 42 | 43 | # Regex to test spf against in dnscheck; Optional 44 | SPF_REGEX=^v=spf.* include:example.com.*(-|~|\\?)all$ 45 | 46 | # Enables dkim with the following selector by default when adding a domain; Optional 47 | DEFAULT_DKIM_SELECTOR=ducky 48 | 49 | # Serve UI for queues (background tasks) at /queues.; Optional; Default false 50 | # QUEUE_UI=true 51 | 52 | # Add basic auth to queue UI, this is recommended. QUEUE_UI_PASSWORD is required if QUEUE_UI_USER is set; Optional 53 | # QUEUE_UI_USER=admin 54 | # QUEUE_UI_PASSWORD=secret 55 | 56 | # Delays every response by this amount of milliseconds. Can be useful during development; Optional 57 | # DELAY=1000 58 | -------------------------------------------------------------------------------- /docs/images/deeper.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louis-lau/DuckyAPI/88697e0d759655083e3af06b0ac58ce0b52af736/docs/images/deeper.jpg -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | DuckyAPI documentation 5 | 6 | 7 | 8 | 9 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ducky-api", 3 | "version": "0.0.0", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "clean": "rimraf dist", 9 | "build": "webpack --color --config webpack.config.prod.js", 10 | "build:dev": "webpack --color --config webpack.config.dev.js", 11 | "start": "wait-on dist/main.js && node dist/main", 12 | "start:dev": "wait-on dist/main.js && nodemon --watch config/development.env --exec \"node --unhandled-rejections=strict dist/main\"", 13 | "start:debug": "wait-on dist/main.js && nodemon --watch config/development.env --exec \"node --inspect dist/main\"", 14 | "buildstart": "npm-run-all --silent --print-label clean build start", 15 | "buildstart:dev": "npm-run-all --silent --print-label clean --parallel build:dev start:dev", 16 | "buildstart:debug": "npm-run-all --silent --print-label clean --parallel build:dev start:debug", 17 | "openapi": "npm-run-all --silent --print-label clean build openapi:generate", 18 | "openapi:generate": "node dist/generate-openapi", 19 | "lint": "npm-run-all --silent --print-label lint:tsc lint:eslint", 20 | "lint:fix": "npm-run-all --silent --print-label lint:tsc lint:eslintfix", 21 | "lint:tsc": "tsc --emitDeclarationOnly -p tsconfig.build.json", 22 | "lint:eslint": "eslint --color --max-warnings 0 ./ --ext ts", 23 | "lint:eslintfix": "eslint --fix --color --max-warnings 0 ./ --ext ts", 24 | "test": "jest", 25 | "test:watch": "jest --watch", 26 | "test:cov": "jest --coverage", 27 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 28 | "test:e2e": "jest --config ./test/jest-e2e.json", 29 | "snyk-protect": "snyk protect", 30 | "prepare": "npm run snyk-protect" 31 | }, 32 | "dependencies": { 33 | "@hapi/joi": "^17.1.1", 34 | "@nestjs/bull": "0.1.2", 35 | "@nestjs/common": "^7.4.2", 36 | "@nestjs/core": "^7.4.2", 37 | "@nestjs/jwt": "^7.1.0", 38 | "@nestjs/passport": "^7.1.0", 39 | "@nestjs/platform-express": "^7.4.2", 40 | "@nestjs/serve-static": "^2.1.3", 41 | "@nestjs/swagger": "^4.6.0", 42 | "@nestjs/typeorm": "^7.1.0", 43 | "bcrypt": "^5.0.0", 44 | "bull": "^3.18.0", 45 | "bull-arena": "^3.2.2", 46 | "class-transformer": "^0.3.1", 47 | "class-validator": "^0.12.2", 48 | "commander": "^6.0.0", 49 | "duckypanel": "0.0.5", 50 | "express-basic-auth": "^1.2.0", 51 | "generate-password": "^1.5.1", 52 | "helmet": "^4.1.0", 53 | "mongodb": "^3.6.0", 54 | "nanoid": "^3.1.12", 55 | "nestjs-console": "^3.1.1", 56 | "passport": "^0.4.1", 57 | "passport-jwt": "^4.0.0", 58 | "passport-local": "^1.0.0", 59 | "reflect-metadata": "^0.1.13", 60 | "rimraf": "^3.0.2", 61 | "rxjs": "^6.6.2", 62 | "snyk": "^1.381.1", 63 | "swagger-ui-express": "^4.1.4", 64 | "typeorm": "^0.2.25" 65 | }, 66 | "devDependencies": { 67 | "@nestjs/cli": "^7.4.1", 68 | "@nestjs/testing": "^7.4.2", 69 | "@types/bcrypt": "^3.0.0", 70 | "@types/bull": "^3.14.1", 71 | "@types/express": "^4.17.7", 72 | "@types/hapi__joi": "^17.1.4", 73 | "@types/helmet": "0.0.47", 74 | "@types/jest": "^26.0.10", 75 | "@types/mongoose": "^5.7.36", 76 | "@types/node": "^14.6.0", 77 | "@types/passport-jwt": "^3.0.3", 78 | "@types/supertest": "^2.0.10", 79 | "@types/webpack-env": "^1.15.2", 80 | "@typescript-eslint/eslint-plugin": "^3.9.1", 81 | "@typescript-eslint/parser": "^3.9.1", 82 | "axios": "^0.19.2", 83 | "eslint": "^7.7.0", 84 | "eslint-config-prettier": "^6.11.0", 85 | "eslint-plugin-prettier": "^3.1.4", 86 | "eslint-plugin-simple-import-sort": "^5.0.3", 87 | "jest": "^26.4.1", 88 | "js-yaml": "^3.14.0", 89 | "nodemon": "^2.0.4", 90 | "npm-run-all": "^4.1.5", 91 | "openapi-snippet": "^0.9.1", 92 | "prettier": "^2.0.5", 93 | "prettier-eslint": "^11.0.0", 94 | "supertest": "^4.0.2", 95 | "ts-jest": "26.2.0", 96 | "ts-loader": "^8.0.2", 97 | "ts-node": "8.10.2", 98 | "tsconfig-paths": "3.9.0", 99 | "tsconfig-paths-webpack-plugin": "^3.3.0", 100 | "typescript": "3.9.7", 101 | "wait-on": "^5.2.0", 102 | "webpack": "^4.44.1", 103 | "webpack-cli": "^3.3.12", 104 | "webpack-merge": "^5.1.2", 105 | "webpack-node-externals": "^2.5.1" 106 | }, 107 | "jest": { 108 | "moduleFileExtensions": [ 109 | "js", 110 | "json", 111 | "ts" 112 | ], 113 | "rootDir": "src", 114 | "testRegex": ".spec.ts$", 115 | "transform": { 116 | "^.+\\.(t|j)s$": "ts-jest" 117 | }, 118 | "coverageDirectory": "../coverage", 119 | "testEnvironment": "node" 120 | }, 121 | "snyk": true 122 | } 123 | -------------------------------------------------------------------------------- /scripts/generate-openapi.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core' 2 | import { SwaggerModule } from '@nestjs/swagger' 3 | import { writeFileSync } from 'fs' 4 | import { safeDump as safeDumpYaml } from 'js-yaml' 5 | import OpenAPISnippet from 'openapi-snippet' 6 | import { resolve } from 'path' 7 | import { AppModule } from 'src/app.module' 8 | import { openapiOptions } from 'src/openapi-options' 9 | 10 | const enrichSchema = (schema, targets): any => { 11 | schema.servers = [ 12 | { 13 | url: 'http://localhost:3000', 14 | }, 15 | ] 16 | for (const path in schema.paths) { 17 | for (const method in schema.paths[path]) { 18 | const generatedCode = OpenAPISnippet.getEndpointSnippets(schema, path, method, targets) 19 | schema.paths[path][method]['x-code-samples'] = [] 20 | for (const snippetIdx in generatedCode.snippets) { 21 | const snippet = generatedCode.snippets[snippetIdx] 22 | let lang: string 23 | let label: string 24 | switch (snippet.id) { 25 | case 'shell_curl': 26 | lang = 'Shell' 27 | label = 'Curl' 28 | break 29 | case 'node_native': 30 | lang = 'JavaScript' 31 | label = 'Node.js' 32 | break 33 | case 'javascript_xhr': 34 | lang = 'JavaScript' 35 | label = 'JavaScript' 36 | break 37 | case 'python_python3': 38 | lang = 'Python' 39 | label = 'Python3' 40 | break 41 | case 'php_curl': 42 | lang = 'PHP' 43 | label = 'PHP' 44 | break 45 | case 'java_unirest': 46 | lang = 'Java' 47 | label = 'Java' 48 | break 49 | case 'csharp_restsharp': 50 | lang = 'C#' 51 | label = 'C#' 52 | break 53 | case 'c_libcurl': 54 | lang = 'C' 55 | label = 'C' 56 | break 57 | 58 | default: 59 | lang = snippet.title 60 | label = snippet.title 61 | break 62 | } 63 | schema.paths[path][method]['x-code-samples'][snippetIdx] = { 64 | lang: lang, 65 | label: label, 66 | source: snippet.content, 67 | } 68 | } 69 | } 70 | } 71 | return schema 72 | } 73 | 74 | const main = async () => { 75 | const app = await NestFactory.create(AppModule) 76 | const document = SwaggerModule.createDocument(app, openapiOptions) 77 | app.close() 78 | 79 | const targets = [ 80 | 'shell_curl', 81 | // 'node_native', 82 | // 'javascript_xhr', 83 | // 'python_python3', 84 | // 'php_curl', 85 | // 'java_unirest', 86 | // 'csharp_restsharp', 87 | // 'c_libcurl', 88 | ] 89 | 90 | const enrichedSchema = enrichSchema(document, targets) 91 | 92 | const yamlSchema = safeDumpYaml(enrichedSchema, { skipInvalid: true }) 93 | writeFileSync(resolve('docs/openapi.yml'), yamlSchema) 94 | 95 | process.exit(0) 96 | } 97 | 98 | main() 99 | -------------------------------------------------------------------------------- /src/accounts/accounts.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { AccountsController } from './accounts.controller' 4 | 5 | describe('Accounts Controller', (): void => { 6 | let controller: AccountsController 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [AccountsController], 12 | }).compile() 13 | 14 | controller = module.get(AccountsController) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(controller).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/accounts/accounts.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiCreatedResponse, 7 | ApiNotFoundResponse, 8 | ApiOkResponse, 9 | ApiOperation, 10 | ApiTags, 11 | ApiUnauthorizedResponse, 12 | } from '@nestjs/swagger' 13 | import { IsNotSuspended } from 'src/common/decorators/is-not-suspended.decorator' 14 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 15 | import { Roles } from 'src/common/decorators/roles.decorator' 16 | import { IsNotSuspendedGuard } from 'src/common/guards/is-not-suspended.guard' 17 | import { RolesGuard } from 'src/common/guards/roles.guard' 18 | import { User } from 'src/users/user.entity' 19 | 20 | import { AccountsService } from './accounts.service' 21 | import { AccountDetails } from './class/account-details.class' 22 | import { AccountListItem } from './class/account-list-item.class' 23 | import { Address } from './class/address.class' 24 | import { CreateAccountDto } from './dto/create-account.dto' 25 | import { UpdateAccountDto } from './dto/update-account.dto' 26 | import { AccountAliasParams } from './params/account-alias.params' 27 | import { AccountIdParams } from './params/account-id.params' 28 | 29 | @Controller('accounts') 30 | @ApiTags('Email Accounts') 31 | @UseGuards(AuthGuard('jwt'), RolesGuard, IsNotSuspendedGuard) 32 | @Roles('user') 33 | @ApiBearerAuth() 34 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 35 | @ApiBadRequestResponse({ description: 'Bad user input' }) 36 | export class AccountsController { 37 | public constructor(private readonly accountsService: AccountsService) {} 38 | 39 | @Delete(':accountId') 40 | @ApiOperation({ operationId: 'deleteAccount', summary: 'Delete email account' }) 41 | @ApiOkResponse({ description: 'Account deleted successfully' }) 42 | @ApiNotFoundResponse({ description: 'No account found with this id' }) 43 | private async deleteAccount(@ReqUser() user: User, @Param() accountIdParams: AccountIdParams): Promise { 44 | return this.accountsService.deleteAccount(user, accountIdParams.accountId) 45 | } 46 | 47 | @Get() 48 | @ApiOperation({ operationId: 'getAccounts', summary: 'List email accounts' }) 49 | @ApiOkResponse({ description: 'A list of accounts', type: AccountListItem, isArray: true }) 50 | private async getAccounts(@ReqUser() user: User): Promise { 51 | return this.accountsService.getAccounts(user) 52 | } 53 | 54 | @Get(':accountId') 55 | @ApiOperation({ operationId: 'getAccountDetails', summary: 'Get email account details' }) 56 | @ApiOkResponse({ description: 'Account details', type: AccountDetails }) 57 | @ApiNotFoundResponse({ description: 'No account found with this id' }) 58 | private async getAccountDetails( 59 | @ReqUser() user: User, 60 | @Param() accountIdParams: AccountIdParams, 61 | ): Promise { 62 | return this.accountsService.getAccountDetails(user, accountIdParams.accountId) 63 | } 64 | 65 | @Post() 66 | @IsNotSuspended() 67 | @ApiOperation({ operationId: 'createAccount', summary: 'Create a new email account' }) 68 | @ApiCreatedResponse({ description: 'Account created successfully' }) 69 | private async createAccount(@ReqUser() user: User, @Body() createAccountDto: CreateAccountDto): Promise { 70 | return this.accountsService.createAccount(user, createAccountDto) 71 | } 72 | 73 | @Put(':accountId') 74 | @ApiOperation({ operationId: 'updateAccount', summary: 'Update existing email account' }) 75 | @ApiOkResponse({ description: 'Account updated successfully' }) 76 | @ApiNotFoundResponse({ description: 'No account found with this id' }) 77 | private async updateAccount( 78 | @ReqUser() user: User, 79 | @Param() accountIdParams: AccountIdParams, 80 | @Body() updateAccountDto: UpdateAccountDto, 81 | ): Promise { 82 | return this.accountsService.updateAccount(user, accountIdParams.accountId, updateAccountDto) 83 | } 84 | 85 | @Post(':accountId/aliases') 86 | @IsNotSuspended() 87 | @ApiOperation({ operationId: 'addAccountAlias', summary: 'Add an account alias' }) 88 | @ApiCreatedResponse({ description: 'Alias successfully added' }) 89 | @ApiNotFoundResponse({ description: 'No account found with this id' }) 90 | private async addAlias( 91 | @ReqUser() user: User, 92 | @Param() accountIdParams: AccountIdParams, 93 | @Body() address: Address, 94 | ): Promise { 95 | return this.accountsService.addAlias(user, accountIdParams.accountId, address) 96 | } 97 | 98 | @Delete(':accountId/aliases/:aliasId') 99 | @ApiOperation({ operationId: 'deleteAccountAlias', summary: 'Delete an account alias' }) 100 | @ApiOkResponse({ description: 'Alias successfully deleted' }) 101 | @ApiNotFoundResponse({ description: 'No account or alias found with this id' }) 102 | private async deleteAlias(@ReqUser() user: User, @Param() accountAliasParams: AccountAliasParams): Promise { 103 | return this.accountsService.deleteAlias(user, accountAliasParams.accountId, accountAliasParams.aliasId) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/accounts/accounts.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, HttpModule, Module } from '@nestjs/common' 2 | import { ConfigModule } from 'src/config/config.module' 3 | import { ConfigService } from 'src/config/config.service' 4 | import { DomainsModule } from 'src/domains/domains.module' 5 | 6 | import { AccountsController } from './accounts.controller' 7 | import { AccountsService } from './accounts.service' 8 | 9 | @Module({ 10 | imports: [ 11 | forwardRef(() => DomainsModule), 12 | HttpModule.registerAsync({ 13 | imports: [ConfigModule], 14 | inject: [ConfigService], 15 | useFactory: (config: ConfigService) => ({ 16 | timeout: 10000, 17 | maxRedirects: 5, 18 | baseURL: config.WILDDUCK_API_URL, 19 | headers: { 20 | 'X-Access-Token': config.WILDDUCK_API_TOKEN, 21 | }, 22 | }), 23 | }), 24 | ], 25 | controllers: [AccountsController], 26 | providers: [AccountsService], 27 | exports: [AccountsService], 28 | }) 29 | export class AccountsModule {} 30 | -------------------------------------------------------------------------------- /src/accounts/accounts.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { AccountsService } from './accounts.service' 4 | 5 | describe('AccountsService', (): void => { 6 | let service: AccountsService 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [AccountsService], 12 | }).compile() 13 | 14 | service = module.get(AccountsService) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(service).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/accounts/class/account-details.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | import { Account } from './account.class' 4 | 5 | class AccountDetailsLimitsQuota { 6 | @ApiProperty({ example: 1073741824, description: 'How many bytes the account is allowed to use' }) 7 | public allowed: number 8 | 9 | @ApiProperty({ example: 2048, description: 'How many bytes the account is currently using' }) 10 | public used: number 11 | } 12 | 13 | class AccountDetailsLimitsSend { 14 | @ApiProperty({ example: 200, description: 'How many messages can be sent per period' }) 15 | public allowed: number 16 | 17 | @ApiProperty({ example: 231, description: 'How many messages were sent in the current period' }) 18 | public used: number 19 | 20 | @ApiProperty({ example: 3600, description: 'Seconds until the end of the current period' }) 21 | public ttl: number 22 | } 23 | 24 | class AccountDetailsLimitsReceive { 25 | @ApiProperty({ example: 1000, description: 'How many messages can be received per period' }) 26 | public allowed: number 27 | 28 | @ApiProperty({ example: 574, description: 'How many messages were received in the current period' }) 29 | public used: number 30 | 31 | @ApiProperty({ example: 3600, description: 'Seconds until the end of the current period' }) 32 | public ttl: number 33 | } 34 | 35 | class AccountDetailsLimitsForward { 36 | @ApiProperty({ example: 100, description: 'How many messages can be forwarded per period' }) 37 | public allowed: number 38 | 39 | @ApiProperty({ example: 56, description: 'How many messages were forwarded in the current period' }) 40 | public used: number 41 | 42 | @ApiProperty({ example: 3600, description: 'Seconds until the end of the current period' }) 43 | public ttl: number 44 | } 45 | 46 | class AccountDetailsLimits { 47 | @ApiProperty({ description: 'Storage quota limit and usage' }) 48 | public quota: AccountDetailsLimitsQuota 49 | 50 | @ApiProperty({ description: 'How many emails the account can send in a period' }) 51 | public send: AccountDetailsLimitsSend 52 | 53 | @ApiProperty({ description: 'How many emails the account can receive in a period' }) 54 | public receive: AccountDetailsLimitsReceive 55 | 56 | @ApiProperty({ description: 'How many emails the account can forward in a period' }) 57 | public forward: AccountDetailsLimitsForward 58 | } 59 | 60 | export class AccountDetails extends Account { 61 | @ApiProperty({ 62 | example: 50, 63 | description: 'Relative scale for detecting spam. 0 means that everything is spam, 100 means that nothing is spam', 64 | }) 65 | public spamLevel: number 66 | 67 | @ApiProperty({ 68 | example: ['imap', 'pop3'], 69 | description: 'List of scopes that are disabled for this user', 70 | }) 71 | public disabledScopes: ('pop3' | 'imap' | 'smtp')[] 72 | 73 | @ApiProperty({ description: 'Account limits' }) 74 | public limits: AccountDetailsLimits 75 | } 76 | -------------------------------------------------------------------------------- /src/accounts/class/account-list-item.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | import { Account } from './account.class' 4 | 5 | class AccountListItemQuota { 6 | @ApiProperty({ example: 1073741824, description: 'How many bytes the account is allowed to use' }) 7 | public allowed: number 8 | 9 | @ApiProperty({ example: 17799833, description: 'How many bytes the account is currently using' }) 10 | public used: number 11 | } 12 | 13 | export class AccountListItem extends Account { 14 | @ApiProperty({ description: 'Account quota usage and limit' }) 15 | public quota: AccountListItemQuota 16 | } 17 | -------------------------------------------------------------------------------- /src/accounts/class/account.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | import { Address } from './address.class' 4 | 5 | export class Account extends Address { 6 | @ApiProperty({ 7 | example: false, 8 | description: 'If true then the account can not authenticate or receive any new mail', 9 | }) 10 | public disabled: boolean 11 | 12 | @ApiProperty({ 13 | description: 'List of aliases for this account', 14 | type: Address, 15 | isArray: true, 16 | }) 17 | public aliases: Address[] 18 | } 19 | -------------------------------------------------------------------------------- /src/accounts/class/address.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator' 3 | 4 | export class Address { 5 | @ApiPropertyOptional({ 6 | example: '59cb948ad80a820b68f05230', 7 | description: 'The unique id of the email account', 8 | readOnly: true, 9 | }) 10 | public id?: string 11 | 12 | @ApiPropertyOptional({ example: 'John Doe', description: 'The name of the email account' }) 13 | @IsOptional() 14 | @IsString() 15 | @IsNotEmpty() 16 | public name?: string | null 17 | 18 | @ApiProperty({ example: 'john@example.com', description: 'The E-Mail address of the email account' }) 19 | @IsEmail() 20 | public address: string 21 | } 22 | -------------------------------------------------------------------------------- /src/accounts/dto/create-account.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsEmail, IsNotEmpty, IsString, NotContains } from 'class-validator' 3 | 4 | import { CreateUpdateAccountCommonDto } from './create-update-common.dto' 5 | 6 | export class CreateAccountDto extends CreateUpdateAccountCommonDto { 7 | @ApiProperty({ example: 'john@example.com', description: 'The E-Mail address of the email account' }) 8 | @IsEmail() 9 | @NotContains('*') 10 | public address: string 11 | 12 | @ApiProperty({ example: 'verysecret', description: 'The new password of the email account' }) 13 | @IsNotEmpty() 14 | @IsString() 15 | public password: string 16 | } 17 | -------------------------------------------------------------------------------- /src/accounts/dto/create-update-common.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { Type } from 'class-transformer' 3 | import { 4 | ArrayUnique, 5 | IsArray, 6 | IsNumber, 7 | IsOptional, 8 | IsPositive, 9 | IsString, 10 | Matches, 11 | Max, 12 | Min, 13 | ValidateNested, 14 | } from 'class-validator' 15 | 16 | export class CreateUpdateAccountLimits { 17 | @ApiPropertyOptional({ 18 | example: 1073741824, 19 | description: 'How many bytes the account is allowed to use', 20 | }) 21 | @IsOptional() 22 | @IsNumber() 23 | @IsPositive() 24 | public quota?: number 25 | 26 | @ApiPropertyOptional({ 27 | example: 200, 28 | description: 'How many emails the account can send in a period', 29 | }) 30 | @IsOptional() 31 | @IsNumber() 32 | @IsPositive() 33 | public send?: number 34 | 35 | @ApiPropertyOptional({ 36 | example: 1000, 37 | description: 'How many emails the account can receive in a period', 38 | }) 39 | @IsOptional() 40 | @IsNumber() 41 | @IsPositive() 42 | public receive?: number 43 | 44 | @ApiPropertyOptional({ 45 | example: 100, 46 | description: 'How many emails the account can forward in a period', 47 | }) 48 | @IsOptional() 49 | @IsNumber() 50 | @IsPositive() 51 | public forward?: number 52 | } 53 | 54 | export class CreateUpdateAccountCommonDto { 55 | @ApiPropertyOptional({ example: 'John Doe', description: 'The name of the email account' }) 56 | @IsOptional() 57 | @IsString() 58 | public name?: string 59 | 60 | @ApiPropertyOptional({ 61 | example: 50, 62 | description: 'Relative scale for detecting spam. 0 means that everything is spam, 100 means that nothing is spam', 63 | }) 64 | @IsOptional() 65 | @IsNumber() 66 | @Min(0) 67 | @Max(100) 68 | public spamLevel?: number 69 | 70 | @ApiProperty({ description: 'Account limits' }) 71 | @IsOptional() 72 | @ValidateNested() 73 | @Type((): typeof CreateUpdateAccountLimits => CreateUpdateAccountLimits) 74 | public limits?: CreateUpdateAccountLimits = {} 75 | 76 | @ApiPropertyOptional({ 77 | example: ['imap', 'pop3'], 78 | description: 'List of scopes that are disabled for this user', 79 | }) 80 | @IsOptional() 81 | @IsArray() 82 | @ArrayUnique() 83 | @Matches(new RegExp('^(pop3|imap|smtp)$'), { 84 | each: true, 85 | message: 'each value in disabledScopes must be either pop3, imap, smtp', 86 | }) 87 | public disabledScopes?: ('pop3' | 'imap' | 'smtp')[] 88 | } 89 | -------------------------------------------------------------------------------- /src/accounts/dto/update-account.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator' 3 | 4 | import { CreateUpdateAccountCommonDto } from './create-update-common.dto' 5 | 6 | export class UpdateAccountDto extends CreateUpdateAccountCommonDto { 7 | @ApiPropertyOptional({ example: 'verysecret', description: 'The new password of the email account' }) 8 | @IsOptional() 9 | @IsNotEmpty() 10 | @IsString() 11 | public password?: string 12 | 13 | @ApiPropertyOptional({ 14 | example: false, 15 | description: 'If true then the account can not authenticate or receive any new mail', 16 | }) 17 | @IsOptional() 18 | @IsBoolean() 19 | public disabled?: boolean 20 | } 21 | -------------------------------------------------------------------------------- /src/accounts/params/account-alias.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsMongoId } from 'class-validator' 3 | 4 | import { AccountIdParams } from './account-id.params' 5 | 6 | export class AccountAliasParams extends AccountIdParams { 7 | @ApiProperty({ description: 'Unique id of the alias' }) 8 | @IsMongoId() 9 | public aliasId: string 10 | } 11 | -------------------------------------------------------------------------------- /src/accounts/params/account-id.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsMongoId } from 'class-validator' 3 | 4 | export class AccountIdParams { 5 | @ApiProperty({ description: 'Unique id of the account' }) 6 | @IsMongoId() 7 | public accountId: string 8 | } 9 | -------------------------------------------------------------------------------- /src/api-keys/api-key.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsString } from 'class-validator' 3 | import { ObjectID } from 'mongodb' 4 | import { Column, Entity, ObjectIdColumn } from 'typeorm' 5 | 6 | @Entity('api-keys') 7 | export class ApiKey { 8 | @ObjectIdColumn() 9 | @ApiPropertyOptional({ 10 | example: 'pnx97h6p64t4gau6vbub-', 11 | description: 'Unique id for this api key', 12 | readOnly: true, 13 | }) 14 | public _id?: string 15 | 16 | @Column() 17 | public userId?: ObjectID 18 | 19 | @ApiProperty({ 20 | example: 'API key for my script', 21 | description: 'Name of api key', 22 | }) 23 | @IsString() 24 | @IsNotEmpty() 25 | @Column() 26 | public name: string 27 | 28 | @ApiPropertyOptional({ 29 | example: '2019-09-01T22:12:08.882Z', 30 | description: 'Date the api key was issued', 31 | readOnly: true, 32 | }) 33 | @Column() 34 | public issuedAt?: Date 35 | } 36 | -------------------------------------------------------------------------------- /src/api-keys/api-keys.cli.ts: -------------------------------------------------------------------------------- 1 | import { Command, Console, createSpinner } from 'nestjs-console' 2 | import { UsersService } from 'src/users/users.service' 3 | 4 | import { ApiKeysService } from './api-keys.service' 5 | 6 | @Console() 7 | export class ApiKeysCli { 8 | public constructor(private readonly usersService: UsersService, private readonly apiKeysService: ApiKeysService) {} 9 | 10 | @Command({ 11 | command: 'create-apikey ', 12 | description: 'Create api key for any user', 13 | }) 14 | async createApiKey(username: string, keyName: string): Promise { 15 | const spinner = createSpinner() 16 | 17 | spinner.start(`Getting user info for ${username}`) 18 | const user = await this.usersService.findByUsername(username) 19 | if (user) { 20 | spinner.succeed(`Got user: ${username}`) 21 | } else { 22 | spinner.fail(`No such user: ${username}`) 23 | process.exit(1) 24 | } 25 | 26 | spinner.start('Creating api key') 27 | const apiKey = await this.apiKeysService.generateApiKey(user, keyName) 28 | spinner.succeed('Created api key, dumping details:') 29 | console.log(apiKey) 30 | process.exit(0) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/api-keys/api-keys.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { ApiKeysController } from './api-keys.controller' 4 | 5 | describe('ApiKeys Controller', () => { 6 | let controller: ApiKeysController 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [ApiKeysController], 11 | }).compile() 12 | 13 | controller = module.get(ApiKeysController) 14 | }) 15 | 16 | it('should be defined', () => { 17 | expect(controller).toBeDefined() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/api-keys/api-keys.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiCreatedResponse, 7 | ApiOkResponse, 8 | ApiOperation, 9 | ApiTags, 10 | ApiUnauthorizedResponse, 11 | } from '@nestjs/swagger' 12 | import { ApiKeyAccessToken } from 'src/api-keys/class/api-key-access-token' 13 | import { ApiKeyIdParams } from 'src/api-keys/params/api-key-id.params' 14 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 15 | import { Roles } from 'src/common/decorators/roles.decorator' 16 | import { ForbidApiKeyGuard } from 'src/common/guards/forbid-api-key.guard' 17 | import { RolesGuard } from 'src/common/guards/roles.guard' 18 | import { User } from 'src/users/user.entity' 19 | 20 | import { ApiKey } from './api-key.entity' 21 | import { ApiKeysService } from './api-keys.service' 22 | 23 | @Controller('apikeys') 24 | @ApiTags('Api Keys') 25 | @UseGuards(AuthGuard('jwt')) 26 | @ApiBearerAuth() 27 | @ApiBadRequestResponse({ description: 'Bad user input' }) 28 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 29 | export class ApiKeysController { 30 | constructor(private readonly apiKeysService: ApiKeysService) {} 31 | 32 | @Post() 33 | @ApiOperation({ 34 | operationId: 'createApiKey', 35 | summary: 'Create an API key', 36 | description: 'Note: This resource is forbidden when using an API key as authorization. Use an access token.', 37 | }) 38 | @UseGuards(ForbidApiKeyGuard, RolesGuard) 39 | @Roles('user') 40 | @ApiCreatedResponse({ description: 'API key', type: ApiKeyAccessToken }) 41 | public async createApiKey(@ReqUser() user: User, @Body() apiKey: ApiKey): Promise { 42 | return this.apiKeysService.generateApiKey(user, apiKey.name) 43 | } 44 | 45 | @Get() 46 | @ApiOperation({ operationId: 'getApiKeys', summary: 'List active api keys' }) 47 | @ApiOkResponse({ description: 'List of active api keys', type: ApiKey, isArray: true }) 48 | public async getApiKeys(@ReqUser() user: User): Promise { 49 | return this.apiKeysService.getKeysForUser(user._id.toHexString()) 50 | } 51 | 52 | @Delete(':id') 53 | @ApiOperation({ 54 | operationId: 'revokeApiKey', 55 | summary: 'Revoke api key', 56 | description: 'Note: This resource is forbidden when using an API key as authorization. Use an access token.', 57 | }) 58 | @UseGuards(ForbidApiKeyGuard, RolesGuard) 59 | @Roles('user') 60 | @ApiOkResponse({ description: 'Api key revoked' }) 61 | public async revokeApiKey(@ReqUser() user: User, @Param() apiKeyIdParams: ApiKeyIdParams): Promise { 62 | return this.apiKeysService.revokeKey(user._id.toHexString(), apiKeyIdParams.id) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/api-keys/api-keys.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { JwtModule } from '@nestjs/jwt' 3 | import { TypeOrmModule } from '@nestjs/typeorm' 4 | import { ConfigModule } from 'src/config/config.module' 5 | import { ConfigService } from 'src/config/config.service' 6 | import { UsersModule } from 'src/users/users.module' 7 | 8 | import { ApiKey } from './api-key.entity' 9 | import { ApiKeysCli } from './api-keys.cli' 10 | import { ApiKeysController } from './api-keys.controller' 11 | import { ApiKeysService } from './api-keys.service' 12 | 13 | @Module({ 14 | controllers: [ApiKeysController], 15 | providers: [ApiKeysService, ApiKeysCli], 16 | imports: [ 17 | UsersModule, 18 | TypeOrmModule.forFeature([ApiKey]), 19 | JwtModule.registerAsync({ 20 | imports: [ConfigModule], 21 | inject: [ConfigService], 22 | useFactory: (config: ConfigService) => ({ 23 | secret: config.TOKEN_SECRET, 24 | }), 25 | }), 26 | ], 27 | exports: [ApiKeysService], 28 | }) 29 | export class ApiKeysModule {} 30 | -------------------------------------------------------------------------------- /src/api-keys/api-keys.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { ApiKeysService } from './api-keys.service' 4 | 5 | describe('ApiKeysService', () => { 6 | let service: ApiKeysService 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ApiKeysService], 11 | }).compile() 12 | 13 | service = module.get(ApiKeysService) 14 | }) 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/api-keys/api-keys.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | import { InjectRepository } from '@nestjs/typeorm' 4 | import { ObjectId } from 'mongodb' 5 | import { nanoid as NanoId } from 'nanoid' 6 | import { ApiKeyAccessToken } from 'src/api-keys/class/api-key-access-token' 7 | import { User } from 'src/users/user.entity' 8 | import { MongoRepository } from 'typeorm' 9 | 10 | import { ApiKey } from './api-key.entity' 11 | 12 | @Injectable() 13 | export class ApiKeysService { 14 | constructor( 15 | @InjectRepository(ApiKey) 16 | private readonly apiKeyRepository: MongoRepository, 17 | private readonly jwtService: JwtService, 18 | ) {} 19 | 20 | public async generateApiKey(user: User, name: string): Promise { 21 | const payload = { 22 | sub: user._id.toHexString(), 23 | type: 'api_key', 24 | } 25 | const keyId = NanoId() 26 | const expireDate = new Date() 27 | expireDate.setFullYear(expireDate.getFullYear() + 100) 28 | this.addKey({ 29 | _id: keyId, 30 | issuedAt: new Date(), 31 | name: name, 32 | userId: new ObjectId(user._id), 33 | }) 34 | return { 35 | accessToken: this.jwtService.sign(payload, { 36 | expiresIn: `36500d`, 37 | jwtid: keyId, 38 | }), 39 | details: { 40 | _id: keyId, 41 | issuedAt: new Date(), 42 | name: name, 43 | }, 44 | } 45 | } 46 | 47 | public async addKey(apiKey: ApiKey): Promise { 48 | this.apiKeyRepository.insert(apiKey) 49 | } 50 | 51 | public async getKey(userId: string, keyId: string): Promise { 52 | return this.apiKeyRepository.findOne({ 53 | _id: keyId, 54 | userId: new ObjectId(userId), 55 | }) 56 | } 57 | 58 | public async revokeKey(userId: string, keyId: string): Promise { 59 | this.apiKeyRepository.delete({ 60 | _id: keyId, 61 | userId: new ObjectId(userId), 62 | }) 63 | } 64 | 65 | public async getKeysForUser(userId: string): Promise { 66 | return this.apiKeyRepository.find({ 67 | select: ['_id', 'name', 'issuedAt'], 68 | where: { userId: new ObjectId(userId) }, 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/api-keys/class/api-key-access-token.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Type } from 'class-transformer' 3 | import { ValidateNested } from 'class-validator' 4 | 5 | import { ApiKey } from '../api-key.entity' 6 | 7 | export class ApiKeyAccessToken { 8 | @ApiProperty({ 9 | example: 10 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImpvaG5kb2UiLCJzdWIiOiI1ZDM1ZDczZmU0YTY3NzVmYjQxZmE0ZjEiLCJpYXQiOjE1NjM5MTU0OTgsImV4cCI6MTU2MzkxNTc5OH0.qYejtBl1Tcv9IWgp9Ax5FiR6uT_W0VwizHkB-3S7_r0', 11 | description: 'API key that can be used to authenticate against the api', 12 | }) 13 | public accessToken: string 14 | 15 | @ApiProperty({ 16 | description: 'API key details', 17 | }) 18 | @ValidateNested() 19 | @Type(() => ApiKey) 20 | public details: ApiKey 21 | } 22 | -------------------------------------------------------------------------------- /src/api-keys/params/api-key-id.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsString } from 'class-validator' 3 | 4 | export class ApiKeyIdParams { 5 | @ApiProperty({ 6 | example: 'pnx97h6p64t4gau6vbub-', 7 | description: 'Unique id of the api key', 8 | }) 9 | @IsString() 10 | @IsNotEmpty() 11 | public id: string 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common' 2 | import { ServeStaticModule } from '@nestjs/serve-static' 3 | import { TypeOrmModule } from '@nestjs/typeorm' 4 | import Bull from 'bull' 5 | import Arena from 'bull-arena' 6 | import BasicAuth from 'express-basic-auth' 7 | import { ConsoleModule } from 'nestjs-console' 8 | import { resolve } from 'path' 9 | 10 | import { AccountsModule } from './accounts/accounts.module' 11 | import { ApiKeysModule } from './api-keys/api-keys.module' 12 | import { AuthModule } from './auth/auth.module' 13 | import { ConfigModule } from './config/config.module' 14 | import { ConfigService } from './config/config.service' 15 | import { DkimModule } from './dkim/dkim.module' 16 | import { DomainsModule } from './domains/domains.module' 17 | import { FiltersModule } from './filters/filters.module' 18 | import { ForwardersModule } from './forwarders/forwarders.module' 19 | import { PackagesModule } from './packages/packages.module' 20 | import { TasksModule } from './tasks/delete-for-domain/delete-for-domain.module' 21 | import { SuspensionModule } from './tasks/suspension/suspension.module' 22 | import { UsersModule } from './users/users.module' 23 | 24 | const entityContext = require.context('.', true, /\.entity\.ts$/) 25 | const migrationContext = require.context('.', true, /migrations\/\d*-.*\.ts$/) 26 | 27 | @Module({ 28 | imports: [ 29 | TypeOrmModule.forRootAsync({ 30 | imports: [ConfigModule], 31 | inject: [ConfigService], 32 | useFactory: (config: ConfigService) => ({ 33 | type: 'mongodb', 34 | url: config.MONGODB_URL, 35 | keepConnectionAlive: true, 36 | entities: [ 37 | ...entityContext.keys().map((id) => { 38 | const entityModule = entityContext(id) 39 | const [entity] = Object.values(entityModule) 40 | return entity 41 | }), 42 | ], 43 | migrations: [ 44 | ...migrationContext.keys().map((id) => { 45 | const migrationModule = migrationContext(id) 46 | const [migration] = Object.values(migrationModule) 47 | return migration 48 | }), 49 | ], 50 | migrationsTransactionMode: 'each', 51 | migrationsRun: true, 52 | useNewUrlParser: true, 53 | useUnifiedTopology: true, 54 | appname: 'ducky-api', 55 | }), 56 | }), 57 | ServeStaticModule.forRootAsync({ 58 | useFactory: (config: ConfigService) => { 59 | if (config.SERVE_DUCKYPANEL) { 60 | return [ 61 | { 62 | rootPath: resolve('node_modules/duckypanel/DuckyPanel'), 63 | exclude: [`/${config.BASE_URL}/*`], 64 | }, 65 | ] 66 | } 67 | return [] 68 | }, 69 | imports: [ConfigModule], 70 | inject: [ConfigService], 71 | }), 72 | ConfigModule, 73 | AuthModule, 74 | AccountsModule, 75 | UsersModule, 76 | DomainsModule, 77 | FiltersModule, 78 | DkimModule, 79 | ForwardersModule, 80 | TasksModule, 81 | PackagesModule, 82 | ConsoleModule, 83 | ApiKeysModule, 84 | SuspensionModule, 85 | ], 86 | }) 87 | export class AppModule implements NestModule { 88 | constructor(private readonly config: ConfigService) {} 89 | public configure(consumer: MiddlewareConsumer): void { 90 | if (this.config.QUEUE_UI) { 91 | if (this.config.QUEUE_UI_USER) { 92 | consumer 93 | .apply( 94 | BasicAuth({ 95 | challenge: true, 96 | users: { 97 | [this.config.QUEUE_UI_USER]: this.config.QUEUE_UI_PASSWORD, 98 | }, 99 | }), 100 | ) 101 | .forRoutes(`queues`) 102 | } 103 | 104 | consumer 105 | .apply( 106 | Arena( 107 | { 108 | Bull, 109 | queues: [ 110 | { 111 | name: 'deleteForDomain', 112 | hostId: 'DuckyAPI', 113 | redis: this.config.REDIS_URL, 114 | }, 115 | { 116 | name: 'suspension', 117 | hostId: 'DuckyAPI', 118 | redis: this.config.REDIS_URL, 119 | }, 120 | ], 121 | }, 122 | { 123 | useCdn: false, 124 | disableListen: true, 125 | }, 126 | ), 127 | ) 128 | .forRoutes(`queues`) 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { AuthController } from './auth.controller' 4 | 5 | describe('Auth Controller', (): void => { 6 | let controller: AuthController 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [AuthController], 12 | }).compile() 13 | 14 | controller = module.get(AuthController) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(controller).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Post, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiCreatedResponse, 7 | ApiOkResponse, 8 | ApiOperation, 9 | ApiTags, 10 | ApiUnauthorizedResponse, 11 | } from '@nestjs/swagger' 12 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 13 | import { Roles } from 'src/common/decorators/roles.decorator' 14 | import { ForbidApiKeyGuard } from 'src/common/guards/forbid-api-key.guard' 15 | import { RolesGuard } from 'src/common/guards/roles.guard' 16 | import { User } from 'src/users/user.entity' 17 | 18 | import { ApiKeysService } from '../api-keys/api-keys.service' 19 | import { AuthService } from './auth.service' 20 | import { AccessToken } from './class/access-token.class' 21 | import { LoginDto } from './dto/login.dto' 22 | 23 | @Controller('authentication') 24 | @ApiTags('Authentication') 25 | @ApiBadRequestResponse({ description: 'Bad user input' }) 26 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 27 | export class AuthController { 28 | public constructor(private readonly authService: AuthService, private readonly apiKeysService: ApiKeysService) {} 29 | 30 | @Delete() 31 | @ApiOperation({ 32 | operationId: 'revokeAllAccessTokens', 33 | summary: 'Revoke previous access tokens', 34 | description: 'Note: This resource is forbidden when using an API key as authorization. Use an access token.', 35 | }) 36 | @UseGuards(AuthGuard('jwt'), ForbidApiKeyGuard, RolesGuard) 37 | @Roles('user') 38 | @ApiBearerAuth() 39 | @ApiOkResponse({ description: 'Successfully expired previous tokens' }) 40 | public async revokeAllAccessTokens(@ReqUser() user: User): Promise { 41 | return this.authService.expireTokens(user) 42 | } 43 | 44 | @Post() 45 | @ApiOperation({ operationId: 'getAccessToken', summary: 'Get an access token' }) 46 | @UseGuards(AuthGuard('local')) 47 | @ApiCreatedResponse({ description: 'Login successful', type: AccessToken }) 48 | @ApiUnauthorizedResponse({ description: 'Invalid username or password' }) 49 | public async getAccessToken(@ReqUser() user: User, @Body() loginDto: LoginDto): Promise { 50 | return this.authService.getAccessToken(user, loginDto.rememberMe) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { JwtModule } from '@nestjs/jwt' 3 | import { PassportModule } from '@nestjs/passport' 4 | import { ApiKeysModule } from 'src/api-keys/api-keys.module' 5 | import { ConfigModule } from 'src/config/config.module' 6 | import { ConfigService } from 'src/config/config.service' 7 | import { UsersModule } from 'src/users/users.module' 8 | 9 | import { AuthController } from './auth.controller' 10 | import { AuthService } from './auth.service' 11 | import { JwtStrategy } from './jwt.strategy' 12 | import { LocalStrategy } from './local.strategy' 13 | 14 | @Module({ 15 | imports: [ 16 | UsersModule, 17 | PassportModule, 18 | ApiKeysModule, 19 | JwtModule.registerAsync({ 20 | imports: [ConfigModule], 21 | inject: [ConfigService], 22 | useFactory: (config: ConfigService) => ({ 23 | secret: config.TOKEN_SECRET, 24 | }), 25 | }), 26 | ], 27 | controllers: [AuthController], 28 | providers: [AuthService, LocalStrategy, JwtStrategy], 29 | exports: [AuthService], 30 | }) 31 | export class AuthModule {} 32 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { AuthService } from './auth.service' 4 | 5 | describe('AuthService', (): void => { 6 | let service: AuthService 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [AuthService], 12 | }).compile() 13 | 14 | service = module.get(AuthService) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(service).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | import Bcrypt from 'bcrypt' 4 | import { nanoid as NanoId } from 'nanoid' 5 | import { User } from 'src/users/user.entity' 6 | import { UsersService } from 'src/users/users.service' 7 | 8 | import { AccessToken } from './class/access-token.class' 9 | 10 | @Injectable() 11 | export class AuthService { 12 | public constructor(private readonly usersService: UsersService, private readonly jwtService: JwtService) {} 13 | 14 | public async validateUser(username: string, password: string): Promise { 15 | const user = await this.usersService.findByUsername(username) 16 | if (user && (await Bcrypt.compare(password, user.password))) { 17 | delete user.password 18 | return user 19 | } else { 20 | return null 21 | } 22 | } 23 | 24 | public async getAccessToken(user: User, rememberMe = false): Promise { 25 | const payload = { 26 | sub: user._id.toHexString(), 27 | type: 'access_token', 28 | } 29 | const expireHours = rememberMe ? 7 * 24 : 8 30 | const expireDate = new Date() 31 | expireDate.setHours(expireDate.getHours() + expireHours) 32 | return { 33 | accessToken: this.jwtService.sign(payload, { 34 | expiresIn: `${expireHours}h`, 35 | jwtid: NanoId(), 36 | }), 37 | expires: expireDate, 38 | } 39 | } 40 | 41 | public async expireTokens(user: User): Promise { 42 | this.usersService.updateMinTokenDate(user._id.toHexString()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/auth/class/access-token.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class AccessToken { 4 | @ApiProperty({ 5 | example: 6 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImpvaG5kb2UiLCJzdWIiOiI1ZDM1ZDczZmU0YTY3NzVmYjQxZmE0ZjEiLCJpYXQiOjE1NjM5MTU0OTgsImV4cCI6MTU2MzkxNTc5OH0.qYejtBl1Tcv9IWgp9Ax5FiR6uT_W0VwizHkB-3S7_r0', 7 | description: 'Access token that can be used to authenticate against the api', 8 | }) 9 | public accessToken: string 10 | 11 | @ApiProperty({ 12 | example: '2019-09-01T22:12:08.882Z', 13 | description: 'The expiry date of the access token', 14 | }) 15 | public expires: Date 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator' 3 | 4 | export class LoginDto { 5 | @ApiProperty({ example: 'johndoe', description: 'Username of the user you want to login as' }) 6 | @IsNotEmpty() 7 | @IsString() 8 | public username: string 9 | 10 | @ApiProperty({ example: 'supersecret', description: 'Password of the user you want to login as' }) 11 | @IsNotEmpty() 12 | @IsString() 13 | public password: string 14 | 15 | @ApiPropertyOptional({ example: false, description: 'Makes the token have a longer expiry time' }) 16 | @IsOptional() 17 | @IsBoolean() 18 | public rememberMe?: boolean 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { ExtractJwt, Strategy } from 'passport-jwt' 4 | import { ConfigService } from 'src/config/config.service' 5 | import { User } from 'src/users/user.entity' 6 | import { UsersService } from 'src/users/users.service' 7 | 8 | import { ApiKeysService } from '../api-keys/api-keys.service' 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | public constructor( 13 | private readonly usersService: UsersService, 14 | private readonly config: ConfigService, 15 | private readonly apiKeysService: ApiKeysService, 16 | ) { 17 | super({ 18 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 19 | ignoreExpiration: false, 20 | secretOrKey: config.TOKEN_SECRET, 21 | }) 22 | } 23 | 24 | public async validate(payload: Record): Promise<{ jwt: any; user: User } | null> { 25 | switch (payload.type) { 26 | case 'access_token': 27 | const issuedAt = new Date(payload.iat * 1000) 28 | const user = await this.usersService.findById(payload.sub) 29 | if (user && issuedAt > user.minTokenDate) { 30 | delete user.password 31 | return { 32 | jwt: payload, 33 | user: user, 34 | } 35 | } 36 | return null 37 | 38 | case 'api_key': 39 | if (await this.apiKeysService.getKey(payload.sub, payload.jti)) { 40 | // If api key exists in database 41 | const user = await this.usersService.findById(payload.sub) 42 | if (user) { 43 | delete user.password 44 | return { 45 | jwt: payload, 46 | user: user, 47 | } 48 | } 49 | } 50 | return null 51 | 52 | default: 53 | return null 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { validateOrReject } from 'class-validator' 4 | import { Strategy } from 'passport-local' 5 | 6 | import { AuthService } from './auth.service' 7 | import { LoginDto } from './dto/login.dto' 8 | 9 | @Injectable() 10 | export class LocalStrategy extends PassportStrategy(Strategy) { 11 | public constructor(private readonly authService: AuthService) { 12 | super() 13 | } 14 | 15 | public async validate(username: string, password: string): Promise { 16 | // Use class-validator to validate type, Nestjs doesn't do this automatically here because this is an authguard 17 | const loginDto = new LoginDto() 18 | loginDto.username = username 19 | loginDto.password = password 20 | try { 21 | await validateOrReject(loginDto) 22 | } catch (errors) { 23 | throw new BadRequestException(errors, 'ValidationError') 24 | } 25 | 26 | const user = await this.authService.validateUser(loginDto.username, loginDto.password) 27 | if (!user) { 28 | throw new UnauthorizedException('InvalidLocal') 29 | } 30 | return { 31 | user: user, 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { BootstrapConsole } from 'nestjs-console' 2 | 3 | import { AppModule } from './app.module' 4 | 5 | const bootstrap = new BootstrapConsole({ 6 | module: AppModule, 7 | useDecorators: true, 8 | contextOptions: { 9 | logger: ['error'], 10 | }, 11 | }) 12 | bootstrap.init().then(async (app) => { 13 | try { 14 | // init the app 15 | await app.init() 16 | // boot the cli 17 | await bootstrap.boot() 18 | process.exit(0) 19 | } catch (error) { 20 | console.error(error) 21 | process.exit(1) 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/common/decorators/is-not-suspended.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | 3 | export const IsNotSuspended = (): any => SetMetadata('isNotSuspended', true) 4 | -------------------------------------------------------------------------------- /src/common/decorators/req-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | import { User } from 'src/users/user.entity' 3 | 4 | export const ReqUser = createParamDecorator( 5 | (data: unknown, ctx: ExecutionContext): User => ctx.switchToHttp().getRequest().user.user, 6 | ) 7 | -------------------------------------------------------------------------------- /src/common/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | 3 | export const Roles = (...roles: string[]): any => SetMetadata('roles', roles) 4 | -------------------------------------------------------------------------------- /src/common/filters/unauthorized-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, UnauthorizedException } from '@nestjs/common' 2 | import { Response } from 'express' 3 | 4 | @Catch(UnauthorizedException) 5 | export class UnauthorizedExceptionFilter implements ExceptionFilter { 6 | catch(exception: UnauthorizedException, host: ArgumentsHost): void { 7 | const ctx = host.switchToHttp() 8 | const req = ctx.getRequest() 9 | const response = ctx.getResponse() 10 | const status = exception.getStatus() 11 | 12 | let message: string 13 | 14 | if (exception.message === 'InvalidLocal') { 15 | message = 'Invalid username or password' 16 | } else if (req.authInfo?.name === 'TokenExpiredError') { 17 | message = 'Your access token has expired, please request a new access token' 18 | } else if (req.authInfo?.name === 'JsonWebTokenError') { 19 | message = 'Your access token has an invalid format' 20 | } else if (req.authInfo?.message === 'No auth token') { 21 | message = 'No access token provided, please provide an access token to access this resource' 22 | } else { 23 | message = 'Invalid access token' 24 | } 25 | 26 | response.status(status).json({ 27 | statusCode: status, 28 | message: message, 29 | error: 'UnauthorizedError', 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/common/guards/forbid-api-key.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class ForbidApiKeyGuard implements CanActivate { 5 | canActivate(context: ExecutionContext): boolean { 6 | const request = context.switchToHttp().getRequest() 7 | const jwt = request.user.jwt 8 | if (jwt.type === 'api_key') { 9 | throw new ForbiddenException( 10 | 'This resource is forbidden when using an API key as authorization.', 11 | 'ApiKeyForbidden', 12 | ) 13 | } 14 | return true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/common/guards/is-not-suspended.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | 4 | @Injectable() 5 | export class IsNotSuspendedGuard implements CanActivate { 6 | constructor(private readonly reflector: Reflector) {} 7 | 8 | canActivate(context: ExecutionContext): boolean { 9 | let isNotSuspendedDecorator = this.reflector.get('isNotSuspended', context.getHandler()) 10 | if (!isNotSuspendedDecorator) { 11 | isNotSuspendedDecorator = this.reflector.get('isNotSuspended', context.getClass()) 12 | } 13 | if (!isNotSuspendedDecorator) { 14 | return true 15 | } 16 | 17 | const request = context.switchToHttp().getRequest() 18 | const user = request.user.user 19 | 20 | if (user && !user.suspended) { 21 | return true 22 | } 23 | 24 | throw new ForbiddenException( 25 | 'You do not have the permission to do this, because your account has been suspended.', 26 | 'SuspendedForbiddenError', 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/common/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { User } from 'src/users/user.entity' 4 | 5 | @Injectable() 6 | export class RolesGuard implements CanActivate { 7 | constructor(private readonly reflector: Reflector) {} 8 | 9 | canActivate(context: ExecutionContext): boolean { 10 | let roles = this.reflector.get('roles', context.getHandler()) 11 | if (!roles) { 12 | roles = this.reflector.get('roles', context.getClass()) 13 | } 14 | if (!roles) { 15 | return true 16 | } 17 | const request = context.switchToHttp().getRequest() 18 | const user: User = request.user.user 19 | const hasRole = (): boolean => user.roles.some((role) => roles.includes(role)) 20 | if (user && user.roles && hasRole()) { 21 | return true 22 | } 23 | 24 | if (user.roles.includes('admin') && user.roles.length === 1) { 25 | throw new ForbiddenException( 26 | 'An admin user should only be used for user management. Try logging in as a normal user', 27 | 'PermissionForbiddenError', 28 | ) 29 | } 30 | 31 | throw new ForbiddenException('You do not have the permission to access this resource.', 'PermissionForbiddenError') 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/common/is-email-or-url.validator.ts: -------------------------------------------------------------------------------- 1 | import { isEmail, isURL, ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator' 2 | 3 | @ValidatorConstraint({ async: true }) 4 | export class EachIsEmailOrHttpOrSmtp implements ValidatorConstraintInterface { 5 | public async validate(input: string[]): Promise { 6 | for (const item of input) { 7 | if ( 8 | !( 9 | isEmail(item) || 10 | isURL(item, { 11 | protocols: ['http', 'https', 'smtp', 'smtps'], 12 | require_protocol: true, 13 | }) 14 | ) 15 | ) { 16 | // Item is not an email or an url with a valid protocol 17 | return false 18 | } 19 | } 20 | // None of the items returned false, all items are valid 21 | return true 22 | } 23 | 24 | public defaultMessage(args: ValidationArguments): string { 25 | return `Each item in ${args.property} must be either an email, http(s) url, smtp(s) url` 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { ConfigService } from './config.service' 4 | 5 | @Global() 6 | @Module({ 7 | providers: [ 8 | { 9 | provide: ConfigService, 10 | useValue: new ConfigService(`config/${process.env.NODE_ENV || 'development'}.env`), 11 | }, 12 | ], 13 | exports: [ConfigService], 14 | }) 15 | export class ConfigModule {} 16 | -------------------------------------------------------------------------------- /src/config/config.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { ConfigService } from './config.service' 4 | 5 | describe('ConfigService', () => { 6 | let service: ConfigService 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ConfigService], 11 | }).compile() 12 | 13 | service = module.get(ConfigService) 14 | }) 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common' 2 | import { classToClass, plainToClass } from 'class-transformer' 3 | import { validateOrReject } from 'class-validator' 4 | import dotenv from 'dotenv' 5 | import fs from 'fs' 6 | 7 | import { DuckyApiConfig } from './ducky-api-config.class' 8 | 9 | export type EnvConfig = Record 10 | 11 | @Injectable() 12 | export class ConfigService extends DuckyApiConfig { 13 | constructor(filePath: string) { 14 | super() 15 | const config = dotenv.parse(fs.readFileSync(filePath)) 16 | this.validateConfig(config) 17 | } 18 | 19 | private readonly logger = new Logger(ConfigService.name, true) 20 | 21 | /** 22 | * Ensures all needed variables are set, and assigns them to this class 23 | */ 24 | private async validateConfig(envConfig: EnvConfig): Promise { 25 | // Run the transformer twice, as the transform decorator seems to run after the type decorator 26 | let duckyApiConfig = plainToClass(DuckyApiConfig, envConfig) 27 | duckyApiConfig = classToClass(duckyApiConfig) 28 | 29 | try { 30 | await validateOrReject(duckyApiConfig, { validationError: { target: false, value: false } }) 31 | } catch (errors) { 32 | this.logger.error('Configuration validation failed') 33 | this.logger.error(errors) 34 | process.exit(1) 35 | } 36 | Object.assign(this, duckyApiConfig) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/config/ducky-api-config.class.ts: -------------------------------------------------------------------------------- 1 | import { Transform, Type } from 'class-transformer' 2 | import { 3 | ArrayMinSize, 4 | IsArray, 5 | IsBoolean, 6 | IsNotEmpty, 7 | IsNumber, 8 | IsOptional, 9 | IsString, 10 | IsUrl, 11 | Matches, 12 | Min, 13 | NotContains, 14 | Validate, 15 | ValidateIf, 16 | ValidateNested, 17 | ValidationArguments, 18 | ValidatorConstraint, 19 | ValidatorConstraintInterface, 20 | } from 'class-validator' 21 | import { DnsCheckMxRecord } from 'src/domains/class/dns.class' 22 | 23 | const jsonParse = (value: any): any => { 24 | try { 25 | return JSON.parse(value) 26 | } catch (error) { 27 | return value 28 | } 29 | } 30 | 31 | @ValidatorConstraint({ name: 'customBaseurlIfDuckpanel', async: false }) 32 | class CustomBaseurlIfDuckpanel implements ValidatorConstraintInterface { 33 | validate(text: string, args: ValidationArguments): boolean { 34 | const config = args.object as DuckyApiConfig 35 | if (config.SERVE_DUCKYPANEL) { 36 | return text !== '' 37 | } else { 38 | return true 39 | } 40 | } 41 | 42 | defaultMessage(): string { 43 | return 'You need to specify a custom BASE_URL when SERVE_DUCKYPANEL is set to true' 44 | } 45 | } 46 | 47 | export class DuckyApiConfig { 48 | @IsNotEmpty() 49 | @IsString() 50 | @Matches(new RegExp('^(development|production|test|provision)$')) 51 | NODE_ENV: 'development' | 'production' | 'test' | 'provision' = 'development' 52 | 53 | @Transform(jsonParse, { toClassOnly: true }) 54 | @IsNumber() 55 | PORT = 3000 56 | 57 | @Transform(jsonParse, { toClassOnly: true }) 58 | @IsBoolean() 59 | SERVE_DUCKYPANEL = false 60 | 61 | @Transform((value: string) => { 62 | // Remove leading and trailing slash 63 | if (value.endsWith('/')) { 64 | value = value.slice(0, -1) 65 | } 66 | if (value.startsWith('/')) { 67 | value = value.slice(1) 68 | } 69 | return value 70 | }) 71 | @IsString() 72 | @Validate(CustomBaseurlIfDuckpanel) 73 | BASE_URL = '/' 74 | 75 | @IsNotEmpty() 76 | @IsString() 77 | @NotContains('CHANGE-ME-PLEASE!', { 78 | message: 'Set TOKEN_SECRET to something safe and random!', 79 | }) 80 | TOKEN_SECRET: string 81 | 82 | @IsNotEmpty() 83 | @IsString() 84 | MONGODB_URL: string 85 | 86 | @IsNotEmpty() 87 | @IsString() 88 | REDIS_URL: string 89 | 90 | @IsNotEmpty() 91 | @IsString() 92 | @IsUrl({ require_tld: false }) 93 | WILDDUCK_API_URL: string 94 | 95 | @IsString() 96 | @IsOptional() 97 | WILDDUCK_API_TOKEN = '' 98 | 99 | @Transform(jsonParse, { toClassOnly: true }) 100 | @IsBoolean() 101 | ALLOW_UNSAFE_ACCOUNT_PASSWORDS = true 102 | 103 | @Transform(jsonParse, { toClassOnly: true }) 104 | @IsBoolean() 105 | ALLOW_FORWARDER_WILDCARD = true 106 | 107 | @Transform(jsonParse, { toClassOnly: true }) 108 | @IsBoolean() 109 | ALLOW_ACCOUNT_WILDCARD = true 110 | 111 | @Transform(jsonParse, { toClassOnly: true }) 112 | @IsBoolean() 113 | QUEUE_UI = false 114 | 115 | @ValidateIf((config: DuckyApiConfig) => config.QUEUE_UI === true) 116 | @IsOptional() 117 | @IsNotEmpty() 118 | @IsString() 119 | QUEUE_UI_USER?: string 120 | 121 | @ValidateIf((config: DuckyApiConfig) => config.QUEUE_UI === true && config.QUEUE_UI_USER !== undefined) 122 | @IsNotEmpty() 123 | @IsString() 124 | QUEUE_UI_PASSWORD: string 125 | 126 | @Transform(jsonParse, { toClassOnly: true }) 127 | @Type(() => DnsCheckMxRecord) 128 | @IsArray() 129 | @ArrayMinSize(1) 130 | @ValidateNested() 131 | MX_RECORDS: DnsCheckMxRecord[] 132 | 133 | @IsNotEmpty() 134 | @IsString() 135 | SPF_CORRECT_VALUE: string 136 | 137 | @IsOptional() 138 | @IsString() 139 | SPF_REGEX: string 140 | 141 | @IsOptional() 142 | @IsString() 143 | @IsNotEmpty() 144 | DEFAULT_DKIM_SELECTOR: string 145 | 146 | @Transform(jsonParse, { toClassOnly: true }) 147 | @IsOptional() 148 | @IsNumber() 149 | @Min(0) 150 | DELAY: number 151 | } 152 | -------------------------------------------------------------------------------- /src/dkim/class/dkim-key.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | class DnsTxt { 4 | @ApiProperty({ 5 | example: 'ducky._domainkey.example.com', 6 | description: 'Domain name to which the TXT record should be added', 7 | }) 8 | public name: string 9 | 10 | @ApiProperty({ example: 'v=DKIM1;t=s;p=MIGfMA0...', description: 'Value of the TXT record' }) 11 | public value: string 12 | } 13 | 14 | export class DkimKey { 15 | @ApiProperty({ example: '59ef21aef255ed1d9d790e7a', description: 'Unique id of the DKIM key' }) 16 | public id: string 17 | 18 | @ApiProperty({ example: 'example.com', description: 'The domain this DKIM key applies to' }) 19 | public domain: string 20 | 21 | @ApiProperty({ example: 'ducky', description: 'DKIM selector' }) 22 | public selector: string 23 | 24 | @ApiProperty({ 25 | example: '6a:aa:d7:ba:e4:99:b4:12:e0:f3:35:01:71:d4:f1:d6:b4:95:c4:f5', 26 | description: 'Unique id of the DKIM key', 27 | }) 28 | public fingerprint: string 29 | 30 | @ApiProperty({ 31 | example: '-----BEGIN PUBLIC KEY-----\r\nMIGfMA0...', 32 | description: 'Public key in DNS format (no prefix/suffix, single line)', 33 | }) 34 | public publicKey: string 35 | 36 | @ApiProperty({ type: DnsTxt, description: 'Value for the DNS TXT record' }) 37 | public dnsTxt: DnsTxt 38 | 39 | @ApiProperty({ 40 | example: '2017-10-24T11:19:10.911Z', 41 | description: 'Datestring of the time the DKIM key was created', 42 | }) 43 | public created: string 44 | } 45 | -------------------------------------------------------------------------------- /src/dkim/dkim.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { DkimController } from './dkim.controller' 4 | 5 | describe('Dkim Controller', (): void => { 6 | let controller: DkimController 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [DkimController], 12 | }).compile() 13 | 14 | controller = module.get(DkimController) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(controller).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/dkim/dkim.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Put, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiNotFoundResponse, 7 | ApiOkResponse, 8 | ApiOperation, 9 | ApiTags, 10 | ApiUnauthorizedResponse, 11 | } from '@nestjs/swagger' 12 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 13 | import { Roles } from 'src/common/decorators/roles.decorator' 14 | import { RolesGuard } from 'src/common/guards/roles.guard' 15 | import { User } from 'src/users/user.entity' 16 | 17 | import { DkimKey } from './class/dkim-key.class' 18 | import { DkimService } from './dkim.service' 19 | import { AddDkimDto } from './dto/add-dkim.dto' 20 | import { DkimParams } from './params/dkim.params' 21 | 22 | @Controller('domains/:domainOrAlias/dkim') 23 | @ApiTags('Dkim') 24 | @UseGuards(AuthGuard('jwt'), RolesGuard) 25 | @Roles('user') 26 | @ApiBearerAuth() 27 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 28 | @ApiBadRequestResponse({ description: 'Bad user input' }) 29 | @ApiNotFoundResponse({ description: 'Domain not found in account' }) 30 | export class DkimController { 31 | public constructor(private readonly dkimService: DkimService) {} 32 | 33 | @Delete() 34 | @ApiOperation({ operationId: 'deleteDkim', summary: 'Delete DKIM key for a domain' }) 35 | @ApiOkResponse({ description: 'DKIM key successfully deleted' }) 36 | public async deleteDkim(@ReqUser() user: User, @Param() dkimParams: DkimParams): Promise { 37 | return this.dkimService.deleteDkim(user, dkimParams.domainOrAlias) 38 | } 39 | 40 | @Get() 41 | @ApiOperation({ operationId: 'getDkim', summary: 'Get DKIM key info for a domain' }) 42 | @ApiOkResponse({ description: 'DKIM key info', type: DkimKey }) 43 | public async getDkim(@ReqUser() user: User, @Param() dkimParams: DkimParams): Promise { 44 | return this.dkimService.getDKIM(user, dkimParams.domainOrAlias) 45 | } 46 | 47 | @Put() 48 | @ApiOperation({ operationId: 'updateDkim', summary: 'Add or update DKIM key for a domain' }) 49 | @ApiOkResponse({ description: 'DKIM key info', type: DkimKey }) 50 | public async updateDkim( 51 | @ReqUser() user: User, 52 | @Body() addDkimDto: AddDkimDto, 53 | @Param() dkimParams: DkimParams, 54 | ): Promise { 55 | return this.dkimService.updateDkim(user, addDkimDto, dkimParams.domainOrAlias) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/dkim/dkim.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, HttpModule, Module } from '@nestjs/common' 2 | import { ConfigModule } from 'src/config/config.module' 3 | import { ConfigService } from 'src/config/config.service' 4 | import { DomainsModule } from 'src/domains/domains.module' 5 | 6 | import { DkimController } from './dkim.controller' 7 | import { DkimService } from './dkim.service' 8 | 9 | @Module({ 10 | imports: [ 11 | forwardRef(() => DomainsModule), 12 | HttpModule.registerAsync({ 13 | imports: [ConfigModule], 14 | inject: [ConfigService], 15 | useFactory: (config: ConfigService) => ({ 16 | timeout: 10000, 17 | maxRedirects: 5, 18 | baseURL: config.WILDDUCK_API_URL, 19 | headers: { 20 | 'X-Access-Token': config.WILDDUCK_API_TOKEN, 21 | }, 22 | }), 23 | }), 24 | ], 25 | exports: [DkimService], 26 | controllers: [DkimController], 27 | providers: [DkimService], 28 | }) 29 | export class DkimModule {} 30 | -------------------------------------------------------------------------------- /src/dkim/dkim.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { DkimService } from './dkim.service' 4 | 5 | describe('DkimService', (): void => { 6 | let service: DkimService 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [DkimService], 12 | }).compile() 13 | 14 | service = module.get(DkimService) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(service).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/dkim/dkim.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpService, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' 2 | import { AxiosResponse } from 'axios' 3 | import { ConfigService } from 'src/config/config.service' 4 | import { DomainsService } from 'src/domains/domains.service' 5 | import { User } from 'src/users/user.entity' 6 | 7 | import { DkimKey } from './class/dkim-key.class' 8 | import { AddDkimDto } from './dto/add-dkim.dto' 9 | 10 | @Injectable() 11 | export class DkimService { 12 | private readonly logger = new Logger(DkimService.name, true) 13 | 14 | public constructor( 15 | private readonly httpService: HttpService, 16 | private readonly config: ConfigService, 17 | private readonly domainsService: DomainsService, 18 | ) {} 19 | 20 | public async resolveDkimId(domain: string): Promise { 21 | let ApiResponse: AxiosResponse 22 | try { 23 | ApiResponse = await this.httpService.get(`/dkim/resolve/${domain}`).toPromise() 24 | } catch (error) { 25 | this.logger.error(error.message) 26 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 27 | } 28 | 29 | if (ApiResponse.data.error || !ApiResponse.data.success) { 30 | switch (ApiResponse.data.code) { 31 | case 'DkimNotFound': 32 | throw new NotFoundException(`No DKIM key found for domain: ${domain}`, 'DkimNotFoundError') 33 | 34 | default: 35 | this.logger.error(ApiResponse.data) 36 | throw new InternalServerErrorException('Unknown error') 37 | } 38 | } 39 | 40 | return ApiResponse.data.id 41 | } 42 | 43 | public async deleteDkim(user: User, domain: string): Promise { 44 | await this.domainsService.checkIfDomainIsAddedToUser(user, domain, true) 45 | 46 | const dkimId = await this.resolveDkimId(domain) 47 | 48 | let apiResponse: AxiosResponse 49 | try { 50 | apiResponse = await this.httpService.delete(`/dkim/${dkimId}`).toPromise() 51 | } catch (error) { 52 | this.logger.error(error.message) 53 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 54 | } 55 | 56 | if (apiResponse.data.error || !apiResponse.data.success) { 57 | switch (apiResponse.data.code) { 58 | case 'DkimNotFound': 59 | throw new NotFoundException(`No DKIM key found for domain: ${domain}`, 'DkimNotFoundError') 60 | 61 | default: 62 | this.logger.error(apiResponse.data) 63 | throw new InternalServerErrorException('Unknown error') 64 | } 65 | } 66 | } 67 | 68 | public async getDKIM(user: User, domain: string): Promise { 69 | await this.domainsService.checkIfDomainIsAddedToUser(user, domain, true) 70 | 71 | const dkimId = await this.resolveDkimId(domain) 72 | 73 | let apiResponse: AxiosResponse 74 | try { 75 | apiResponse = await this.httpService.get(`/dkim/${dkimId}`).toPromise() 76 | } catch (error) { 77 | this.logger.error(error.message) 78 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 79 | } 80 | 81 | if (apiResponse.data.error || !apiResponse.data.success) { 82 | switch (apiResponse.data.code) { 83 | case 'DkimNotFound': 84 | throw new NotFoundException(`No DKIM key found for domain: ${domain}`, 'DkimNotFoundError') 85 | 86 | default: 87 | this.logger.error(apiResponse.data) 88 | throw new InternalServerErrorException('Unknown error') 89 | } 90 | } 91 | 92 | return { 93 | id: apiResponse.data.id, 94 | domain: apiResponse.data.domain, 95 | selector: apiResponse.data.selector, 96 | fingerprint: apiResponse.data.fingerprint, 97 | publicKey: apiResponse.data.publicKey, 98 | dnsTxt: { 99 | name: apiResponse.data.dnsTxt.name, 100 | value: apiResponse.data.dnsTxt.value, 101 | }, 102 | created: apiResponse.data.created, 103 | } 104 | } 105 | 106 | public async updateDkim(user: User, addDkimDto: AddDkimDto, domain: string): Promise { 107 | await this.domainsService.checkIfDomainIsAddedToUser(user, domain, true) 108 | 109 | let apiResponse: AxiosResponse 110 | try { 111 | apiResponse = await this.httpService 112 | .post(`/dkim`, { 113 | domain: domain, 114 | selector: addDkimDto.selector, 115 | privateKey: addDkimDto.privateKey, 116 | }) 117 | .toPromise() 118 | } catch (error) { 119 | this.logger.error(error.message) 120 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 121 | } 122 | 123 | if (apiResponse.data.error || !apiResponse.data.success) { 124 | switch (apiResponse.data.code) { 125 | case 'DkimNotFound': 126 | throw new NotFoundException(`No DKIM key found for domain: ${domain}`, 'DkimNotFoundError') 127 | 128 | default: 129 | this.logger.error(apiResponse.data) 130 | throw new InternalServerErrorException('Unknown error') 131 | } 132 | } 133 | 134 | return { 135 | id: apiResponse.data.id, 136 | domain: apiResponse.data.domain, 137 | selector: apiResponse.data.selector, 138 | fingerprint: apiResponse.data.fingerprint, 139 | publicKey: apiResponse.data.publicKey, 140 | dnsTxt: { 141 | name: apiResponse.data.dnsTxt.name, 142 | value: apiResponse.data.dnsTxt.value, 143 | }, 144 | created: apiResponse.data.created, 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/dkim/dto/add-dkim.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator' 3 | 4 | export class AddDkimDto { 5 | @ApiProperty({ example: 'default', description: 'Selector for dkim key' }) 6 | @IsNotEmpty() 7 | @IsString() 8 | public selector: string 9 | 10 | @ApiPropertyOptional({ 11 | example: '-----BEGIN RSA PRIVATE KEY-----...', 12 | description: 13 | 'Pem formatted DKIM private key. If not set then a new 2048 bit RSA key is generated, beware though that it can take several seconds to complete', 14 | }) 15 | @IsOptional() 16 | @IsString() 17 | @Matches(new RegExp('^-----BEGIN (RSA )?PRIVATE KEY-----'), { 18 | message: 'privateKey should be a pem formatted private key', 19 | }) 20 | public privateKey?: string 21 | } 22 | -------------------------------------------------------------------------------- /src/dkim/params/dkim.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsFQDN, IsNotEmpty } from 'class-validator' 3 | 4 | export class DkimParams { 5 | @ApiProperty({ description: 'example.com' }) 6 | @IsNotEmpty() 7 | @IsFQDN() 8 | public domainOrAlias: string 9 | } 10 | -------------------------------------------------------------------------------- /src/domains/class/dns.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator' 3 | 4 | export class DnsCheckMxRecord { 5 | @ApiProperty({ example: 'mx.example.com', description: 'MX record server' }) 6 | @IsString() 7 | @IsNotEmpty() 8 | public exchange: string 9 | 10 | @ApiProperty({ example: 1, description: 'MX record priority' }) 11 | @IsNumber() 12 | @Min(0) 13 | public priority: number 14 | } 15 | 16 | class DnsCheckDkimRecord { 17 | @ApiProperty({ example: 'default', description: 'DKIM record selector' }) 18 | public selector: string 19 | 20 | @ApiProperty({ 21 | example: 22 | 'v=DKIM1;t=s;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAseRvI//jDgRsZ1BtGixcLO16/B8yEzsgVSBvCWgwf39LRAey14eZLoyyolX7wVUe71VN67cEuey7XlYGHzGntDtLh/CmI8vvDaiym0VNv8zrZok2TbYW0I4Ts9YkNtCUC5EKjyrwX7AT97ZjiXVX6JK+oEmdtgwxtrQc9+trYj3udlStEmpH0yluY3kSmUYDe3e4TEdLUX7+x/i4D8+65dIXdw52cRNka9aMpH7ZdsfPvrFd6y+ItOuX1Zsb8uFdQz21/Tf1aVczwbZgpUFfpyt55erLwfFLdlH7aRwBIJGQDMzl4SFkGgxDuSPjUePHO266PiHm2/r8A0515n3ZCwIDAQAB', 23 | description: 'DKIM record value', 24 | }) 25 | public value: string 26 | } 27 | 28 | class DnsCheckCurrentValues { 29 | @ApiProperty({ description: 'List of DNS records', type: DnsCheckMxRecord, isArray: true }) 30 | public mx: DnsCheckMxRecord[] 31 | 32 | @ApiProperty({ example: 'v=spf1 include:example.com -all', description: 'Value of the SPF record' }) 33 | public spf: string 34 | 35 | @ApiPropertyOptional({ description: 'DKIM record selector and value' }) 36 | public dkim?: DnsCheckDkimRecord 37 | } 38 | 39 | class DnsCheckError { 40 | @ApiProperty({ example: 'dkim', description: 'Type of error/warning. Can be ns, mx, spf, dkim' }) 41 | public type: 'ns' | 'mx' | 'spf' | 'dkim' 42 | 43 | @ApiProperty({ example: 'DkimNotFound', description: 'Machine readable error/warning string' }) 44 | public error: string 45 | 46 | @ApiProperty({ 47 | example: 'DKIM is enabled, but no record was found', 48 | description: 'Human readable error/warning message', 49 | }) 50 | public message: string 51 | } 52 | 53 | class DnsCheckWarning extends DnsCheckError {} 54 | class DnsCheckCorrectValues extends DnsCheckCurrentValues {} 55 | 56 | export class DnsCheck { 57 | @ApiProperty({ description: 'Current values of the DNS records' }) 58 | public currentValues: DnsCheckCurrentValues 59 | 60 | @ApiProperty({ description: 'Correct values of the DNS records' }) 61 | public correctValues: DnsCheckCorrectValues 62 | 63 | @ApiProperty({ description: 'List of errors with the DNS records', type: DnsCheckError, isArray: true }) 64 | public errors: DnsCheckError[] 65 | 66 | @ApiProperty({ description: 'List of warnings with the DNS records', type: DnsCheckError, isArray: true }) 67 | public warnings: DnsCheckWarning[] 68 | } 69 | -------------------------------------------------------------------------------- /src/domains/domain.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsFQDN, IsNotEmpty } from 'class-validator' 3 | import { Column } from 'typeorm' 4 | 5 | export class DomainAlias { 6 | @Column() 7 | @ApiProperty({ example: 'example.com', description: 'The domain name' }) 8 | @IsNotEmpty() 9 | @IsFQDN() 10 | public domain: string 11 | 12 | @ApiPropertyOptional({ example: false, readOnly: true, description: 'If DKIM is active for this domain' }) 13 | public dkim?: boolean 14 | } 15 | 16 | export class Domain extends DomainAlias { 17 | @Column() 18 | @ApiPropertyOptional({ 19 | example: true, 20 | readOnly: true, 21 | description: 'If this user is the domain admin, this currently serves no function', 22 | }) 23 | public admin?: boolean 24 | 25 | @Column(() => DomainAlias) 26 | @ApiPropertyOptional({ 27 | description: 'Domains aliased to this domain', 28 | readOnly: true, 29 | type: DomainAlias, 30 | isArray: true, 31 | }) 32 | public aliases?: DomainAlias[] 33 | } 34 | -------------------------------------------------------------------------------- /src/domains/domains.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { DomainsController } from './domains.controller' 4 | 5 | describe('Domains Controller', (): void => { 6 | let controller: DomainsController 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [DomainsController], 12 | }).compile() 13 | 14 | controller = module.get(DomainsController) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(controller).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/domains/domains.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiCreatedResponse, 7 | ApiNotFoundResponse, 8 | ApiOkResponse, 9 | ApiOperation, 10 | ApiTags, 11 | ApiUnauthorizedResponse, 12 | } from '@nestjs/swagger' 13 | import { IsNotSuspended } from 'src/common/decorators/is-not-suspended.decorator' 14 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 15 | import { Roles } from 'src/common/decorators/roles.decorator' 16 | import { IsNotSuspendedGuard } from 'src/common/guards/is-not-suspended.guard' 17 | import { RolesGuard } from 'src/common/guards/roles.guard' 18 | import { User } from 'src/users/user.entity' 19 | 20 | import { DnsCheck } from './class/dns.class' 21 | import { Domain, DomainAlias } from './domain.entity' 22 | import { DomainsService } from './domains.service' 23 | import { DomainAliasParams } from './params/alias.params' 24 | import { DomainParams } from './params/domain.params' 25 | 26 | @Controller('domains') 27 | @ApiTags('Domains') 28 | @UseGuards(AuthGuard('jwt'), RolesGuard, IsNotSuspendedGuard) 29 | @Roles('user') 30 | @ApiBearerAuth() 31 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 32 | @ApiBadRequestResponse({ description: 'Bad user input' }) 33 | export class DomainsController { 34 | public constructor(private readonly domainsService: DomainsService) {} 35 | @Delete(':domain') 36 | @ApiOperation({ 37 | operationId: 'deleteDomain', 38 | summary: 'Delete a domain', 39 | description: 40 | 'WARNING: This will also delete any email accounts, forwarders, and DKIM keys associated with this domain', 41 | }) 42 | @ApiOkResponse({ description: 'Domain successfully deleted' }) 43 | @ApiNotFoundResponse({ description: 'Domain not found on user' }) 44 | private async deleteDomain(@ReqUser() user: User, @Param() domainParams: DomainParams): Promise { 45 | return this.domainsService.deleteDomain(user, domainParams.domain) 46 | } 47 | 48 | @Get() 49 | @ApiOperation({ operationId: 'getDomains', summary: 'List domains' }) 50 | @ApiOkResponse({ description: 'A list of domains', type: Domain, isArray: true }) 51 | private async getDomains(@ReqUser() user: User): Promise { 52 | return this.domainsService.getDomains(user) 53 | } 54 | 55 | @Post() 56 | @IsNotSuspended() 57 | @ApiOperation({ operationId: 'addDomain', summary: 'Add a domain' }) 58 | @ApiCreatedResponse({ description: 'Domain successfully added' }) 59 | private async addDomain(@ReqUser() user: User, @Body() domainDto: Domain): Promise { 60 | return this.domainsService.addDomain(user, domainDto.domain) 61 | } 62 | 63 | @Get(':domain/DNS') 64 | @ApiOperation({ operationId: 'checkDNS', summary: 'Get and check DNS records' }) 65 | @ApiOkResponse({ description: 'The current and the correct DNS records for this domain', type: DnsCheck }) 66 | @ApiNotFoundResponse({ description: 'Domain not found on user' }) 67 | private async checkDNS(@ReqUser() user: User, @Param() domainParams: DomainParams): Promise { 68 | return this.domainsService.checkDns(user, domainParams.domain) 69 | } 70 | 71 | @Post(':domain/aliases') 72 | @IsNotSuspended() 73 | @ApiOperation({ operationId: 'addAlias', summary: 'Add a domain alias' }) 74 | @ApiCreatedResponse({ description: 'Alias successfully added' }) 75 | @ApiNotFoundResponse({ description: 'Domain not found on user' }) 76 | private async addAlias( 77 | @ReqUser() user: User, 78 | @Param() domainParams: DomainParams, 79 | @Body() domainAlias: DomainAlias, 80 | ): Promise { 81 | await this.domainsService.addAlias(user, domainParams.domain, domainAlias.domain) 82 | } 83 | 84 | @Delete(':domain/aliases/:alias') 85 | @ApiOperation({ operationId: 'deleteAlias', summary: 'Delete a domain alias' }) 86 | @ApiOkResponse({ description: 'Alias successfully deleted' }) 87 | @ApiNotFoundResponse({ description: 'Domain not found on user' }) 88 | private async deleteAlias(@ReqUser() user: User, @Param() domainAliasParams: DomainAliasParams): Promise { 89 | await this.domainsService.deleteAlias(user, domainAliasParams.domain, domainAliasParams.alias) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/domains/domains.module.ts: -------------------------------------------------------------------------------- 1 | import { BullModule } from '@nestjs/bull' 2 | import { forwardRef, HttpModule, Module } from '@nestjs/common' 3 | import { AccountsModule } from 'src/accounts/accounts.module' 4 | import { ConfigModule } from 'src/config/config.module' 5 | import { ConfigService } from 'src/config/config.service' 6 | import { DkimModule } from 'src/dkim/dkim.module' 7 | import { ForwardersModule } from 'src/forwarders/forwarders.module' 8 | import { DeleteForDomainConfigService } from 'src/tasks/delete-for-domain/delete-for-domain-config.service' 9 | import { UsersModule } from 'src/users/users.module' 10 | 11 | import { DomainsController } from './domains.controller' 12 | import { DomainsService } from './domains.service' 13 | 14 | @Module({ 15 | imports: [ 16 | HttpModule.registerAsync({ 17 | imports: [ConfigModule], 18 | inject: [ConfigService], 19 | useFactory: (config: ConfigService) => ({ 20 | timeout: 10000, 21 | maxRedirects: 5, 22 | baseURL: config.WILDDUCK_API_URL, 23 | headers: { 24 | 'X-Access-Token': config.WILDDUCK_API_TOKEN, 25 | }, 26 | }), 27 | }), 28 | UsersModule, 29 | forwardRef(() => AccountsModule), 30 | forwardRef(() => DkimModule), 31 | ForwardersModule, 32 | BullModule.registerQueueAsync({ 33 | name: 'deleteForDomain', 34 | useClass: DeleteForDomainConfigService, 35 | }), 36 | ], 37 | controllers: [DomainsController], 38 | providers: [DomainsService], 39 | exports: [DomainsService], 40 | }) 41 | export class DomainsModule {} 42 | -------------------------------------------------------------------------------- /src/domains/domains.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { DomainsService } from './domains.service' 4 | 5 | describe('DomainsService', (): void => { 6 | let service: DomainsService 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [DomainsService], 12 | }).compile() 13 | 14 | service = module.get(DomainsService) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(service).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/domains/params/alias.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsFQDN, IsNotEmpty } from 'class-validator' 3 | 4 | export class DomainAliasParams { 5 | @ApiProperty({ description: 'example.com' }) 6 | @IsNotEmpty() 7 | @IsFQDN() 8 | public domain: string 9 | 10 | @ApiProperty({ description: 'example.com' }) 11 | @IsNotEmpty() 12 | @IsFQDN() 13 | public alias: string 14 | } 15 | -------------------------------------------------------------------------------- /src/domains/params/domain.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsFQDN, IsNotEmpty } from 'class-validator' 3 | 4 | export class DomainParams { 5 | @ApiProperty({ description: 'example.com' }) 6 | @IsNotEmpty() 7 | @IsFQDN() 8 | public domain: string 9 | } 10 | -------------------------------------------------------------------------------- /src/filters/class/filter-details.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | import { CreateUpdateFilterDto } from '../dto/create-update-filter.dto' 4 | 5 | export class FilterDetails extends CreateUpdateFilterDto { 6 | @ApiProperty({ example: '5a1c0ee490a34c67e266931c', description: 'Unique id of the filter' }) 7 | public id: string 8 | } 9 | -------------------------------------------------------------------------------- /src/filters/class/filter-list-item.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | import { Filter } from './filter.class' 4 | 5 | export class FilterListItem extends Filter { 6 | @ApiProperty({ example: '5a1c0ee490a34c67e266931c', description: 'Unique id of the filter' }) 7 | public id: string 8 | 9 | @ApiProperty({ 10 | example: [ 11 | ['from', '(John)'], 12 | ['to', '(John)'], 13 | ], 14 | description: 'A list of query descriptions', 15 | }) 16 | public query: string[][] 17 | 18 | @ApiProperty({ 19 | example: [['mark it as spam'], ['forward to', 'johndoe@example.com, smtp://mx.example.com:25, example.com']], 20 | description: 'A list of action descriptions', 21 | }) 22 | public action: string[][] 23 | 24 | @ApiProperty({ 25 | example: '2019-08-14T15:14:25.176Z', 26 | description: 'Datestring of the time the filter was created', 27 | }) 28 | public created: string 29 | } 30 | -------------------------------------------------------------------------------- /src/filters/class/filter.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsOptional, IsString } from 'class-validator' 3 | 4 | export class Filter { 5 | @ApiPropertyOptional({ example: 'Mark as seen from John', description: 'The name of the filter' }) 6 | @IsOptional() 7 | @IsString() 8 | public name?: string 9 | 10 | @ApiPropertyOptional({ example: false, description: 'If true, then this filter is ignored' }) 11 | @IsOptional() 12 | @IsBoolean() 13 | public disabled?: boolean 14 | } 15 | -------------------------------------------------------------------------------- /src/filters/dto/create-update-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { Type } from 'class-transformer' 3 | import { 4 | ArrayUnique, 5 | IsArray, 6 | IsBoolean, 7 | IsDefined, 8 | IsMongoId, 9 | IsNumber, 10 | IsOptional, 11 | IsString, 12 | Validate, 13 | ValidateIf, 14 | ValidateNested, 15 | } from 'class-validator' 16 | import { EachIsEmailOrHttpOrSmtp } from 'src/common/is-email-or-url.validator' 17 | 18 | import { Filter } from '../class/filter.class' 19 | 20 | class Query { 21 | @ApiPropertyOptional({ 22 | example: 'John', 23 | description: 'Partial match for the From: header (case insensitive)', 24 | }) 25 | @IsOptional() 26 | @IsString() 27 | public from?: string 28 | 29 | @ApiPropertyOptional({ 30 | example: 'John', 31 | description: 'Partial match for the To:/Cc: headers (case insensitive)', 32 | }) 33 | @IsOptional() 34 | @IsString() 35 | public to?: string 36 | 37 | @ApiPropertyOptional({ 38 | example: 'You have 1 new notification', 39 | description: 'Partial match for the Subject: header (case insensitive)', 40 | }) 41 | @IsOptional() 42 | @IsString() 43 | public subject?: string 44 | 45 | @ApiPropertyOptional({ 46 | example: "John's list", 47 | description: 'Partial match for the List-ID: header (case insensitive)', 48 | }) 49 | @IsOptional() 50 | @IsString() 51 | public listId?: string 52 | 53 | @ApiPropertyOptional({ 54 | example: 'Dedicated servers', 55 | description: 'Fulltext search against message text', 56 | }) 57 | @IsOptional() 58 | @IsString() 59 | public text?: string 60 | 61 | @ApiPropertyOptional({ 62 | example: false, 63 | description: 'Does a message have to have an attachment or not', 64 | type: Boolean, 65 | }) 66 | @IsOptional() 67 | @ValidateIf((object, value): boolean => value !== '') 68 | @IsBoolean() 69 | public ha?: boolean | '' 70 | 71 | @ApiPropertyOptional({ 72 | example: 1000, 73 | description: 74 | 'Message size in bytes. If the value is a positive number then message needs to be larger, if negative then message needs to be smaller than abs(size) value', 75 | type: Number, 76 | }) 77 | @IsOptional() 78 | @ValidateIf((object, value): boolean => value !== '') 79 | @IsNumber() 80 | public size?: number | '' 81 | } 82 | 83 | class Action { 84 | @ApiPropertyOptional({ 85 | example: true, 86 | description: 'If true then mark matching messages as Seen', 87 | type: Boolean, 88 | }) 89 | @IsOptional() 90 | @ValidateIf((object, value): boolean => value !== '') 91 | @IsBoolean() 92 | public seen?: boolean | '' 93 | 94 | @ApiPropertyOptional({ 95 | example: true, 96 | description: 'If true then mark matching messages as Flagged', 97 | type: Boolean, 98 | }) 99 | @IsOptional() 100 | @ValidateIf((object, value): boolean => value !== '') 101 | @IsBoolean() 102 | public flag?: boolean | '' 103 | 104 | @ApiPropertyOptional({ 105 | example: true, 106 | description: 'If true then do not store matching messages', 107 | type: Boolean, 108 | }) 109 | @IsOptional() 110 | @ValidateIf((object, value): boolean => value !== '') 111 | @IsBoolean() 112 | public delete?: boolean | '' 113 | 114 | @ApiPropertyOptional({ 115 | example: true, 116 | description: 'If true then store matching messags to Junk Mail folder', 117 | type: Boolean, 118 | }) 119 | @IsOptional() 120 | @ValidateIf((object, value): boolean => value !== '') 121 | @IsBoolean() 122 | public spam?: boolean | '' 123 | 124 | @ApiPropertyOptional({ 125 | example: '5a1c0ee490a34c67e266932c', 126 | description: 'Mailbox ID to store matching messages to', 127 | }) 128 | @IsOptional() 129 | @IsMongoId() 130 | public mailbox?: string 131 | 132 | @ApiPropertyOptional({ 133 | example: ['johndoe@example.com', 'smtp://mx.example.com:25', 'https://example.com'], 134 | description: 135 | 'An array of forwarding targets. The value could either be an email address or a relay url to next MX server ("smtp://mx2.zone.eu:25") or an URL where mail contents are POSTed to', 136 | type: [String], 137 | }) 138 | @IsOptional() 139 | @ValidateIf((object, value): boolean => value !== '') 140 | @IsArray() 141 | @IsString({ each: true }) 142 | @ArrayUnique() 143 | @Validate(EachIsEmailOrHttpOrSmtp) 144 | public targets?: string[] | '' 145 | } 146 | 147 | export class CreateUpdateFilterDto extends Filter { 148 | @ApiProperty({ description: 'Rules that a message must match' }) 149 | @ValidateNested() 150 | @IsDefined({ message: 'query should not be null or undefined. However, it can be empty' }) 151 | @Type((): typeof Query => Query) 152 | public query: Query 153 | 154 | @ApiProperty({ description: 'Rules that a message must match' }) 155 | @ValidateNested() 156 | @IsDefined({ message: 'action should not be null or undefined. However, it can be empty' }) 157 | @Type((): typeof Action => Action) 158 | public action: Action 159 | } 160 | -------------------------------------------------------------------------------- /src/filters/filters.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { FiltersController } from './filters.controller' 4 | 5 | describe('Filters Controller', (): void => { 6 | let controller: FiltersController 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [FiltersController], 12 | }).compile() 13 | 14 | controller = module.get(FiltersController) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(controller).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/filters/filters.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiCreatedResponse, 7 | ApiNotFoundResponse, 8 | ApiOkResponse, 9 | ApiOperation, 10 | ApiTags, 11 | ApiUnauthorizedResponse, 12 | } from '@nestjs/swagger' 13 | import { AccountIdParams } from 'src/accounts/params/account-id.params' 14 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 15 | import { Roles } from 'src/common/decorators/roles.decorator' 16 | import { RolesGuard } from 'src/common/guards/roles.guard' 17 | import { User } from 'src/users/user.entity' 18 | 19 | import { FilterDetails } from './class/filter-details.class' 20 | import { FilterListItem } from './class/filter-list-item.class' 21 | import { CreateUpdateFilterDto } from './dto/create-update-filter.dto' 22 | import { FiltersService } from './filters.service' 23 | import { FilterIdParams } from './params/filter-id.params' 24 | 25 | @Controller('accounts/:accountId/filters') 26 | @ApiTags('Filters') 27 | @UseGuards(AuthGuard('jwt'), RolesGuard) 28 | @Roles('user') 29 | @ApiBearerAuth() 30 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 31 | @ApiBadRequestResponse({ description: 'Bad user input' }) 32 | export class FiltersController { 33 | public constructor(private readonly filtersService: FiltersService) {} 34 | 35 | @Delete(':filterId') 36 | @ApiOperation({ operationId: 'deleteFilter', summary: 'Delete filter' }) 37 | @ApiOkResponse({ description: 'Filter deleted successfully' }) 38 | @ApiNotFoundResponse({ description: 'No account or filter found with this id' }) 39 | public async deleteFilter(@ReqUser() user: User, @Param() filterIdParams: FilterIdParams): Promise { 40 | return this.filtersService.deleteFilter(user, filterIdParams.accountId, filterIdParams.filterId) 41 | } 42 | 43 | @Get() 44 | @ApiOperation({ operationId: 'getFilters', summary: 'List filters' }) 45 | @ApiOkResponse({ description: 'A list of filters', type: FilterListItem, isArray: true }) 46 | @ApiNotFoundResponse({ description: 'No account found with this id' }) 47 | public async getFilters(@ReqUser() user: User, @Param() accountIdParams: AccountIdParams): Promise { 48 | return this.filtersService.getFilters(user, accountIdParams.accountId) 49 | } 50 | 51 | @Get(':filterId') 52 | @ApiOperation({ operationId: 'getFilterDetails', summary: 'Get filter details' }) 53 | @ApiOkResponse({ description: 'Filter details', type: FilterDetails }) 54 | @ApiNotFoundResponse({ description: 'No account or filter found with this id' }) 55 | public async getFilterDetails( 56 | @ReqUser() user: User, 57 | @Param() filterIdParams: FilterIdParams, 58 | ): Promise { 59 | return this.filtersService.getFilter(user, filterIdParams.accountId, filterIdParams.filterId) 60 | } 61 | 62 | @Post() 63 | @ApiOperation({ operationId: 'createFilter', summary: 'Create a new filter' }) 64 | @ApiCreatedResponse({ description: 'Filter created successfully' }) 65 | @ApiNotFoundResponse({ description: 'No account found with this id' }) 66 | public async createFilter( 67 | @ReqUser() user: User, 68 | @Param() accountIdParams: AccountIdParams, 69 | @Body() createUpdateFilterDto: CreateUpdateFilterDto, 70 | ): Promise { 71 | return this.filtersService.createFilter(user, accountIdParams.accountId, createUpdateFilterDto) 72 | } 73 | 74 | @Put(':filterId') 75 | @ApiOperation({ operationId: 'updateFilter', summary: 'Update existing filter' }) 76 | @ApiOkResponse({ description: 'Account updated successfully' }) 77 | @ApiNotFoundResponse({ description: 'No account or filter found with this id' }) 78 | public async updateFilter( 79 | @ReqUser() user: User, 80 | @Param() filterIdParams: FilterIdParams, 81 | @Body() createUpdateFilterDto: CreateUpdateFilterDto, 82 | ): Promise { 83 | return this.filtersService.updateFilter( 84 | user, 85 | filterIdParams.accountId, 86 | filterIdParams.filterId, 87 | createUpdateFilterDto, 88 | ) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/filters/filters.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule, Module } from '@nestjs/common' 2 | import { AccountsModule } from 'src/accounts/accounts.module' 3 | import { ConfigModule } from 'src/config/config.module' 4 | import { ConfigService } from 'src/config/config.service' 5 | 6 | import { FiltersController } from './filters.controller' 7 | import { FiltersService } from './filters.service' 8 | 9 | @Module({ 10 | imports: [ 11 | HttpModule.registerAsync({ 12 | imports: [ConfigModule], 13 | inject: [ConfigService], 14 | useFactory: (config: ConfigService) => ({ 15 | timeout: 10000, 16 | maxRedirects: 5, 17 | baseURL: config.WILDDUCK_API_URL, 18 | headers: { 19 | 'X-Access-Token': config.WILDDUCK_API_TOKEN, 20 | }, 21 | }), 22 | }), 23 | AccountsModule, 24 | ], 25 | controllers: [FiltersController], 26 | providers: [FiltersService], 27 | }) 28 | export class FiltersModule {} 29 | -------------------------------------------------------------------------------- /src/filters/filters.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { FiltersService } from './filters.service' 4 | 5 | describe('FiltersService', (): void => { 6 | let service: FiltersService 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [FiltersService], 12 | }).compile() 13 | 14 | service = module.get(FiltersService) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(service).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/filters/filters.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | HttpService, 4 | Injectable, 5 | InternalServerErrorException, 6 | Logger, 7 | NotFoundException, 8 | } from '@nestjs/common' 9 | import { AxiosResponse } from 'axios' 10 | import { AccountsService } from 'src/accounts/accounts.service' 11 | import { ConfigService } from 'src/config/config.service' 12 | import { User } from 'src/users/user.entity' 13 | 14 | import { FilterDetails } from './class/filter-details.class' 15 | import { FilterListItem } from './class/filter-list-item.class' 16 | import { CreateUpdateFilterDto } from './dto/create-update-filter.dto' 17 | 18 | @Injectable() 19 | export class FiltersService { 20 | private readonly logger = new Logger(FiltersService.name, true) 21 | 22 | public constructor( 23 | private readonly httpService: HttpService, 24 | private readonly accountsService: AccountsService, 25 | private readonly config: ConfigService, 26 | ) {} 27 | 28 | public async deleteFilter(user: User, accountId: string, filterId: string): Promise { 29 | // Run get accountdetails to make sure account exists and user has permission, we don't do anything with it because it will throw an exception if needed 30 | await this.accountsService.getAccountDetails(user, accountId) 31 | 32 | let apiResponse: AxiosResponse 33 | try { 34 | apiResponse = await this.httpService.delete(`/users/${accountId}/filters/${filterId}`).toPromise() 35 | } catch (error) { 36 | if (error.response.status === 404) { 37 | // TODO: remove this when the 404 gets changed to 200 with FilterNotFoundError in WildDuck 38 | throw new NotFoundException(`Filter: ${filterId} not found`, 'FilterNotFoundError') 39 | } 40 | this.logger.error(error.message) 41 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 42 | } 43 | 44 | if (apiResponse.data.error || !apiResponse.data.success) { 45 | switch (apiResponse.data.code) { 46 | case 'FilterNotFound': 47 | throw new NotFoundException(`Filter: ${filterId} not found`, 'FilterNotFoundError') 48 | 49 | default: 50 | this.logger.error(apiResponse.data) 51 | throw new InternalServerErrorException('Unknown error') 52 | } 53 | } 54 | } 55 | 56 | public async getFilters(user: User, accountId: string): Promise { 57 | // Run get accountdetails to make sure account exists and user has permission, we don't do anything with it because it will throw an exception if needed 58 | await this.accountsService.getAccountDetails(user, accountId) 59 | 60 | let apiResponse: AxiosResponse 61 | try { 62 | apiResponse = await this.httpService.get(`/users/${accountId}/filters`).toPromise() 63 | } catch (error) { 64 | this.logger.error(error.message) 65 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 66 | } 67 | 68 | if (apiResponse.data.error || !apiResponse.data.success) { 69 | switch (apiResponse.data.code) { 70 | default: 71 | this.logger.error(apiResponse.data) 72 | throw new InternalServerErrorException('Unknown error') 73 | } 74 | } 75 | 76 | if (apiResponse.data.results.length === 0) { 77 | return [] 78 | } 79 | 80 | const filters: FilterListItem[] = [] 81 | for (const result of apiResponse.data.results) { 82 | filters.push({ 83 | id: result.id, 84 | name: result.name, 85 | disabled: result.disabled, 86 | created: result.created, 87 | action: result.action, 88 | query: result.query, 89 | }) 90 | } 91 | return filters 92 | } 93 | 94 | public async getFilter(user: User, accountId: string, filterId: string): Promise { 95 | // Run get accountdetails to make sure account exists and user has permission, we don't do anything with it because it will throw an exception if needed 96 | await this.accountsService.getAccountDetails(user, accountId) 97 | 98 | let apiResponse: AxiosResponse 99 | try { 100 | apiResponse = await this.httpService.get(`/users/${accountId}/filters/${filterId}`).toPromise() 101 | } catch (error) { 102 | this.logger.error(error.message) 103 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 104 | } 105 | 106 | if (apiResponse.data.error || !apiResponse.data.success) { 107 | switch (apiResponse.data.code) { 108 | case 'FilterNotFound': 109 | throw new NotFoundException(`Filter: ${filterId} not found`, 'FilterNotFoundError') 110 | 111 | default: 112 | this.logger.error(apiResponse.data) 113 | throw new InternalServerErrorException('Unknown error') 114 | } 115 | } 116 | 117 | const filter: FilterDetails = { 118 | id: apiResponse.data.id, 119 | name: apiResponse.data.name, 120 | disabled: apiResponse.data.disabled, 121 | action: apiResponse.data.action, 122 | query: apiResponse.data.query, 123 | } 124 | 125 | return filter 126 | } 127 | 128 | public async createFilter( 129 | user: User, 130 | accountId: string, 131 | createUpdateFilterDto: CreateUpdateFilterDto, 132 | ): Promise { 133 | // Run get accountdetails to make sure account exists and user has permission, we don't do anything with it because it will throw an exception if needed 134 | await this.accountsService.getAccountDetails(user, accountId) 135 | 136 | let apiResponse: AxiosResponse 137 | try { 138 | // Pass createUpdateFilterDto directly as it's exactly what the WildDuck API requires 139 | apiResponse = await this.httpService.post(`/users/${accountId}/filters`, createUpdateFilterDto).toPromise() 140 | } catch (error) { 141 | this.logger.error(error.message) 142 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 143 | } 144 | 145 | if (apiResponse.data.error || !apiResponse.data.success) { 146 | switch (apiResponse.data.code) { 147 | case 'NoSuchMailbox': 148 | throw new BadRequestException( 149 | `The mailbox: ${createUpdateFilterDto.action.mailbox} does not exist on account: ${accountId}`, 150 | 'MailboxNotFoundError', 151 | ) 152 | 153 | default: 154 | this.logger.error(apiResponse.data) 155 | throw new InternalServerErrorException('Unknown error') 156 | } 157 | } 158 | } 159 | 160 | public async updateFilter( 161 | user: User, 162 | accountId: string, 163 | filterId: string, 164 | createUpdateFilterDto: CreateUpdateFilterDto, 165 | ): Promise { 166 | // Run get accountdetails to make sure account exists and user has permission, we don't do anything with it because it will throw an exception if needed 167 | await this.accountsService.getAccountDetails(user, accountId) 168 | 169 | let apiResponse: AxiosResponse 170 | try { 171 | // Pass createUpdateFilterDto directly as it's exactly what the WildDuck API requires 172 | apiResponse = await this.httpService 173 | .put(`/users/${accountId}/filters/${filterId}`, createUpdateFilterDto) 174 | .toPromise() 175 | } catch (error) { 176 | this.logger.error(error.message) 177 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 178 | } 179 | 180 | if (apiResponse.data.error || !apiResponse.data.success) { 181 | switch (apiResponse.data.code) { 182 | case 'FilterNotFound': 183 | throw new NotFoundException(`Filter: ${filterId} not found`, 'FilterNotFoundError') 184 | 185 | case 'NoSuchMailbox': 186 | throw new BadRequestException( 187 | `The mailbox: ${createUpdateFilterDto.action.mailbox} does not exist on account: ${accountId}`, 188 | 'MailboxNotFoundError', 189 | ) 190 | 191 | default: 192 | this.logger.error(apiResponse.data) 193 | throw new InternalServerErrorException('Unknown error') 194 | } 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/filters/params/filter-id.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsMongoId } from 'class-validator' 3 | 4 | export class FilterIdParams { 5 | @ApiProperty({ description: 'Unique id of the account' }) 6 | @IsMongoId() 7 | public accountId: string 8 | 9 | @ApiProperty({ description: 'Unique id of the filter' }) 10 | @IsMongoId() 11 | public filterId: string 12 | } 13 | -------------------------------------------------------------------------------- /src/forwarders/class/forwarder-details.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | import { Forwarder } from './forwarder.class' 4 | 5 | class ForwarderDetailsForwards { 6 | @ApiProperty({ example: 100, description: 'How many messages can be forwarded per period' }) 7 | public allowed: number 8 | 9 | @ApiProperty({ example: 56, description: 'How many messages were forwarded in the current period' }) 10 | public used: number 11 | 12 | @ApiProperty({ example: 3600, description: 'Seconds until the end of the current period' }) 13 | public ttl: number 14 | } 15 | 16 | class ForwarderDetailsLimits { 17 | @ApiProperty({ description: 'Forwarding quota' }) 18 | public forward: ForwarderDetailsForwards 19 | } 20 | 21 | export class ForwarderDetails extends Forwarder { 22 | @ApiProperty({ example: 'John Doe', description: 'Identity name' }) 23 | public name: string 24 | 25 | @ApiProperty({ 26 | example: ['johndoe@example.com', 'smtp://mx.example.com:25', 'https://example.com'], 27 | description: 'List of forwarding targets', 28 | }) 29 | public targets: string[] 30 | 31 | @ApiProperty({ description: 'Forwarder limits and usage' }) 32 | public limits: ForwarderDetailsLimits 33 | } 34 | -------------------------------------------------------------------------------- /src/forwarders/class/forwarder.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class Forwarder { 4 | @ApiProperty({ example: '59cb948ad80a820b68f05230', description: 'The unique id of the forwarder' }) 5 | public id: string 6 | 7 | @ApiProperty({ example: 'john@example.com', description: 'The E-Mail address of the forwarder' }) 8 | public address: string 9 | } 10 | -------------------------------------------------------------------------------- /src/forwarders/dto/create-forwarder.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsEmail } from 'class-validator' 3 | 4 | import { CreateUpdateForwarderCommonDto } from './create-update-forwarder-common.dto' 5 | 6 | export class CreateForwarderDto extends CreateUpdateForwarderCommonDto { 7 | @ApiProperty({ example: 'john@example.com', description: 'The E-Mail address that should be forwarded' }) 8 | @IsEmail() 9 | public address: string 10 | } 11 | -------------------------------------------------------------------------------- /src/forwarders/dto/create-update-forwarder-common.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { Type } from 'class-transformer' 3 | import { 4 | ArrayUnique, 5 | IsArray, 6 | IsNotEmpty, 7 | IsNumber, 8 | IsOptional, 9 | IsPositive, 10 | IsString, 11 | Validate, 12 | ValidateNested, 13 | } from 'class-validator' 14 | import { EachIsEmailOrHttpOrSmtp } from 'src/common/is-email-or-url.validator' 15 | 16 | class CreateUpdateForwarderCommonDtoLimits { 17 | @ApiPropertyOptional({ example: 600, description: 'How many messages can be forwarded per period' }) 18 | @IsOptional() 19 | @IsNumber() 20 | @IsPositive() 21 | public forward?: number 22 | } 23 | 24 | export class CreateUpdateForwarderCommonDto { 25 | @ApiPropertyOptional({ example: 'John Doe', description: 'Identity name' }) 26 | @IsOptional() 27 | @IsNotEmpty() 28 | @IsString() 29 | public name?: string 30 | 31 | @ApiPropertyOptional({ 32 | example: ['johndoe@example.com', 'smtp://mx.example.com:25', 'https://example.com'], 33 | description: 34 | 'An array of forwarding targets. The value could either be an email address or a relay url to next MX server ("smtp://mx2.zone.eu:25") or an URL where mail contents are POSTed to', 35 | type: [String], 36 | }) 37 | @IsOptional() 38 | @IsArray() 39 | @IsString({ each: true }) 40 | @ArrayUnique() 41 | @Validate(EachIsEmailOrHttpOrSmtp) 42 | public targets?: string[] 43 | 44 | @ApiProperty({ description: 'Limits for this forwarder' }) 45 | @ValidateNested() 46 | @Type((): typeof CreateUpdateForwarderCommonDtoLimits => CreateUpdateForwarderCommonDtoLimits) 47 | public limits?: CreateUpdateForwarderCommonDtoLimits 48 | } 49 | -------------------------------------------------------------------------------- /src/forwarders/dto/update-forwarder.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsEmail, IsOptional } from 'class-validator' 3 | 4 | import { CreateUpdateForwarderCommonDto } from './create-update-forwarder-common.dto' 5 | 6 | export class UpdateForwarderDto extends CreateUpdateForwarderCommonDto { 7 | @ApiPropertyOptional({ 8 | example: 'john@example.com', 9 | description: 'The E-Mail address that should be forwarded', 10 | }) 11 | @IsOptional() 12 | @IsEmail() 13 | public address: string 14 | } 15 | -------------------------------------------------------------------------------- /src/forwarders/forwarders.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { ForwardersController } from './forwarders.controller' 4 | 5 | describe('Forwarders Controller', (): void => { 6 | let controller: ForwardersController 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [ForwardersController], 12 | }).compile() 13 | 14 | controller = module.get(ForwardersController) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(controller).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/forwarders/forwarders.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiCreatedResponse, 7 | ApiNotFoundResponse, 8 | ApiOkResponse, 9 | ApiOperation, 10 | ApiTags, 11 | ApiUnauthorizedResponse, 12 | } from '@nestjs/swagger' 13 | import { IsNotSuspended } from 'src/common/decorators/is-not-suspended.decorator' 14 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 15 | import { Roles } from 'src/common/decorators/roles.decorator' 16 | import { IsNotSuspendedGuard } from 'src/common/guards/is-not-suspended.guard' 17 | import { RolesGuard } from 'src/common/guards/roles.guard' 18 | import { User } from 'src/users/user.entity' 19 | 20 | import { ForwarderDetails } from './class/forwarder-details.class' 21 | import { Forwarder } from './class/forwarder.class' 22 | import { CreateForwarderDto } from './dto/create-forwarder.dto' 23 | import { UpdateForwarderDto } from './dto/update-forwarder.dto' 24 | import { ForwardersService } from './forwarders.service' 25 | import { ForwarderIdParams } from './params/forwarder-id.params' 26 | 27 | @Controller('forwarders') 28 | @ApiTags('Forwarders') 29 | @UseGuards(AuthGuard('jwt'), RolesGuard, IsNotSuspendedGuard) 30 | @Roles('user') 31 | @ApiBearerAuth() 32 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 33 | @ApiBadRequestResponse({ description: 'Bad user input' }) 34 | export class ForwardersController { 35 | public constructor(private readonly forwardersService: ForwardersService) {} 36 | 37 | @Delete(':forwarderId') 38 | @ApiOperation({ operationId: 'deleteForwarder', summary: 'Delete forwarder' }) 39 | @ApiOkResponse({ description: 'Forwarder deleted successfully' }) 40 | @ApiNotFoundResponse({ description: 'No forwarder found with this id' }) 41 | private async deleteForwarder(@ReqUser() user: User, @Param() forwarderIdParams: ForwarderIdParams): Promise { 42 | return this.forwardersService.deleteForwarder(user, forwarderIdParams.forwarderId) 43 | } 44 | 45 | @Get() 46 | @ApiOperation({ operationId: 'getForwarders', summary: 'List forwarders' }) 47 | @ApiOkResponse({ description: 'A list of forwarders', type: Forwarder, isArray: true }) 48 | private async getForwarders(@ReqUser() user: User): Promise { 49 | return this.forwardersService.getForwarders(user) 50 | } 51 | 52 | @Get(':forwarderId') 53 | @ApiOperation({ operationId: 'getForwarderDetails', summary: 'Get forwarder details' }) 54 | @ApiOkResponse({ description: 'Forwarder details', type: ForwarderDetails }) 55 | @ApiNotFoundResponse({ description: 'No forwarder found with this id' }) 56 | private async getForwarderDetails( 57 | @ReqUser() user: User, 58 | @Param() forwarderIdParams: ForwarderIdParams, 59 | ): Promise { 60 | return this.forwardersService.getForwarderDetails(user, forwarderIdParams.forwarderId) 61 | } 62 | 63 | @Post() 64 | @IsNotSuspended() 65 | @ApiOperation({ operationId: 'createForwarder', summary: 'Create a new forwarder' }) 66 | @ApiCreatedResponse({ description: 'Forwarder created successfully' }) 67 | private async createForwarder(@ReqUser() user: User, @Body() createForwarderDto: CreateForwarderDto): Promise { 68 | return this.forwardersService.createForwarder(user, createForwarderDto) 69 | } 70 | 71 | @Put(':forwarderId') 72 | @ApiOperation({ operationId: 'updateForwarder', summary: 'Update existing forwarder' }) 73 | @ApiOkResponse({ description: 'Forwarder updated successfully' }) 74 | @ApiNotFoundResponse({ description: 'No forwarder found with this id' }) 75 | private async updateForwarder( 76 | @ReqUser() user: User, 77 | @Param() forwarderIdParams: ForwarderIdParams, 78 | @Body() updateForwarderDto: UpdateForwarderDto, 79 | ): Promise { 80 | return this.forwardersService.updateForwarder(user, forwarderIdParams.forwarderId, updateForwarderDto) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/forwarders/forwarders.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, HttpModule, Module } from '@nestjs/common' 2 | import { ConfigModule } from 'src/config/config.module' 3 | import { ConfigService } from 'src/config/config.service' 4 | import { DomainsModule } from 'src/domains/domains.module' 5 | 6 | import { ForwardersController } from './forwarders.controller' 7 | import { ForwardersService } from './forwarders.service' 8 | 9 | @Module({ 10 | imports: [ 11 | forwardRef(() => DomainsModule), 12 | HttpModule.registerAsync({ 13 | imports: [ConfigModule], 14 | inject: [ConfigService], 15 | useFactory: (config: ConfigService) => ({ 16 | timeout: 10000, 17 | maxRedirects: 5, 18 | baseURL: config.WILDDUCK_API_URL, 19 | headers: { 20 | 'X-Access-Token': config.WILDDUCK_API_TOKEN, 21 | }, 22 | }), 23 | }), 24 | ], 25 | exports: [ForwardersService], 26 | providers: [ForwardersService], 27 | controllers: [ForwardersController], 28 | }) 29 | export class ForwardersModule {} 30 | -------------------------------------------------------------------------------- /src/forwarders/forwarders.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { ForwardersService } from './forwarders.service' 4 | 5 | describe('ForwardersService', (): void => { 6 | let service: ForwardersService 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [ForwardersService], 12 | }).compile() 13 | 14 | service = module.get(ForwardersService) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(service).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/forwarders/forwarders.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | HttpService, 4 | Injectable, 5 | InternalServerErrorException, 6 | Logger, 7 | NotFoundException, 8 | } from '@nestjs/common' 9 | import { AxiosResponse } from 'axios' 10 | import { ConfigService } from 'src/config/config.service' 11 | import { DomainsService } from 'src/domains/domains.service' 12 | import { User } from 'src/users/user.entity' 13 | 14 | import { ForwarderDetails } from './class/forwarder-details.class' 15 | import { Forwarder } from './class/forwarder.class' 16 | import { CreateForwarderDto } from './dto/create-forwarder.dto' 17 | import { UpdateForwarderDto } from './dto/update-forwarder.dto' 18 | 19 | @Injectable() 20 | export class ForwardersService { 21 | private readonly logger = new Logger(ForwardersService.name, true) 22 | 23 | public constructor( 24 | private readonly httpService: HttpService, 25 | private readonly config: ConfigService, 26 | private readonly domainsService: DomainsService, 27 | ) {} 28 | 29 | public async getForwarders(user: User, domain?: string): Promise { 30 | if (user.domains.length === 0) { 31 | return [] 32 | } 33 | 34 | let domainTags: string 35 | if (domain) { 36 | await this.domainsService.checkIfDomainIsAddedToUser(user, domain) 37 | domainTags = `domain:${domain}` 38 | } else { 39 | // Comma delimited list of domains with "domain:" prefix to match the tags added to forwarders 40 | domainTags = user.domains.map((domain): string => `domain:${domain.domain}`).join() 41 | } 42 | 43 | let results: any[] = [] 44 | let nextCursor: string | false 45 | 46 | while (true) { 47 | let apiResponse: AxiosResponse 48 | try { 49 | apiResponse = await this.httpService 50 | .get(`/addresses`, { 51 | params: { 52 | tags: domainTags, 53 | requiredTags: 'forwarder', 54 | limit: 250, 55 | next: nextCursor, 56 | }, 57 | }) 58 | .toPromise() 59 | } catch (error) { 60 | this.logger.error(error.message) 61 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 62 | } 63 | if (apiResponse.data.results.length === 0) { 64 | return [] 65 | } 66 | 67 | // Add results of this page to the results array 68 | results = results.concat(apiResponse.data.results) 69 | 70 | if (apiResponse.data.nextCursor) { 71 | // Set next cursor value and repeat 72 | nextCursor = apiResponse.data.nextCursor 73 | } else { 74 | break 75 | } 76 | } 77 | 78 | const forwarders: Forwarder[] = [] 79 | for (const result of results) { 80 | forwarders.push({ 81 | id: result.id, 82 | address: result.address, 83 | }) 84 | } 85 | return forwarders 86 | } 87 | 88 | public async getForwarderDetails(user: User, forwarderId: string): Promise { 89 | let apiResponse: AxiosResponse 90 | try { 91 | apiResponse = await this.httpService.get(`/addresses/forwarded/${forwarderId}`).toPromise() 92 | } catch (error) { 93 | if (error.response.data.error) { 94 | switch (error.response.data.code) { 95 | case 'AddressNotFound': 96 | throw new NotFoundException(`No forwarder found with id: ${forwarderId}`, 'ForwarderNotFoundError') 97 | 98 | default: 99 | this.logger.error(error.response.data) 100 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 101 | } 102 | } 103 | this.logger.error(error.message) 104 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 105 | } 106 | 107 | if (apiResponse.data.error || !apiResponse.data.success) { 108 | switch (apiResponse.data.code) { 109 | default: 110 | this.logger.error(apiResponse.data) 111 | throw new InternalServerErrorException('Unknown error') 112 | } 113 | } 114 | 115 | const addressDomain: string = apiResponse.data.address.substring(apiResponse.data.address.lastIndexOf('@') + 1) 116 | if (!user.domains.some((domain): boolean => domain.domain === addressDomain)) { 117 | // if address domain doesn't belong to user 118 | throw new NotFoundException(`No forwarder found with id: ${forwarderId}`, 'ForwarderNotFoundError') 119 | } 120 | 121 | return { 122 | id: apiResponse.data.id, 123 | name: apiResponse.data.name, 124 | address: apiResponse.data.address, 125 | targets: apiResponse.data.targets, 126 | limits: { 127 | forward: { 128 | allowed: apiResponse.data.limits.forwards.allowed, 129 | used: apiResponse.data.limits.forwards.used, 130 | ttl: apiResponse.data.limits.forwards.ttl, 131 | }, 132 | }, 133 | } 134 | } 135 | 136 | public async createForwarder(user: User, createForwarderDto: CreateForwarderDto): Promise { 137 | const addressDomain = createForwarderDto.address.substring(createForwarderDto.address.lastIndexOf('@') + 1) 138 | if (!user.domains.some((domain): boolean => domain.domain === addressDomain)) { 139 | // if address domain doesn't belong to user 140 | throw new BadRequestException( 141 | `You don't have permission to add forwarders on ${addressDomain}. Add the domain first.`, 142 | 'DomainNotFoundError', 143 | ) 144 | } 145 | 146 | if ( 147 | createForwarderDto.limits.forward && 148 | user.maxForward !== 0 && 149 | createForwarderDto.limits.forward > user.maxForward 150 | ) { 151 | throw new BadRequestException(`Forward limit may not be higher than ${user.maxForward}`, 'ValidationError') 152 | } 153 | 154 | let apiResponse: AxiosResponse 155 | try { 156 | apiResponse = await this.httpService 157 | .post(`/addresses/forwarded`, { 158 | address: createForwarderDto.address, 159 | name: createForwarderDto.name, 160 | targets: createForwarderDto.targets, 161 | forwards: createForwarderDto.limits.forward || user.maxForward, 162 | allowWildcard: this.config.ALLOW_FORWARDER_WILDCARD, 163 | tags: [`domain:${addressDomain}`, 'forwarder'], 164 | }) 165 | .toPromise() 166 | } catch (error) { 167 | this.logger.error(error.message) 168 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 169 | } 170 | 171 | if (apiResponse.data.error || !apiResponse.data.success) { 172 | switch (apiResponse.data.code) { 173 | case 'AddressExistsError': 174 | throw new BadRequestException(`Address: ${createForwarderDto.address} already exists`, 'AddressExistsError') 175 | 176 | default: 177 | this.logger.error(apiResponse.data) 178 | throw new InternalServerErrorException('Unknown error') 179 | } 180 | } 181 | } 182 | 183 | public async updateForwarder(user: User, forwarderId: string, updateForwarderDto: UpdateForwarderDto): Promise { 184 | let addressDomain 185 | if (updateForwarderDto.address) { 186 | addressDomain = updateForwarderDto.address.substring(updateForwarderDto.address.lastIndexOf('@') + 1) 187 | if (!user.domains.some((domain): boolean => domain.domain === addressDomain)) { 188 | // if address domain doesn't belong to user 189 | throw new BadRequestException( 190 | `You don't have permission to add forwarders on ${addressDomain}. Add the domain first.`, 191 | 'DomainNotFoundError', 192 | ) 193 | } 194 | } 195 | 196 | // Run get forwarderdetails to make sure forwarder exists and user has permission, we don't do anything with it because it will throw an exception if needed 197 | await this.getForwarderDetails(user, forwarderId) 198 | 199 | let apiResponse: AxiosResponse 200 | try { 201 | apiResponse = await this.httpService 202 | .put(`/addresses/forwarded/${forwarderId}`, { 203 | address: updateForwarderDto.address, 204 | name: updateForwarderDto.name, 205 | targets: updateForwarderDto.targets, 206 | forwards: updateForwarderDto.limits.forward, 207 | tags: addressDomain ? [`domain:${addressDomain}`, 'forwarder'] : undefined, 208 | }) 209 | .toPromise() 210 | } catch (error) { 211 | this.logger.error(error.message) 212 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 213 | } 214 | 215 | if (apiResponse.data.error || !apiResponse.data.success) { 216 | switch (apiResponse.data.code) { 217 | case 'AddressExistsError': 218 | throw new BadRequestException(`Address: ${updateForwarderDto.address} already exists`, 'AddressExistsError') 219 | 220 | case 'ChangeNotAllowed': 221 | throw new BadRequestException( 222 | `Update to address: ${updateForwarderDto.address} not allowed. Keep in mind wildcard addresses can not be changed`, 223 | 'AddressChangeNotAllowedError', 224 | ) 225 | 226 | default: 227 | this.logger.error(apiResponse.data) 228 | throw new InternalServerErrorException('Unknown error') 229 | } 230 | } 231 | } 232 | 233 | public async disable(forwarderId: string, disable = true): Promise { 234 | let apiResponse: AxiosResponse 235 | try { 236 | apiResponse = await this.httpService 237 | .put(`/addresses/forwarded/${forwarderId}`, { 238 | forwardedDisabled: disable, 239 | }) 240 | .toPromise() 241 | } catch (error) { 242 | this.logger.error(error.message) 243 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 244 | } 245 | 246 | if (apiResponse.data.error || !apiResponse.data.success) { 247 | switch (apiResponse.data.code) { 248 | case 'AddressNotFound': 249 | break 250 | 251 | default: 252 | this.logger.error(apiResponse.data) 253 | throw new InternalServerErrorException('Unknown error') 254 | } 255 | } 256 | } 257 | 258 | public async deleteForwarder(user: User, forwarderId: string): Promise { 259 | // Run get accountdetails to make sure account exists and user has permission, we don't do anything with it because it will throw an exception if needed 260 | await this.getForwarderDetails(user, forwarderId) 261 | 262 | let apiResponse: AxiosResponse 263 | try { 264 | apiResponse = await this.httpService.delete(`/addresses/forwarded/${forwarderId}`).toPromise() 265 | } catch (error) { 266 | this.logger.error(error.message) 267 | throw new InternalServerErrorException('Backend service not reachable', 'WildduckApiError') 268 | } 269 | 270 | if (apiResponse.data.error || !apiResponse.data.success) { 271 | switch (apiResponse.data.code) { 272 | case 'AddressNotFound': 273 | throw new NotFoundException(`No forwarder found with id: ${forwarderId}`, 'ForwarderNotFoundError') 274 | 275 | default: 276 | this.logger.error(apiResponse.data) 277 | throw new InternalServerErrorException('Unknown error') 278 | } 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/forwarders/params/forwarder-id.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsMongoId } from 'class-validator' 3 | 4 | export class ForwarderIdParams { 5 | @ApiProperty({ description: 'Unique id of the forwarder' }) 6 | @IsMongoId() 7 | public forwarderId: string 8 | } 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, ValidationError, ValidationPipe } from '@nestjs/common' 2 | import { NestFactory } from '@nestjs/core' 3 | import { SwaggerModule } from '@nestjs/swagger' 4 | import fs from 'fs' 5 | import Helmet from 'helmet' 6 | import { resolve } from 'path' 7 | import { promisify } from 'util' 8 | 9 | import { AppModule } from './app.module' 10 | import { UnauthorizedExceptionFilter } from './common/filters/unauthorized-exception.filter' 11 | import { ConfigService } from './config/config.service' 12 | import { openapiOptions } from './openapi-options' 13 | 14 | const writeFile = promisify(fs.writeFile) 15 | declare const module: any 16 | 17 | async function bootstrap(): Promise { 18 | const app = await NestFactory.create(AppModule) 19 | const config: ConfigService = app.get('ConfigService') 20 | 21 | if (config.SERVE_DUCKYPANEL) { 22 | // Write baseurl to file for DuckyPanel to find 23 | await writeFile( 24 | resolve('node_modules/duckypanel/DuckyPanel/config/production.json'), 25 | `{"apiUrl":"/${config.BASE_URL}"}`, 26 | ) 27 | } 28 | 29 | app.setGlobalPrefix(config.BASE_URL) 30 | 31 | if (config.DELAY) { 32 | app.use(function (req, res, next) { 33 | setTimeout(next, config.DELAY) 34 | }) 35 | } 36 | 37 | app.enableCors() 38 | app.useGlobalFilters(new UnauthorizedExceptionFilter()) 39 | app.useGlobalPipes( 40 | new ValidationPipe({ 41 | whitelist: true, 42 | forbidNonWhitelisted: true, 43 | validationError: { target: false, value: false }, 44 | exceptionFactory: (errors: ValidationError[]): BadRequestException => 45 | new BadRequestException(errors, 'ValidationError'), 46 | }), 47 | ) 48 | app.use(Helmet()) 49 | 50 | const document = SwaggerModule.createDocument(app, openapiOptions) 51 | SwaggerModule.setup(`${config.BASE_URL}/swagger`, app, document, { 52 | swaggerOptions: { 53 | defaultModelsExpandDepth: 0, 54 | displayRequestDuration: true, 55 | displayOperationId: true, 56 | }, 57 | }) 58 | 59 | await app.listen(config.PORT) 60 | 61 | if (module.hot) { 62 | module.hot.accept() 63 | module.hot.dispose((): Promise => app.close()) 64 | } 65 | } 66 | bootstrap() 67 | -------------------------------------------------------------------------------- /src/migrations/1580519162771-AddIndexes.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | import { MongoQueryRunner } from 'typeorm/driver/mongodb/MongoQueryRunner' 3 | 4 | export class AddIndexes1580519162771 implements MigrationInterface { 5 | public async up(queryRunner: QueryRunner): Promise { 6 | const mongoRunner = queryRunner as MongoQueryRunner 7 | mongoRunner.createCollectionIndex('api-keys', 'userId') 8 | mongoRunner.createCollectionIndex('users', 'username', { unique: true }) 9 | mongoRunner.createCollectionIndexes('users', [ 10 | { 11 | key: { 12 | 'domains.domain': 1, 13 | }, 14 | unique: true, 15 | partialFilterExpression: { 'domains.domain': { $exists: true } }, 16 | }, 17 | ]) 18 | } 19 | 20 | public async down(queryRunner: QueryRunner): Promise { 21 | const mongoRunner = queryRunner as MongoQueryRunner 22 | mongoRunner.dropCollectionIndex('api-keys', 'userId') 23 | mongoRunner.dropCollectionIndex('users', 'username') 24 | mongoRunner.dropCollectionIndex('users', 'domains.domain') 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/migrations/1580520383448-SetDefaultRole.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | import { MongoQueryRunner } from 'typeorm/driver/mongodb/MongoQueryRunner' 3 | 4 | export class SetDefaultRole1580520383448 implements MigrationInterface { 5 | public async up(queryRunner: QueryRunner): Promise { 6 | const mongoRunner = queryRunner as MongoQueryRunner 7 | await mongoRunner.updateMany( 8 | 'users', 9 | {}, 10 | { 11 | $set: { 12 | roles: ['user'], 13 | }, 14 | }, 15 | ) 16 | } 17 | 18 | public async down(queryRunner: QueryRunner): Promise { 19 | const mongoRunner = queryRunner as MongoQueryRunner 20 | await mongoRunner.updateMany( 21 | 'users', 22 | {}, 23 | { 24 | $unset: { 25 | roles: '', 26 | }, 27 | }, 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/migrations/1589835792874-PackageToPackageId.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | import { MongoQueryRunner } from 'typeorm/driver/mongodb/MongoQueryRunner' 3 | 4 | export class PackageToPackageId1589835792874 implements MigrationInterface { 5 | public async up(queryRunner: QueryRunner): Promise { 6 | const mongoRunner = queryRunner as MongoQueryRunner 7 | await mongoRunner.updateMany( 8 | 'users', 9 | {}, 10 | { 11 | $rename: { 12 | package: 'packageId', 13 | }, 14 | }, 15 | ) 16 | } 17 | 18 | public async down(queryRunner: QueryRunner): Promise { 19 | const mongoRunner = queryRunner as MongoQueryRunner 20 | await mongoRunner.updateMany( 21 | 'users', 22 | {}, 23 | { 24 | $rename: { 25 | packageId: 'package', 26 | }, 27 | }, 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/openapi-options.ts: -------------------------------------------------------------------------------- 1 | import { DocumentBuilder } from '@nestjs/swagger' 2 | 3 | export const openapiOptions = new DocumentBuilder() 4 | .setTitle('DuckyAPI') 5 | .setDescription('A customer facing api for WildDuck') 6 | .setVersion('1.0') 7 | .addBearerAuth() 8 | .addTag('Authentication') 9 | .addTag('Api Keys') 10 | .addTag('Domains') 11 | .addTag('Dkim') 12 | .addTag('Email Accounts') 13 | .addTag('Filters') 14 | .addTag('Forwarders') 15 | .addTag('Profile') 16 | .addTag('Users') 17 | .addTag('Packages') 18 | .build() 19 | -------------------------------------------------------------------------------- /src/packages/dto/package-id.params.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsMongoId } from 'class-validator' 3 | 4 | export class PackageIdParams { 5 | @ApiProperty({ 6 | example: '5d49e11f600a423ffc0b1297', 7 | description: 'Unique id for the package', 8 | }) 9 | @IsMongoId() 10 | id: string 11 | } 12 | -------------------------------------------------------------------------------- /src/packages/package.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsNumber, IsOptional, IsString, Min } from 'class-validator' 3 | import { ObjectId } from 'mongodb' 4 | import { AfterLoad, Column, Entity, ObjectIdColumn } from 'typeorm' 5 | 6 | @Entity({ 7 | name: 'packages', 8 | }) 9 | export class Package { 10 | @ObjectIdColumn() 11 | @ApiPropertyOptional({ 12 | example: '5d49e11f600a423ffc0b1297', 13 | description: 'Unique id for this package', 14 | readOnly: true, 15 | }) 16 | public _id?: string | ObjectId 17 | 18 | @Column() 19 | @ApiProperty({ 20 | example: 'Small', 21 | description: 'Display name to use for this package', 22 | }) 23 | @IsString() 24 | @IsNotEmpty() 25 | public name: string 26 | 27 | @ApiPropertyOptional({ 28 | example: 1073741824, 29 | description: 'Storage quota in bytes, 0 is unlimited', 30 | }) 31 | @IsOptional() 32 | @Column() 33 | @IsNumber() 34 | @Min(0) 35 | public quota?: number 36 | 37 | @Column() 38 | @ApiPropertyOptional({ 39 | example: 200, 40 | description: 'Max send quota for accounts created by this user, 0 is unlimited', 41 | }) 42 | @IsOptional() 43 | @IsNumber() 44 | @Min(0) 45 | public maxSend?: number 46 | 47 | @Column() 48 | @ApiPropertyOptional({ 49 | example: 1000, 50 | description: 'Max recieve quota for accounts created by this user, 0 is unlimited', 51 | }) 52 | @IsOptional() 53 | @IsNumber() 54 | @Min(0) 55 | public maxReceive?: number 56 | 57 | @Column() 58 | @ApiPropertyOptional({ 59 | example: 100, 60 | description: 'Max forward quota for accounts created by this user, 0 is unlimited', 61 | }) 62 | @IsOptional() 63 | @IsNumber() 64 | @Min(0) 65 | public maxForward?: number 66 | 67 | @AfterLoad() 68 | private async setMissingLimitsToZero(): Promise { 69 | const limits = ['quota', 'maxSend', 'maxReceive', 'maxForward'] 70 | for (const limit of limits) { 71 | if (this[limit] === undefined) { 72 | this[limit] = 0 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/packages/packages.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { PackagesController } from './packages.controller' 4 | 5 | describe('Packages Controller', () => { 6 | let controller: PackagesController 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [PackagesController], 11 | }).compile() 12 | 13 | controller = module.get(PackagesController) 14 | }) 15 | 16 | it('should be defined', () => { 17 | expect(controller).toBeDefined() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/packages/packages.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | forwardRef, 6 | Get, 7 | Inject, 8 | NotFoundException, 9 | Param, 10 | Post, 11 | Put, 12 | UseGuards, 13 | } from '@nestjs/common' 14 | import { AuthGuard } from '@nestjs/passport' 15 | import { 16 | ApiBadRequestResponse, 17 | ApiBearerAuth, 18 | ApiCreatedResponse, 19 | ApiOkResponse, 20 | ApiOperation, 21 | ApiTags, 22 | ApiUnauthorizedResponse, 23 | } from '@nestjs/swagger' 24 | import { Roles } from 'src/common/decorators/roles.decorator' 25 | import { RolesGuard } from 'src/common/guards/roles.guard' 26 | import { UsersService } from 'src/users/users.service' 27 | 28 | import { PackageIdParams } from './dto/package-id.params' 29 | import { Package } from './package.entity' 30 | import { PackagesService } from './packages.service' 31 | 32 | @Controller('packages') 33 | @ApiTags('Packages') 34 | @UseGuards(AuthGuard('jwt'), RolesGuard) 35 | @Roles('admin') 36 | @ApiBearerAuth() 37 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 38 | @ApiBadRequestResponse({ description: 'Bad user input' }) 39 | export class PackagesController { 40 | public constructor( 41 | private readonly packagesService: PackagesService, 42 | @Inject(forwardRef(() => UsersService)) 43 | private readonly usersService: UsersService, 44 | ) {} 45 | 46 | @Get() 47 | @ApiOperation({ operationId: 'getPackages', summary: '[Admin only] Get a list of packages' }) 48 | @ApiOkResponse({ description: 'List of packages', type: Package, isArray: true }) 49 | public async getPackages(): Promise { 50 | return this.packagesService.getPackages() 51 | } 52 | 53 | @Post() 54 | @ApiOperation({ operationId: 'createPackage', summary: '[Admin only] Create package' }) 55 | @ApiCreatedResponse({ description: 'Successfully created package', type: Package }) 56 | public async createPackage(@Body() packaget: Package): Promise { 57 | return this.packagesService.savePackage(packaget) 58 | } 59 | 60 | @Put(':id') 61 | @ApiOperation({ 62 | operationId: 'updatePackage', 63 | summary: '[Admin only] Update package', 64 | description: 'Will also update quota for existing users, except if you modified the users quota manually.', 65 | }) 66 | @ApiOkResponse({ description: 'Successfully updated package', type: Package }) 67 | public async updatePackage(@Body() newPackage: Package, @Param() packageIdParams: PackageIdParams): Promise { 68 | const oldPackage = await this.packagesService.getPackageById(packageIdParams.id) 69 | if (oldPackage) { 70 | newPackage._id = packageIdParams.id 71 | await this.packagesService.savePackage(newPackage) 72 | 73 | if (newPackage.quota) { 74 | await this.usersService.replacelimitForPackage(newPackage._id, 'quota', oldPackage.quota, newPackage.quota) 75 | } 76 | if (newPackage.maxSend) { 77 | await this.usersService.replacelimitForPackage( 78 | newPackage._id, 79 | 'maxSend', 80 | oldPackage.maxSend, 81 | newPackage.maxSend, 82 | ) 83 | } 84 | if (newPackage.maxReceive) { 85 | await this.usersService.replacelimitForPackage( 86 | newPackage._id, 87 | 'maxReceive', 88 | oldPackage.maxReceive, 89 | newPackage.maxReceive, 90 | ) 91 | } 92 | if (newPackage.maxForward) { 93 | await this.usersService.replacelimitForPackage( 94 | newPackage._id, 95 | 'maxForward', 96 | oldPackage.maxForward, 97 | newPackage.maxForward, 98 | ) 99 | } 100 | return this.packagesService.getPackageById(packageIdParams.id) 101 | } else { 102 | throw new NotFoundException(`No package found with id ${packageIdParams.id}`, 'PackageNotFoundError') 103 | } 104 | } 105 | 106 | @Delete(':id') 107 | @ApiOperation({ operationId: 'deletePackage', summary: '[Admin only] Delete package' }) 108 | @ApiOkResponse({ description: 'Successfully deleted package', type: Package }) 109 | public async deletePackage(@Param() packageIdParams: PackageIdParams): Promise { 110 | return this.packagesService.deletePackage(packageIdParams.id) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/packages/packages.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | import { UsersModule } from 'src/users/users.module' 4 | 5 | import { Package } from './package.entity' 6 | import { PackagesController } from './packages.controller' 7 | import { PackagesService } from './packages.service' 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Package]), forwardRef(() => UsersModule)], 11 | exports: [PackagesService], 12 | controllers: [PackagesController], 13 | providers: [PackagesService], 14 | }) 15 | export class PackagesModule {} 16 | -------------------------------------------------------------------------------- /src/packages/packages.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { PackagesService } from './packages.service' 4 | 5 | describe('PackagesService', () => { 6 | let service: PackagesService 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [PackagesService], 11 | }).compile() 12 | 13 | service = module.get(PackagesService) 14 | }) 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/packages/packages.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common' 2 | import { InjectRepository } from '@nestjs/typeorm' 3 | import { ObjectId } from 'mongodb' 4 | import { UsersService } from 'src/users/users.service' 5 | import { MongoRepository } from 'typeorm' 6 | 7 | import { Package } from './package.entity' 8 | 9 | @Injectable() 10 | export class PackagesService { 11 | constructor( 12 | @InjectRepository(Package) 13 | private readonly packageRepository: MongoRepository, 14 | @Inject(forwardRef(() => UsersService)) 15 | private readonly usersService: UsersService, 16 | ) {} 17 | 18 | public async getPackages(): Promise { 19 | return this.packageRepository.find() 20 | } 21 | 22 | public async getPackageById(id: string): Promise { 23 | return this.packageRepository.findOne(id) 24 | } 25 | 26 | public async savePackage(packagep: Package): Promise { 27 | if (packagep._id) { 28 | packagep._id = new ObjectId(packagep._id) 29 | } 30 | const packageEntity = this.packageRepository.create(packagep) 31 | return this.packageRepository.save(packageEntity) 32 | } 33 | 34 | public async deletePackage(id: string): Promise { 35 | if ((await this.usersService.countByPackage(id)) > 0) { 36 | throw new BadRequestException('Can not delete a package with users assigned to it', 'PackageInUseError') 37 | } 38 | this.packageRepository.delete(id) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/tasks/delete-for-domain/delete-for-domain-config.service.ts: -------------------------------------------------------------------------------- 1 | import { BullModuleOptions, BullOptionsFactory } from '@nestjs/bull' 2 | import { Injectable } from '@nestjs/common' 3 | import { ConfigService } from 'src/config/config.service' 4 | 5 | @Injectable() 6 | export class DeleteForDomainConfigService implements BullOptionsFactory { 7 | private redisUrl: string 8 | 9 | constructor(config: ConfigService) { 10 | this.redisUrl = config.REDIS_URL 11 | } 12 | 13 | createBullOptions(): BullModuleOptions { 14 | return { 15 | name: 'deleteForDomain', 16 | defaultJobOptions: { 17 | attempts: 5, 18 | backoff: { 19 | delay: 6000, 20 | type: 'exponential', 21 | }, 22 | removeOnComplete: 1000, 23 | }, 24 | redis: this.redisUrl, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/tasks/delete-for-domain/delete-for-domain.interfaces.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'src/users/user.entity' 2 | 3 | export interface DeleteForDomainData { 4 | user: User 5 | domain: string 6 | } 7 | -------------------------------------------------------------------------------- /src/tasks/delete-for-domain/delete-for-domain.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { AccountsModule } from 'src/accounts/accounts.module' 3 | import { DomainsModule } from 'src/domains/domains.module' 4 | import { ForwardersModule } from 'src/forwarders/forwarders.module' 5 | 6 | import { DeleteForDomainProcessor } from './delete-for-domain.processor' 7 | 8 | @Module({ 9 | imports: [AccountsModule, ForwardersModule, DomainsModule], 10 | providers: [DeleteForDomainProcessor], 11 | }) 12 | export class TasksModule {} 13 | -------------------------------------------------------------------------------- /src/tasks/delete-for-domain/delete-for-domain.processor.ts: -------------------------------------------------------------------------------- 1 | import { OnQueueActive, OnQueueCompleted, OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull' 2 | import { Logger } from '@nestjs/common' 3 | import { Job } from 'bull' 4 | import { AccountsService } from 'src/accounts/accounts.service' 5 | import { AccountListItem } from 'src/accounts/class/account-list-item.class' 6 | import { DomainAlias } from 'src/domains/domain.entity' 7 | import { DomainsService } from 'src/domains/domains.service' 8 | import { Forwarder } from 'src/forwarders/class/forwarder.class' 9 | import { ForwardersService } from 'src/forwarders/forwarders.service' 10 | 11 | import { DeleteForDomainData } from './delete-for-domain.interfaces' 12 | 13 | @Processor('deleteForDomain') 14 | export class DeleteForDomainProcessor { 15 | public constructor( 16 | private readonly accountsService: AccountsService, 17 | private readonly forwardersService: ForwardersService, 18 | private readonly domainsService: DomainsService, 19 | ) {} 20 | 21 | private readonly logger = new Logger(DeleteForDomainProcessor.name, true) 22 | 23 | @Process({ name: 'deleteAccounts' }) 24 | private async processDeleteAccounts(job: Job): Promise { 25 | let accounts: AccountListItem[] = [] 26 | try { 27 | accounts = await this.accountsService.getAccounts(job.data.user, job.data.domain) 28 | } catch (error) { 29 | // Don't throw error if no accounts were found 30 | if (error.response.error === 'AccountNotFoundError') { 31 | return 32 | } else { 33 | throw error 34 | } 35 | } 36 | 37 | const accountChunks: AccountListItem[][] = [] 38 | const chunkSize = 10 39 | for (let i = 0; i < accounts.length; i += chunkSize) { 40 | accountChunks.push(accounts.slice(i, i + chunkSize)) 41 | } 42 | 43 | let promises: Promise[] = [] 44 | for (const [i, accountChunk] of accountChunks.entries()) { 45 | job.progress(Math.round((i / accountChunks.length) * 100)) 46 | 47 | promises = [] 48 | for (const account of accountChunk) { 49 | promises.push(this.accountsService.deleteAccount(job.data.user, account.id)) 50 | } 51 | await Promise.all(promises) 52 | } 53 | job.progress(100) 54 | } 55 | 56 | @Process({ name: 'deleteForwarders' }) 57 | private async processDeleteForwarders(job: Job): Promise { 58 | let forwarders: Forwarder[] = [] 59 | try { 60 | forwarders = await this.forwardersService.getForwarders(job.data.user, job.data.domain) 61 | } catch (error) { 62 | // Don't throw error if no forwarders were found 63 | if (error.response.error === 'ForwarderNotFoundError') { 64 | return 65 | } else { 66 | throw error 67 | } 68 | } 69 | 70 | const forwarderChunks: Forwarder[][] = [] 71 | const chunkSize = 10 72 | for (let i = 0; i < forwarders.length; i += chunkSize) { 73 | forwarderChunks.push(forwarders.slice(i, i + chunkSize)) 74 | } 75 | 76 | let promises: Promise[] = [] 77 | for (const [i, forwarderChunk] of forwarderChunks.entries()) { 78 | job.progress(Math.round((i / forwarderChunks.length) * 100)) 79 | 80 | promises = [] 81 | for (const forwarder of forwarderChunk) { 82 | promises.push(this.forwardersService.deleteForwarder(job.data.user, forwarder.id)) 83 | } 84 | await Promise.all(promises) 85 | } 86 | job.progress(100) 87 | } 88 | 89 | @Process({ name: 'deleteAccountAliases' }) 90 | private async processDeleteAccountAliases(job: Job): Promise { 91 | const accountAliases = await this.accountsService.getAliases(job.data.user, job.data.domain) 92 | if (!accountAliases || accountAliases.length === 0) { 93 | return 94 | } 95 | 96 | const aliasChunks: Record[][] = [] 97 | const chunkSize = 10 98 | for (let i = 0; i < accountAliases.length; i += chunkSize) { 99 | aliasChunks.push(accountAliases.slice(i, i + chunkSize)) 100 | } 101 | 102 | let promises: Promise[] = [] 103 | for (const [i, aliasChunk] of aliasChunks.entries()) { 104 | job.progress(Math.round((i / aliasChunks.length) * 100)) 105 | 106 | promises = [] 107 | for (const alias of aliasChunk) { 108 | promises.push(this.accountsService.deleteAlias(job.data.user, alias.user, alias.id)) 109 | } 110 | await Promise.all(promises) 111 | } 112 | job.progress(100) 113 | } 114 | 115 | @Process({ name: 'deleteAliases' }) 116 | private async processDeleteAliases(job: Job): Promise { 117 | const aliases = job.data.user.domains.find((domain) => domain.domain === job.data.domain).aliases 118 | if (!aliases || aliases.length === 0) { 119 | return 120 | } 121 | 122 | const aliasChunks: DomainAlias[][] = [] 123 | const chunkSize = 10 124 | for (let i = 0; i < aliases.length; i += chunkSize) { 125 | aliasChunks.push(aliases.slice(i, i + chunkSize)) 126 | } 127 | 128 | let promises: Promise[] = [] 129 | for (const [i, aliasChunk] of aliasChunks.entries()) { 130 | job.progress(Math.round((i / aliasChunks.length) * 100)) 131 | 132 | promises = [] 133 | for (const alias of aliasChunk) { 134 | promises.push(this.domainsService.deleteAlias(job.data.user, job.data.domain, alias.domain)) 135 | } 136 | await Promise.all(promises) 137 | } 138 | job.progress(100) 139 | } 140 | 141 | @OnQueueActive() 142 | private onActive(job: Job): void { 143 | this.logger.log( 144 | `Processing job ${job.id} (${job.name}) for user ${job.data.user._id.toHexString()} and domain ${ 145 | job.data.domain 146 | }`, 147 | ) 148 | } 149 | 150 | @OnQueueCompleted() 151 | private onCompleted(job: Job): void { 152 | this.logger.log(`Completed job ${job.id} (${job.name}) successfully`) 153 | } 154 | 155 | @OnQueueError() 156 | private onError(job: Job): void { 157 | this.logger.error(`Error for job ${job.id} (${job.name}): ${job.stacktrace}`) 158 | } 159 | 160 | @OnQueueFailed() 161 | private onFailed(job: Job): void { 162 | this.logger.error(`Job ${job.id} (${job.name}) failed!: ${job.stacktrace}`) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/tasks/suspension/suspension-config.service.ts: -------------------------------------------------------------------------------- 1 | import { BullModuleOptions, BullOptionsFactory } from '@nestjs/bull' 2 | import { Injectable } from '@nestjs/common' 3 | import { ConfigService } from 'src/config/config.service' 4 | 5 | @Injectable() 6 | export class SuspensionConfigService implements BullOptionsFactory { 7 | private redisUrl: string 8 | 9 | constructor(config: ConfigService) { 10 | this.redisUrl = config.REDIS_URL 11 | } 12 | 13 | createBullOptions(): BullModuleOptions { 14 | return { 15 | name: 'suspension', 16 | defaultJobOptions: { 17 | attempts: 5, 18 | backoff: { 19 | delay: 6000, 20 | type: 'exponential', 21 | }, 22 | removeOnComplete: 1000, 23 | }, 24 | redis: this.redisUrl, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/tasks/suspension/suspension.interfaces.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'src/users/user.entity' 2 | 3 | export interface SuspensionData { 4 | user: User 5 | } 6 | -------------------------------------------------------------------------------- /src/tasks/suspension/suspension.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { AccountsModule } from 'src/accounts/accounts.module' 3 | import { ForwardersModule } from 'src/forwarders/forwarders.module' 4 | 5 | import { SuspensionProcessor } from './suspension.processor' 6 | 7 | @Module({ 8 | imports: [AccountsModule, ForwardersModule], 9 | providers: [SuspensionProcessor], 10 | }) 11 | export class SuspensionModule {} 12 | -------------------------------------------------------------------------------- /src/tasks/suspension/suspension.processor.ts: -------------------------------------------------------------------------------- 1 | import { OnQueueActive, OnQueueCompleted, OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull' 2 | import { Logger } from '@nestjs/common' 3 | import { Job } from 'bull' 4 | import { AccountsService } from 'src/accounts/accounts.service' 5 | import { AccountListItem } from 'src/accounts/class/account-list-item.class' 6 | import { Forwarder } from 'src/forwarders/class/forwarder.class' 7 | import { ForwardersService } from 'src/forwarders/forwarders.service' 8 | 9 | import { SuspensionData } from './suspension.interfaces' 10 | 11 | @Processor('suspension') 12 | export class SuspensionProcessor { 13 | public constructor( 14 | private readonly accountsService: AccountsService, 15 | private readonly forwardersService: ForwardersService, 16 | ) {} 17 | 18 | private readonly logger = new Logger(SuspensionProcessor.name, true) 19 | 20 | @Process({ name: 'suspendAccounts' }) 21 | private async processSuspendAccounts(job: Job): Promise { 22 | let accounts: AccountListItem[] = [] 23 | try { 24 | accounts = await this.accountsService.getAccounts(job.data.user) 25 | } catch (error) { 26 | // Don't throw error if no accounts were found 27 | if (error.response.error === 'AccountNotFoundError') { 28 | return 29 | } else { 30 | throw error 31 | } 32 | } 33 | 34 | const accountChunks: AccountListItem[][] = [] 35 | const chunkSize = 10 36 | for (let i = 0; i < accounts.length; i += chunkSize) { 37 | accountChunks.push(accounts.slice(i, i + chunkSize)) 38 | } 39 | 40 | let promises: Promise[] = [] 41 | for (const [i, accountChunk] of accountChunks.entries()) { 42 | job.progress(Math.round((i / accountChunks.length) * 100)) 43 | 44 | promises = [] 45 | for (const account of accountChunk) { 46 | promises.push(this.accountsService.suspend(account.id, true)) 47 | } 48 | await Promise.all(promises) 49 | } 50 | job.progress(100) 51 | } 52 | 53 | @Process({ name: 'unsuspendAccounts' }) 54 | private async processUnsuspendAccounts(job: Job): Promise { 55 | let accounts: AccountListItem[] = [] 56 | try { 57 | accounts = await this.accountsService.getAccounts(job.data.user) 58 | } catch (error) { 59 | // Don't throw error if no accounts were found 60 | if (error.response.error === 'AccountNotFoundError') { 61 | return 62 | } else { 63 | throw error 64 | } 65 | } 66 | 67 | const accountChunks: AccountListItem[][] = [] 68 | const chunkSize = 10 69 | for (let i = 0; i < accounts.length; i += chunkSize) { 70 | accountChunks.push(accounts.slice(i, i + chunkSize)) 71 | } 72 | 73 | let promises: Promise[] = [] 74 | for (const [i, accountChunk] of accountChunks.entries()) { 75 | job.progress(Math.round((i / accountChunks.length) * 100)) 76 | 77 | promises = [] 78 | for (const account of accountChunk) { 79 | promises.push(this.accountsService.suspend(account.id, false)) 80 | } 81 | await Promise.all(promises) 82 | } 83 | job.progress(100) 84 | } 85 | 86 | @Process({ name: 'suspendForwarders' }) 87 | private async processSuspendForwarders(job: Job): Promise { 88 | let forwarders: Forwarder[] = [] 89 | try { 90 | forwarders = await this.forwardersService.getForwarders(job.data.user) 91 | } catch (error) { 92 | // Don't throw error if no accounts were found 93 | if (error.response.error === 'ForwarderNotFoundError') { 94 | return 95 | } else { 96 | throw error 97 | } 98 | } 99 | 100 | const forwarderChunks: Forwarder[][] = [] 101 | const chunkSize = 10 102 | for (let i = 0; i < forwarders.length; i += chunkSize) { 103 | forwarderChunks.push(forwarders.slice(i, i + chunkSize)) 104 | } 105 | 106 | let promises: Promise[] = [] 107 | for (const [i, forwarderChunk] of forwarderChunks.entries()) { 108 | job.progress(Math.round((i / forwarderChunks.length) * 100)) 109 | 110 | promises = [] 111 | for (const forwarder of forwarderChunk) { 112 | promises.push(this.forwardersService.disable(forwarder.id, true)) 113 | } 114 | await Promise.all(promises) 115 | } 116 | job.progress(100) 117 | } 118 | @Process({ name: 'unsuspendForwarders' }) 119 | private async processUnsuspendForwarders(job: Job): Promise { 120 | let forwarders: Forwarder[] = [] 121 | try { 122 | forwarders = await this.forwardersService.getForwarders(job.data.user) 123 | } catch (error) { 124 | // Don't throw error if no accounts were found 125 | if (error.response.error === 'ForwarderNotFoundError') { 126 | return 127 | } else { 128 | throw error 129 | } 130 | } 131 | 132 | const forwarderChunks: Forwarder[][] = [] 133 | const chunkSize = 10 134 | for (let i = 0; i < forwarders.length; i += chunkSize) { 135 | forwarderChunks.push(forwarders.slice(i, i + chunkSize)) 136 | } 137 | 138 | let promises: Promise[] = [] 139 | for (const [i, forwarderChunk] of forwarderChunks.entries()) { 140 | job.progress(Math.round((i / forwarderChunks.length) * 100)) 141 | 142 | promises = [] 143 | for (const forwarder of forwarderChunk) { 144 | promises.push(this.forwardersService.disable(forwarder.id, false)) 145 | } 146 | await Promise.all(promises) 147 | } 148 | job.progress(100) 149 | } 150 | 151 | @OnQueueActive() 152 | private onActive(job: Job): void { 153 | this.logger.log(`Processing job ${job.id} (${job.name}) for user ${job.data.user._id.toHexString()}`) 154 | } 155 | 156 | @OnQueueCompleted() 157 | private onCompleted(job: Job): void { 158 | this.logger.log(`Completed job ${job.id} (${job.name}) successfully`) 159 | } 160 | 161 | @OnQueueError() 162 | private onError(job: Job): void { 163 | this.logger.error(`Error for job ${job.id} (${job.name}): ${job.stacktrace}`) 164 | } 165 | 166 | @OnQueueFailed() 167 | private onFailed(job: Job): void { 168 | this.logger.error(`Job ${job.id} (${job.name}) failed!: ${job.stacktrace}`) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsAscii, IsMongoId, IsNotEmpty, IsOptional, IsString, NotContains } from 'class-validator' 3 | 4 | export class CreateUserDto { 5 | @ApiProperty({ 6 | example: 'johndoe', 7 | description: 'The username for this user', 8 | }) 9 | @IsNotEmpty() 10 | @IsString() 11 | @IsAscii() 12 | @NotContains(' ', { message: 'username must not contain spaces' }) 13 | public username: string 14 | 15 | @ApiProperty({ 16 | example: 'supersecret', 17 | description: 'The password for this user', 18 | }) 19 | @IsNotEmpty() 20 | @IsString() 21 | public password: string 22 | 23 | @ApiPropertyOptional({ 24 | example: '5d49e11f600a423ffc0b1297', 25 | description: 'Package id to assign to this user', 26 | required: true, 27 | }) 28 | @IsOptional() 29 | @IsMongoId() 30 | public packageId?: string 31 | } 32 | -------------------------------------------------------------------------------- /src/users/dto/delete-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsOptional } from 'class-validator' 3 | 4 | export class DeleteUserDto { 5 | @ApiPropertyOptional({ 6 | description: 'If true will not delete the user, but delete all domains and suspend the user', 7 | example: true, 8 | }) 9 | @IsOptional() 10 | @IsBoolean() 11 | onlyDeleteDomainsAndSuspend: boolean 12 | } 13 | -------------------------------------------------------------------------------- /src/users/dto/update-user-admin.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsBoolean, IsMongoId, IsOptional } from 'class-validator' 3 | 4 | import { UpdateUserDto } from './update-user.dto' 5 | 6 | export class UpdateUserAdminDto extends UpdateUserDto { 7 | @ApiProperty({ 8 | example: '5d49e11f600a423ffc0b1297', 9 | description: 'Package id to assign to this user', 10 | }) 11 | @IsOptional() 12 | @IsMongoId() 13 | public packageId?: string 14 | 15 | @ApiProperty({ 16 | example: false, 17 | description: 18 | "A suspended user doesn't have access to most api methods, and all accounts and forwarders are suspended", 19 | }) 20 | @IsOptional() 21 | @IsBoolean() 22 | public suspended?: boolean 23 | } 24 | -------------------------------------------------------------------------------- /src/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsAscii, IsNotEmpty, IsOptional, IsString, NotContains } from 'class-validator' 3 | 4 | export class UpdateUserDto { 5 | @ApiProperty({ 6 | example: 'johndoe', 7 | description: 'The username for this user', 8 | }) 9 | @IsOptional() 10 | @IsNotEmpty() 11 | @IsString() 12 | @IsAscii() 13 | @NotContains(' ', { message: 'username must not contain spaces' }) 14 | public username?: string 15 | 16 | @ApiProperty({ 17 | example: 'supersecret', 18 | description: 'The password for this user', 19 | }) 20 | @IsOptional() 21 | @IsNotEmpty() 22 | @IsString() 23 | public password?: string 24 | } 25 | -------------------------------------------------------------------------------- /src/users/dto/user-id-params.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsMongoId } from 'class-validator' 3 | 4 | export class UserIdParams { 5 | @ApiProperty({ 6 | example: '5d49e11f600a423ffc0b1297', 7 | description: 'Unique id for the user', 8 | }) 9 | @IsMongoId() 10 | id: string 11 | } 12 | -------------------------------------------------------------------------------- /src/users/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import Bcrypt from 'bcrypt' 3 | import { ObjectId } from 'mongodb' 4 | import { Domain } from 'src/domains/domain.entity' 5 | import { AfterLoad, BeforeInsert, BeforeUpdate, Column, Entity, ObjectIdColumn } from 'typeorm' 6 | 7 | @Entity('users') 8 | export class User { 9 | @ObjectIdColumn() 10 | @ApiProperty({ 11 | example: '5d49e11f600a423ffc0b1297', 12 | description: 'Unique id for this user', 13 | type: String, 14 | }) 15 | public _id?: ObjectId 16 | 17 | @Column() 18 | @ApiProperty({ example: 'johndoe', description: 'The username for this user' }) 19 | public username: string 20 | 21 | @Column() 22 | public password?: string 23 | 24 | @Column() 25 | public minTokenDate: Date 26 | 27 | @Column(() => Domain) 28 | public domains: Domain[] 29 | 30 | @Column() 31 | @ApiProperty({ 32 | example: false, 33 | description: 34 | "A suspended user doesn't have access to most api methods, and all accounts and forwarders are suspended", 35 | }) 36 | public suspended: boolean 37 | 38 | @Column() 39 | @ApiProperty({ 40 | example: ['user'], 41 | description: 'User roles', 42 | }) 43 | public roles: string[] 44 | 45 | @Column() 46 | @ApiProperty({ 47 | example: '5d49e11f600a423ffc0b1297', 48 | description: 'Package id for this user', 49 | type: String, 50 | }) 51 | public packageId?: ObjectId 52 | 53 | @Column() 54 | @ApiProperty({ 55 | example: 1073741824, 56 | description: 'Storage quota in bytes, 0 is unlimited', 57 | }) 58 | public quota?: number 59 | 60 | @Column() 61 | @ApiProperty({ 62 | example: 200, 63 | description: 'Max send quota for accounts created by this user, 0 is unlimited', 64 | }) 65 | public maxSend?: number 66 | 67 | @Column() 68 | @ApiProperty({ 69 | example: 1000, 70 | description: 'Max recieve quota for accounts created by this user, 0 is unlimited', 71 | }) 72 | public maxReceive?: number 73 | 74 | @Column() 75 | @ApiProperty({ 76 | example: 100, 77 | description: 'Max forward quota for accounts created by this user, 0 is unlimited', 78 | }) 79 | public maxForward?: number 80 | 81 | @BeforeInsert() 82 | @BeforeUpdate() 83 | private async hashPassword(): Promise { 84 | if (this.password) { 85 | this.password = await Bcrypt.hash(this.password, 10) 86 | } 87 | } 88 | 89 | @BeforeInsert() 90 | private async setDefaultInsertValues(): Promise { 91 | this.minTokenDate = new Date() 92 | if (!this.domains) { 93 | this.domains = [] 94 | } 95 | } 96 | 97 | @AfterLoad() 98 | private async setMissingLimitsToZero(): Promise { 99 | const limits = ['quota', 'maxSend', 'maxReceive', 'maxForward'] 100 | for (const limit of limits) { 101 | if (this[limit] === undefined) { 102 | this[limit] = 0 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/users/users.cli.ts: -------------------------------------------------------------------------------- 1 | import genPassword from 'generate-password' 2 | import { Command, Console, createSpinner } from 'nestjs-console' 3 | 4 | import { CreateUserDto } from './dto/create-user.dto' 5 | import { UsersService } from './users.service' 6 | 7 | @Console() 8 | export class UsersCli { 9 | public constructor(private readonly usersService: UsersService) {} 10 | 11 | @Command({ 12 | command: 'create-admin [password]', 13 | description: 'Create admin user', 14 | }) 15 | async createAdmin(username: string, password?: string): Promise { 16 | const spinner = createSpinner() 17 | 18 | if (!password) { 19 | spinner.start(`Generating password`) 20 | password = genPassword.generate({ 21 | length: 12, 22 | numbers: true, 23 | excludeSimilarCharacters: true, 24 | }) 25 | spinner.succeed('Password generated') 26 | } 27 | 28 | spinner.start(`Creating admin user with username: "${username}" and password: "${password}"`) 29 | const user: CreateUserDto = { 30 | username: username, 31 | password: password, 32 | } 33 | 34 | try { 35 | await this.usersService.createUser(user, true) 36 | } catch (error) { 37 | spinner.fail(`Failed creating admin user with username: "${username}" and password: "${password}":`) 38 | console.error(error) 39 | process.exit(1) 40 | } 41 | spinner.succeed(`Created admin user with username: "${username}" and password: "${password}"`) 42 | process.exit(0) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { UsersController } from './users.controller' 4 | 5 | describe('Users Controller', (): void => { 6 | let controller: UsersController 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [UsersController], 12 | }).compile() 13 | 14 | controller = module.get(UsersController) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(controller).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiBody, 7 | ApiCreatedResponse, 8 | ApiOkResponse, 9 | ApiOperation, 10 | ApiTags, 11 | ApiUnauthorizedResponse, 12 | } from '@nestjs/swagger' 13 | import { ReqUser } from 'src/common/decorators/req-user.decorator' 14 | import { Roles } from 'src/common/decorators/roles.decorator' 15 | import { RolesGuard } from 'src/common/guards/roles.guard' 16 | 17 | import { CreateUserDto } from './dto/create-user.dto' 18 | import { DeleteUserDto } from './dto/delete-user.dto' 19 | import { UpdateUserAdminDto } from './dto/update-user-admin.dto' 20 | import { UpdateUserDto } from './dto/update-user.dto' 21 | import { UserIdParams } from './dto/user-id-params.dto' 22 | import { User } from './user.entity' 23 | import { UsersService } from './users.service' 24 | 25 | @Controller('users') 26 | @UseGuards(AuthGuard('jwt'), RolesGuard) 27 | @ApiBearerAuth() 28 | @ApiUnauthorizedResponse({ description: 'Invalid or expired token' }) 29 | @ApiBadRequestResponse({ description: 'Bad user input' }) 30 | export class UsersController { 31 | public constructor(private readonly usersService: UsersService) {} 32 | 33 | @Get() 34 | @Roles('admin') 35 | @ApiTags('Users') 36 | @ApiOperation({ operationId: 'getUsers', summary: '[Admin only] List all users' }) 37 | @ApiOkResponse({ description: 'list of users', type: User, isArray: true }) 38 | public async getUsers(): Promise { 39 | return (await this.usersService.getUsers()).map((user) => { 40 | delete user.password 41 | delete user.minTokenDate 42 | delete user.domains 43 | return user 44 | }) 45 | } 46 | 47 | @Post() 48 | @Roles('admin') 49 | @ApiTags('Users') 50 | @ApiOperation({ operationId: 'createUser', summary: '[Admin only] Create new API user' }) 51 | @ApiCreatedResponse({ description: 'User successfully created' }) 52 | public async createUser(@Body() createUserDto: CreateUserDto): Promise { 53 | await this.usersService.createUser(createUserDto) 54 | } 55 | 56 | @Get('me') 57 | @Roles('user', 'admin') 58 | @ApiTags('Profile') 59 | @ApiOperation({ operationId: 'getMe', summary: 'Get account info for current access token' }) 60 | @ApiOkResponse({ description: 'User info', type: User }) 61 | public async getMe(@ReqUser() user: User): Promise { 62 | delete user.password 63 | delete user.minTokenDate 64 | delete user.domains 65 | return user 66 | } 67 | 68 | @Put('me') 69 | @Roles('user', 'admin') 70 | @ApiTags('Profile') 71 | @ApiOperation({ operationId: 'updateMe', summary: 'Update username/password' }) 72 | @ApiOkResponse({ description: 'User updated successfully' }) 73 | public async updateMe(@ReqUser() user: User, @Body() updateUserDto: UpdateUserDto): Promise { 74 | await this.usersService.updateUsernameOrPassword(user._id.toHexString(), updateUserDto) 75 | } 76 | 77 | @Delete(':id') 78 | @Roles('admin') 79 | @ApiTags('Users') 80 | @ApiOperation({ operationId: 'deleteUser', summary: '[Admin only] Delete API user' }) 81 | @ApiBody({ required: false, type: DeleteUserDto }) 82 | @ApiOkResponse({ description: 'User successfully deleted' }) 83 | public async deleteUser(@Param() userIdParams: UserIdParams, @Body() deleteUserDto?: DeleteUserDto): Promise { 84 | this.usersService.deleteUser(userIdParams.id, deleteUserDto?.onlyDeleteDomainsAndSuspend) 85 | } 86 | 87 | @Put(':id') 88 | @Roles('admin') 89 | @ApiTags('Users') 90 | @ApiOperation({ operationId: 'updateUser', summary: '[Admin only] Update API user' }) 91 | @ApiOkResponse() 92 | public async updateUser( 93 | @Body() updateUserAdminDto: UpdateUserAdminDto, 94 | @Param() userIdParams: UserIdParams, 95 | ): Promise { 96 | if (updateUserAdminDto.username || updateUserAdminDto.password) { 97 | this.usersService.updateUsernameOrPassword(userIdParams.id, updateUserAdminDto) 98 | } 99 | if (updateUserAdminDto.packageId) { 100 | this.usersService.updatePackage(userIdParams.id, updateUserAdminDto.packageId) 101 | } 102 | if (updateUserAdminDto.suspended !== undefined) { 103 | this.usersService.suspend(userIdParams.id, updateUserAdminDto.suspended) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { BullModule } from '@nestjs/bull' 2 | import { forwardRef, Module } from '@nestjs/common' 3 | import { TypeOrmModule } from '@nestjs/typeorm' 4 | import { DomainsModule } from 'src/domains/domains.module' 5 | import { PackagesModule } from 'src/packages/packages.module' 6 | import { DeleteForDomainConfigService } from 'src/tasks/delete-for-domain/delete-for-domain-config.service' 7 | import { SuspensionConfigService } from 'src/tasks/suspension/suspension-config.service' 8 | 9 | import { User } from './user.entity' 10 | import { UsersCli } from './users.cli' 11 | import { UsersController } from './users.controller' 12 | import { UsersService } from './users.service' 13 | 14 | @Module({ 15 | imports: [ 16 | TypeOrmModule.forFeature([User]), 17 | BullModule.registerQueueAsync( 18 | { 19 | name: 'suspension', 20 | useClass: SuspensionConfigService, 21 | }, 22 | { 23 | name: 'deleteForDomain', 24 | useClass: DeleteForDomainConfigService, 25 | }, 26 | ), 27 | forwardRef(() => PackagesModule), 28 | forwardRef(() => DomainsModule), 29 | ], 30 | providers: [UsersService, UsersCli], 31 | exports: [UsersService], 32 | controllers: [UsersController], 33 | }) 34 | export class UsersModule {} 35 | -------------------------------------------------------------------------------- /src/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | 3 | import { UsersService } from './users.service' 4 | 5 | describe('UsersService', (): void => { 6 | let service: UsersService 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [UsersService], 12 | }).compile() 13 | 14 | service = module.get(UsersService) 15 | }, 16 | ) 17 | 18 | it('should be defined', (): void => { 19 | expect(service).toBeDefined() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { InjectQueue } from '@nestjs/bull' 2 | import { 3 | BadRequestException, 4 | forwardRef, 5 | Inject, 6 | Injectable, 7 | InternalServerErrorException, 8 | Logger, 9 | NotFoundException, 10 | } from '@nestjs/common' 11 | import { InjectRepository } from '@nestjs/typeorm' 12 | import { Queue } from 'bull' 13 | import { ObjectID, ObjectId } from 'mongodb' 14 | import { nanoid as NanoId } from 'nanoid' 15 | import { Domain, DomainAlias } from 'src/domains/domain.entity' 16 | import { DomainsService } from 'src/domains/domains.service' 17 | import { Package } from 'src/packages/package.entity' 18 | import { PackagesService } from 'src/packages/packages.service' 19 | import { DeleteForDomainData } from 'src/tasks/delete-for-domain/delete-for-domain.interfaces' 20 | import { SuspensionData } from 'src/tasks/suspension/suspension.interfaces' 21 | import { MongoRepository } from 'typeorm' 22 | 23 | import { CreateUserDto } from './dto/create-user.dto' 24 | import { UpdateUserDto } from './dto/update-user.dto' 25 | import { User } from './user.entity' 26 | 27 | @Injectable() 28 | export class UsersService { 29 | constructor( 30 | @InjectRepository(User) 31 | private readonly userRepository: MongoRepository, 32 | @Inject(forwardRef(() => PackagesService)) 33 | private readonly packagesService: PackagesService, 34 | @Inject(forwardRef(() => DomainsService)) 35 | private readonly domainsService: DomainsService, 36 | @InjectQueue('suspension') 37 | readonly suspensionQueue: Queue, 38 | @InjectQueue('deleteForDomain') 39 | readonly deleteForDomainQueue: Queue, 40 | ) {} 41 | private readonly logger = new Logger(UsersService.name, true) 42 | 43 | public async getUsers(): Promise { 44 | return this.userRepository.find() 45 | } 46 | 47 | public async findByUsername(username: string): Promise { 48 | username = username.toLowerCase() 49 | return this.userRepository.findOne({ 50 | username: username, 51 | }) 52 | } 53 | 54 | public async findByPackage(packageId: string): Promise { 55 | return this.userRepository.find({ 56 | where: { 57 | packageId: new ObjectId(packageId), 58 | }, 59 | }) 60 | } 61 | 62 | public async countByPackage(packageId: string): Promise { 63 | return this.userRepository.count({ 64 | where: { 65 | package: new ObjectId(packageId), 66 | }, 67 | }) 68 | } 69 | 70 | public async findById(id: string): Promise { 71 | return this.userRepository.findOne(id) 72 | } 73 | 74 | public async findByIdNoPassword(id: string): Promise { 75 | const user = await this.userRepository.findOne(id) 76 | if (!user) { 77 | return undefined 78 | } 79 | delete user.password 80 | return user 81 | } 82 | 83 | public async deleteUser(id: string, onlyDeleteDomainsAndSuspend = false): Promise { 84 | const user = await this.findByIdNoPassword(id) 85 | if (user) { 86 | await this.domainsService.deleteAllDomains(user) 87 | if (onlyDeleteDomainsAndSuspend) { 88 | this.suspend(id, true) 89 | } else { 90 | this.userRepository.delete(id) 91 | } 92 | } 93 | } 94 | 95 | public async findByDomain(domain: string): Promise { 96 | return this.userRepository.find({ 97 | where: { 98 | $or: [ 99 | { 100 | 'domains.domain': domain, 101 | }, 102 | { 103 | 'domains.aliases': domain, 104 | }, 105 | ], 106 | }, 107 | }) 108 | } 109 | 110 | public async countByDomain(domain: string): Promise { 111 | return this.userRepository.count({ 112 | $or: [ 113 | { 114 | 'domains.domain': domain, 115 | }, 116 | { 117 | 'domains.aliases': domain, 118 | }, 119 | ], 120 | }) 121 | } 122 | 123 | public async pushDomain(userId: string, domain: Domain): Promise { 124 | const user = await this.findByIdNoPassword(userId) 125 | const userEntity = new User() 126 | Object.assign(userEntity, user) 127 | 128 | userEntity.domains.push(domain) 129 | 130 | try { 131 | return await this.userRepository.save(userEntity) 132 | } catch (error) { 133 | // TODO: add custom exception handler for unknown errors that basically does the following: 134 | const errorId = NanoId() 135 | this.logger.error(`${errorId}: ${error.message}`) 136 | throw new InternalServerErrorException(`Unknown error: ${errorId}`) 137 | } 138 | } 139 | 140 | public async pullDomain(userId: string, domain: string): Promise { 141 | const user = await this.findByIdNoPassword(userId) 142 | const userEntity = new User() 143 | Object.assign(userEntity, user) 144 | 145 | // New array of only domains that don't match domain. 146 | userEntity.domains = userEntity.domains.filter((domainObject) => domainObject.domain !== domain) 147 | 148 | try { 149 | return this.userRepository.save(userEntity) 150 | } catch (error) { 151 | // TODO: add custom exception handler for unknown errors that basically does the following: 152 | const errorId = NanoId() 153 | this.logger.error(`${errorId}: ${error.message}`) 154 | throw new InternalServerErrorException(`Unknown error: ${errorId}`) 155 | } 156 | } 157 | 158 | public async pushAlias(userId: string, domain: string, alias: DomainAlias): Promise { 159 | const user = await this.findByIdNoPassword(userId) 160 | const userEntity = new User() 161 | Object.assign(userEntity, user) 162 | 163 | userEntity.domains = userEntity.domains.map((userDomain) => { 164 | if (userDomain.domain === domain) { 165 | if (!userDomain.aliases) { 166 | userDomain.aliases = [] 167 | } 168 | userDomain.aliases.push(alias) 169 | } 170 | return userDomain 171 | }) 172 | 173 | try { 174 | return await this.userRepository.save(userEntity) 175 | } catch (error) { 176 | const errorId = NanoId() 177 | this.logger.error(`${errorId}: ${error.message}`) 178 | throw new InternalServerErrorException(`Unknown error: ${errorId}`) 179 | } 180 | } 181 | 182 | public async pullAlias(userId: string, alias: string): Promise { 183 | const user = await this.findByIdNoPassword(userId) 184 | const userEntity = new User() 185 | Object.assign(userEntity, user) 186 | 187 | userEntity.domains = userEntity.domains.map((userDomain) => { 188 | if (userDomain.aliases) { 189 | // New array of only aliases that don't match alias. 190 | userDomain.aliases = userDomain.aliases.filter((domainAlias) => domainAlias.domain !== alias) 191 | } 192 | return userDomain 193 | }) 194 | 195 | try { 196 | return this.userRepository.save(userEntity) 197 | } catch (error) { 198 | // TODO: add custom exception handler for unknown errors that basically does the following: 199 | const errorId = NanoId() 200 | this.logger.error(`${errorId}: ${error.message}`) 201 | throw new InternalServerErrorException(`Unknown error: ${errorId}`) 202 | } 203 | } 204 | 205 | public async updateMinTokenDate(userId: string, date = new Date()): Promise { 206 | const user = await this.findByIdNoPassword(userId) 207 | const userEntity = new User() 208 | Object.assign(userEntity, user) 209 | 210 | userEntity.minTokenDate = date 211 | 212 | try { 213 | return await this.userRepository.save(userEntity) 214 | } catch (error) { 215 | this.logger.error(error.message) 216 | throw new InternalServerErrorException('Unknown error') 217 | } 218 | } 219 | 220 | public async createUser(createUserDto: CreateUserDto, admin = false): Promise { 221 | createUserDto.username = createUserDto.username.toLowerCase() 222 | 223 | let userPackage: Package 224 | const hasPackage: boolean = !admin && createUserDto.packageId !== undefined 225 | if (hasPackage) { 226 | userPackage = await this.packagesService.getPackageById(createUserDto.packageId) 227 | if (!userPackage) { 228 | throw new BadRequestException(`No package found with id ${createUserDto.packageId}`, 'PackageNotFoundError') 229 | } 230 | } 231 | 232 | const newUser: Partial = { 233 | packageId: hasPackage ? new ObjectId(createUserDto.packageId) : undefined, 234 | quota: hasPackage ? userPackage.quota : undefined, 235 | maxSend: hasPackage ? userPackage.maxSend : undefined, 236 | maxReceive: hasPackage ? userPackage.maxReceive : undefined, 237 | maxForward: hasPackage ? userPackage.maxForward : undefined, 238 | roles: admin ? ['admin'] : ['user'], 239 | } 240 | Object.assign(newUser, createUserDto) 241 | 242 | const createdUser = this.userRepository.create(newUser) 243 | 244 | try { 245 | return await this.userRepository.save(createdUser) 246 | } catch (error) { 247 | switch (error.code) { 248 | case 11000: 249 | throw new BadRequestException('This user already exists', 'UserExistsError') 250 | 251 | default: 252 | throw new InternalServerErrorException('Unknown error') 253 | } 254 | } 255 | } 256 | 257 | public async updateUsernameOrPassword(userId: string, updateuserDto: UpdateUserDto): Promise { 258 | const user = await this.findByIdNoPassword(userId) 259 | if (!user) { 260 | throw new NotFoundException(`No user found with id: ${userId}`) 261 | } 262 | const userEntity = new User() 263 | Object.assign(userEntity, user) 264 | 265 | if (updateuserDto.username) { 266 | updateuserDto.username = updateuserDto.username.toLowerCase() 267 | userEntity.username = updateuserDto.username 268 | } 269 | if (updateuserDto.password) { 270 | userEntity.password = updateuserDto.password 271 | } 272 | 273 | try { 274 | return await this.userRepository.save(userEntity) 275 | } catch (error) { 276 | switch (error.code) { 277 | case 11000: 278 | throw new BadRequestException('This username is already taken', 'UserExistsError') 279 | 280 | default: 281 | throw new InternalServerErrorException('Unknown error') 282 | } 283 | } 284 | } 285 | 286 | public async updatePackage(userId: string, packageId: string): Promise { 287 | const userPackage = await this.packagesService.getPackageById(packageId) 288 | if (!userPackage) { 289 | throw new BadRequestException(`No package found with id ${packageId}`, 'PackageNotFoundError') 290 | } 291 | 292 | const user = await this.findByIdNoPassword(userId) 293 | if (!user) { 294 | throw new NotFoundException(`No user found with id: ${userId}`) 295 | } 296 | 297 | const userEntity = new User() 298 | Object.assign(userEntity, user) 299 | 300 | userEntity.packageId = new ObjectId(packageId) 301 | userEntity.quota = userPackage.quota 302 | userEntity.maxForward = userPackage.maxForward 303 | userEntity.maxReceive = userPackage.maxReceive 304 | userEntity.maxSend = userPackage.maxSend 305 | 306 | return this.userRepository.save(userEntity) 307 | } 308 | 309 | public async replacelimitForPackage( 310 | packageId: string, 311 | limit: 'quota' | 'maxForward' | 'maxReceive' | 'maxSend', 312 | oldLimit: number, 313 | newLimit: number, 314 | ): Promise { 315 | this.userRepository.update( 316 | { 317 | packageId: new ObjectID(packageId), 318 | [limit]: oldLimit, 319 | }, 320 | { 321 | [limit]: newLimit, 322 | }, 323 | ) 324 | } 325 | 326 | public async suspend(userId: string, suspend = true): Promise { 327 | const user = await this.findByIdNoPassword(userId) 328 | if (!user) { 329 | throw new NotFoundException(`No user found with id: ${userId}`) 330 | } 331 | const userEntity = new User() 332 | Object.assign(userEntity, user) 333 | 334 | userEntity.suspended = suspend 335 | 336 | this.userRepository.save(userEntity) 337 | 338 | this.suspensionQueue.add(suspend ? 'suspendAccounts' : 'unsuspendAccounts', { 339 | user: userEntity, 340 | }) 341 | this.suspensionQueue.add(suspend ? 'suspendForwarders' : 'unsuspendForwarders', { 342 | user: userEntity, 343 | }) 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { AppModule } from 'src/app.module' 3 | import Request from 'supertest' 4 | 5 | describe('AppController (e2e)', (): void => { 6 | let app 7 | 8 | beforeEach( 9 | async (): Promise => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile() 13 | 14 | app = moduleFixture.createNestApplication() 15 | await app.init() 16 | }, 17 | ) 18 | 19 | it('/ (GET)', (): Request.Test => { 20 | return Request(app.getHttpServer()).get('/').expect(200).expect('Hello World!') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "pretty": true, 14 | "esModuleInterop": true 15 | }, 16 | "exclude": ["node_modules", "dist", "duckypanel"] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin') 3 | 4 | module.exports = { 5 | entry: { 6 | main: './src/main.ts', 7 | cli: './src/cli.ts', 8 | 'generate-openapi': './scripts/generate-openapi.ts', 9 | }, 10 | target: 'node', 11 | resolve: { 12 | extensions: ['.tsx', '.ts', '.js'], 13 | plugins: [new TsconfigPathsPlugin()], 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /.tsx?$/, 19 | use: 'ts-loader', 20 | exclude: /node_modules/, 21 | }, 22 | ], 23 | }, 24 | output: { 25 | path: path.join(__dirname, 'dist'), 26 | filename: '[name].js', 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const baseConfig = require('./webpack.config.base.js') 3 | const nodeExternals = require('webpack-node-externals') 4 | const webpack = require('webpack') 5 | 6 | module.exports = merge(baseConfig, { 7 | entry: { 8 | main: ['webpack/hot/poll?100', './src/main.ts'], 9 | cli: './src/cli.ts', 10 | 'generate-openapi': './scripts/generate-openapi.ts', 11 | }, 12 | mode: 'development', 13 | watch: true, 14 | externals: [ 15 | nodeExternals({ 16 | allowlist: ['webpack/hot/poll?100'], 17 | }), 18 | ], 19 | devtool: 'eval-source-map', 20 | plugins: [new webpack.HotModuleReplacementPlugin()], 21 | }) 22 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const baseConfig = require('./webpack.config.base.js') 3 | const nodeExternals = require('webpack-node-externals') 4 | 5 | module.exports = merge(baseConfig, { 6 | mode: 'production', 7 | optimization: { 8 | minimize: false, 9 | }, 10 | externals: [nodeExternals()], 11 | }) 12 | --------------------------------------------------------------------------------