├── .env
├── .eslintrc.js
├── .github
└── workflows
│ ├── njsscan.yml
│ └── node.js.yml
├── .gitignore
├── .prettierrc
├── .swcrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── package.json
├── pnpm-lock.yaml
├── setup
├── clone.sh
├── config.txt
├── mongo.sh
├── nginx.sh
├── pm2.sh
└── setup.sh
├── src
├── api
│ └── client.ts
├── constants
│ ├── config.ts
│ ├── index.ts
│ └── termcolors.ts
├── index.ts
├── middleware
│ ├── auth.ts
│ ├── compression.ts
│ ├── limiter.ts
│ └── multer.ts
├── resources
│ └── user
│ │ ├── controller.ts
│ │ └── model.ts
├── server.ts
└── utils
│ ├── apiError.ts
│ ├── apiErrorHandler.ts
│ ├── db.ts
│ ├── email.ts
│ ├── formatting.ts
│ ├── index.ts
│ ├── killProcessOnPort.ts
│ ├── measured.ts
│ ├── process.ts
│ ├── router.ts
│ ├── socketio.ts
│ ├── stripeWebhooks.ts
│ ├── terminate.ts
│ ├── types.ts
│ ├── validation.ts
│ └── withInterval.ts
└── tsconfig.json
/.env:
--------------------------------------------------------------------------------
1 | PORT=4000
2 | BASE_URL=http://localhost:4000
3 |
4 | NODE_ENV=production
5 | LOG_LEVEL=error # debug, info, warn, error
6 | JWT_SECRET=D04rSbcNX5NNZSNf5a4adEn0Dz5VJFuKuE
7 |
8 | MONGODB_HOST=127.0.0.1
9 | MONGODB_PORT=27017
10 | MONGODB_DB_NAME=NODE_API
11 | MONGODB_USER=NODE_API
12 | MONGODB_PASSWORD=NODE_API
13 |
14 | EMAIL_HOST=smtp.gmail.com
15 | EMAIL_PORT=587
16 | EMAIL_ADDRESS=email@email.com
17 | EMAIL_PASSWORD=email_password
18 |
19 | REDIS_PORT=6379
20 | REDIS_HOST=127.0.0.1
21 | AWS_REGION=eu-west-2
22 | AWS_PARAMETER_STORE_PATH=AAA
23 | AWS_ACCESS_KEY_ID=AAA
24 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | es2021: true,
4 | node: true,
5 | },
6 | extends: ['standard-with-typescript', 'plugin:n/recommended', 'plugin:import/recommended', 'plugin:promise/recommended'],
7 | overrides: [
8 | {
9 | files: ['*.ts'],
10 | },
11 | ],
12 | parserOptions: {
13 | ecmaVersion: 'latest',
14 | sourceType: 'module',
15 | project: './tsconfig.json',
16 | },
17 | rules: {
18 | '@typescript-eslint/prefer-nullish-coalescing': 'off',
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/.github/workflows/njsscan.yml:
--------------------------------------------------------------------------------
1 | # This workflow uses actions that are not certified by GitHub.
2 | # They are provided by a third-party and are governed by
3 | # separate terms of service, privacy policy, and support
4 | # documentation.
5 |
6 | # This workflow integrates njsscan with GitHub's Code Scanning feature
7 | # nodejsscan is a static security code scanner that finds insecure code patterns in your Node.js applications
8 |
9 | name: njsscan sarif
10 |
11 | on:
12 | push:
13 | branches: [ "master" ]
14 | pull_request:
15 | # The branches below must be a subset of the branches above
16 | branches: [ "master" ]
17 | schedule:
18 | - cron: '27 18 * * 2'
19 |
20 | permissions:
21 | contents: read
22 |
23 | jobs:
24 | njsscan:
25 | permissions:
26 | contents: read # for actions/checkout to fetch code
27 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
29 | runs-on: ubuntu-latest
30 | name: njsscan code scanning
31 | steps:
32 | - name: Checkout the code
33 | uses: actions/checkout@v3
34 | - name: nodejsscan scan
35 | id: njsscan
36 | uses: ajinabraham/njsscan-action@7237412fdd36af517e2745077cedbf9d6900d711
37 | with:
38 | args: '. --sarif --output results.sarif || true'
39 | - name: Upload njsscan report
40 | uses: github/codeql-action/upload-sarif@v2
41 | with:
42 | sarif_file: results.sarif
43 |
--------------------------------------------------------------------------------
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: Node.js CI
5 |
6 | on:
7 | push:
8 | branches: [ "master" ]
9 | pull_request:
10 | branches: [ "master" ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | strategy:
18 | matrix:
19 | node-version: [18.x]
20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
21 |
22 | steps:
23 | - uses: actions/checkout@v3
24 | - name: Use Node.js ${{ matrix.node-version }}
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 | cache: 'npm'
29 | - run: npm ci
30 | - run: npm run build --if-present
31 | - run: npm test --if-present
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Node.gitignore
2 | # Logs
3 | logs
4 | *.log
5 | npm-debug.log*
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 | .pnpm-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
13 |
14 | # Runtime data
15 | pids
16 | *.pid
17 | *.seed
18 | *.pid.lock
19 |
20 | # Directory for instrumented libs generated by jscoverage/JSCover
21 | lib-cov
22 |
23 | # Coverage directory used by tools like istanbul
24 | coverage
25 | *.lcov
26 |
27 | # nyc test coverage
28 | .nyc_output
29 |
30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
31 | .grunt
32 |
33 | # Bower dependency directory (https://bower.io/)
34 | bower_components
35 |
36 | # node-waf configuration
37 | .lock-wscript
38 |
39 | # Compiled binary addons (https://nodejs.org/api/addons.html)
40 | build/Release
41 |
42 | # Dependency directories
43 | node_modules/
44 | jspm_packages/
45 |
46 | # Snowpack dependency directory (https://snowpack.dev/)
47 | web_modules/
48 |
49 | # TypeScript cache
50 | *.tsbuildinfo
51 |
52 | # Optional npm cache directory
53 | .npm
54 |
55 | # Optional eslint cache
56 | .eslintcache
57 |
58 | # Optional stylelint cache
59 | .stylelintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variable files
77 | .env
78 | .env.development.local
79 | .env.test.local
80 | .env.production.local
81 | .env.local
82 |
83 | # parcel-bundler cache (https://parceljs.org/)
84 | .cache
85 | .parcel-cache
86 |
87 | # Next.js build output
88 | .next
89 | out
90 |
91 | # Nuxt.js build / generate output
92 | .nuxt
93 | dist
94 |
95 | # Gatsby files
96 | .cache/
97 | # Comment in the public line in if your project uses Gatsby and not Next.js
98 | # https://nextjs.org/blog/next-9-1#public-directory-support
99 | # public
100 |
101 | # vuepress build output
102 | .vuepress/dist
103 |
104 | # vuepress v2.x temp and cache directory
105 | .temp
106 | .cache
107 |
108 | # Docusaurus cache and generated files
109 | .docusaurus
110 |
111 | # Serverless directories
112 | .serverless/
113 |
114 | # FuseBox cache
115 | .fusebox/
116 |
117 | # DynamoDB Local files
118 | .dynamodb/
119 |
120 | # TernJS port file
121 | .tern-port
122 |
123 | # Stores VSCode versions used for testing VSCode extensions
124 | .vscode-test
125 |
126 | # yarn v2
127 | .yarn/cache
128 | .yarn/unplugged
129 | .yarn/build-state.yml
130 | .yarn/install-state.gz
131 | .pnp.*
132 |
133 | ### macOS.gitignore
134 | # General
135 | .DS_Store
136 | .AppleDouble
137 | .LSOverride
138 |
139 | # Icon must end with two \r
140 | Icon
141 |
142 |
143 | # Thumbnails
144 | ._*
145 |
146 | # Files that might appear in the root of a volume
147 | .DocumentRevisions-V100
148 | .fseventsd
149 | .Spotlight-V100
150 | .TemporaryItems
151 | .Trashes
152 | .VolumeIcon.icns
153 | .com.apple.timemachine.donotpresent
154 |
155 | # Directories potentially created on remote AFP share
156 | .AppleDB
157 | .AppleDesktop
158 | Network Trash Folder
159 | Temporary Items
160 | .apdisk
161 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "semi": false,
5 | "bracketSameLine": false,
6 | "printWidth": 180,
7 | "tabWidth": 1,
8 | "useTabs": true,
9 | "arrowParens": "avoid",
10 | "endOfLine": "auto"
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/.swcrc:
--------------------------------------------------------------------------------
1 | {
2 | "jsc": {
3 | "parser": {
4 | "syntax": "typescript",
5 | "tsx": true
6 | },
7 | "target": "es5",
8 | "loose": false,
9 | "minify": {
10 | "compress": false,
11 | "mangle": false
12 | }
13 | },
14 | "module": {
15 | "type": "commonjs"
16 | },
17 | "minify": false,
18 | "isModule": true
19 | }
20 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": ["termcolors"],
3 | "solidity.formatter": "none",
4 | "editor.formatOnSave": true
5 | }
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Oleg Kirillovich
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Node.js + Typescript + Mongoose REST API Template 🚀
2 |

3 |
4 | [](https://github.com/olegkron/node-ts-api-template/stargazers) [](https://github.com/olegkron/node-ts-api-template/network/members) [](https://github.com/olegkron/node-ts-api-template/graphs/contributors) [](https://github.com/olegkron/node-ts-api-template/issues) [](https://github.com/olegkron/node-ts-api-template/blob/main/LICENSE)
5 |
6 |
7 | Quick and easy setup for creating a REST API using Node.js, Express, TypeScript and Mongoose.
8 |
9 | ## 🌟 Features
10 |
11 | - ⚡ SWC for blazing-fast builds compared to TSC
12 | - 🔒 JWT tokens for user authentication and routes protection
13 | - 📚 Ready-to-go user model, controller, sign up, and sign in routes
14 | - ⚡ Optional websockets built with Socket.io
15 | - 🖼️ Image uploads with Multer
16 | - 🔧 Environment variables management with dotenv
17 | - 💡 Error handling
18 | - 📝 Asynchronous logging with Pino
19 | - ☁️ Ready-to-go access to AWS Parameter Store
20 |
21 | ## 🚀 Getting Started
22 |
23 | 1. Clone the repository: `git clone https://github.com/olegkron/node-ts-api-template.git`
24 | 2. Install dependencies: `npm install`
25 | 3. Create a .env file with your configurations.
26 | 4. Start the development server with `npm start`.
27 | 5. The API will be running on the port specified in the .env file
28 |
29 | ## 📚 Usage
30 |
31 | The template includes a basic user model and routes for sign up and sign in. You can easily add more models and routes as needed.
32 |
33 | ### Authentication
34 |
35 | All routes are protected by default and require a valid JWT token to be included in the `Authorization` header of the request.
36 |
37 | ### Websockets
38 |
39 | The template includes an optional implementation of websockets using Socket.io.
40 |
41 | ## 🛠️ Built With
42 |
43 | - [Node.js](https://nodejs.org/)
44 | - [Express](https://expressjs.com/)
45 | - [Typescript](https://www.typescriptlang.org/)
46 | - [Mongoose](https://mongoosejs.com/)
47 | - [JWT](https://jwt.io/)
48 | - [Multer](https://www.npmjs.com/package/multer)
49 | - [dotenv](https://www.npmjs.com/package/dotenv)
50 | - [Pino](https://getpino.io/)
51 | - [Socket.io](https://socket.io/)
52 |
53 | ## 📝 To-do's
54 |
55 | - Nodemailer for easy email sending
56 | - Twilio for SMS verification
57 | - Rate limiting
58 | - Password reset functionality
59 | - Support for different database types (PostgreSQL, MySQL)
60 | - Caching (Redis)
61 | - Password hashing with Argon
62 |
63 | ## 🙌 Contributing
64 |
65 | If you have any suggestions for improvements or find any bugs, feel free to open a pull request or an issue.
66 |
67 | ## 👥 Authors
68 |
69 | - **Oleg Kron** - [olegkron](https://github.com/olegkron)
70 |
71 | ## 📄 License
72 |
73 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/olegkron/node-ts-api-template/blob/master/LICENSE) file for details.
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "node-typescript-api",
3 | "version": "1.0.0",
4 | "engines": {
5 | "node": ">21.5.0"
6 | },
7 | "description": "",
8 | "main": "index.js",
9 | "scripts": {
10 | "start": "swc src -d dist && node --env-file=.env --env-file=.development.env dist/index.js",
11 | "start:dev": "swc src -d dist && node --env-file=.env --env-file=.development.env dist/index.js",
12 | "start:prod": "node --env-file=.env --env-file=.production.env dist/index.js",
13 | "dev": "concurrently \"swc src -d dist -wq\" \"node dist/index.js\"",
14 | "build": "swc src -d dist",
15 | "test": "npx vitest run",
16 | "lint": "eslint --ext .ts src/",
17 | "lint:fix": "eslint --ext .ts src/ --fix",
18 | "format": "prettier --write \"src/**/*.ts\""
19 | },
20 | "keywords": [],
21 | "author": "",
22 | "license": "ISC",
23 | "devDependencies": {
24 | "@swc/cli": "^0.1.63",
25 | "@swc/core": "^1.3.104",
26 | "@types/express": "^4.17.21",
27 | "@types/jest": "^29.5.11",
28 | "@types/multer": "^1.4.11",
29 | "@types/supertest": "^2.0.16",
30 | "@typescript-eslint/eslint-plugin": "^6.19.0",
31 | "@typescript-eslint/parser": "^6.19.0",
32 | "chokidar": "^3.5.3",
33 | "concurrently": "^8.2.2",
34 | "eslint": "^8.36.0",
35 | "eslint-config-prettier": "^9.1.0",
36 | "eslint-config-standard-with-typescript": "^43.0.0",
37 | "eslint-plugin-import": "^2.29.1",
38 | "eslint-plugin-n": "^16.6.2",
39 | "eslint-plugin-prettier": "^5.1.3",
40 | "eslint-plugin-promise": "^6.1.1",
41 | "jest": "^29.7.0",
42 | "mockingoose": "^2.16.2",
43 | "nodemon": "^2.0.22",
44 | "prettier": "^2.8.8",
45 | "supertest": "^6.3.4",
46 | "ts-jest": "^29.1.1",
47 | "typescript": "^4.9.5",
48 | "vitest": "^0.34.6"
49 | },
50 | "dependencies": {
51 | "apicache": "^1.6.3",
52 | "axios": "^1.5.1",
53 | "bcrypt": "^5.1.1",
54 | "body-parser": "^1.20.2",
55 | "compression": "^1.7.4",
56 | "dayjs": "^1.11.10",
57 | "express": "^4.18.2",
58 | "jsonwebtoken": "^9.0.2",
59 | "mongoose": "^6.12.5",
60 | "morgan": "^1.10.0",
61 | "multer": "1.4.5-lts.1",
62 | "sharp": "^0.31.3",
63 | "socket.io": "^4.7.4"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/setup/clone.sh:
--------------------------------------------------------------------------------
1 | function clone_repo() {
2 | # Check that the config file exists
3 | local config_file="config.txt"
4 | if [[ ! -f $config_file ]]; then
5 | echo "Config file not found!"
6 | exit 1
7 | fi
8 |
9 | # Read the access token and repository URL from the config file
10 | local access_token=$(grep "^ACCESS_TOKEN=" "$config_file" | cut -d "=" -f 2)
11 | local repo_address=$(grep "^REPO_ADDRESS=" "$config_file" | cut -d "=" -f 2)
12 |
13 | # Modify the repository URL to include the access token
14 | repo_address=${repo_address/https:\/\//}
15 | repo_address="https://$access_token@$repo_address"
16 |
17 | # Clone the repository using the modified URL
18 | git clone "$repo_address" || { echo "Failed to clone repository"; exit 1; }
19 | }
20 |
--------------------------------------------------------------------------------
/setup/config.txt:
--------------------------------------------------------------------------------
1 | ACCESS_TOKEN=
2 | REPO_ADDRESS=https://github.com//.git
3 |
--------------------------------------------------------------------------------
/setup/mongo.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | MONGODB_VERSION="6.0"
4 |
5 | function install_mongodb() {
6 | echo "Installing MongoDB $MONGODB_VERSION..."
7 | wget -qO - "https://www.mongodb.org/static/pgp/server-$MONGODB_VERSION.asc" | sudo apt-key add - || { echo "Error adding MongoDB repository key"; exit 1; }
8 | echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu $(lsb_release -cs)/mongodb-org/$MONGODB_VERSION multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-$MONGODB_VERSION.list
9 | sudo apt install -y mongodb-org
10 | }
11 |
12 | function enable_mongodb_service() {
13 | echo "Enabling and starting MongoDB service..."
14 | sudo systemctl enable mongod || { echo "Error enabling MongoDB service"; exit 1; }
15 | sudo systemctl start mongod || { echo "Error starting MongoDB service"; exit 1; }
16 | }
17 |
--------------------------------------------------------------------------------
/setup/nginx.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | function install_nginx() {
6 | echo "Installing NGINX..."
7 | sudo apt install -y nginx || { echo "Error installing NGINX"; exit 1; }
8 | }
9 |
10 | function setup_nginx() {
11 | echo "Setting up NGINX configuration..."
12 | read -p "Enter your domain: " DOMAIN
13 |
14 | # Create a backup of the existing nginx config
15 | sudo cp /etc/nginx/sites-available/default{,.bak}
16 |
17 | SERVER_CONFIG=$(cat < /dev/null
38 | sudo nginx -t && sudo systemctl reload nginx
39 | }
40 |
41 | function setup_nginx_ssl() {
42 | echo "Setting up SSL for NGINX..."
43 | sudo add-apt-repository ppa:certbot/certbot
44 | sudo apt-get update
45 | sudo apt-get install python-certbot-nginx
46 | sudo certbot --nginx -d $DOMAIN -d www.$DOMAIN
47 | sudo certbot renew --dry-run
48 | }
49 | install_nginx
50 | setup_nginx
51 | restart_nginx
52 | nginx_ssl_setup
53 |
54 |
55 | function restart_nginx() {
56 | echo "Checking NGINX config..."
57 | sudo nginx -t
58 |
59 | echo "Restarting NGINX..."
60 | if ! command -v nginx &> /dev/null; then
61 | echo "NGINX is not installed!"
62 | exit 1
63 | fi
64 | sudo systemctl restart nginx --if-available
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/setup/pm2.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | APP_PATH=$(pwd)
6 | CONFIG_FILE="pm2.config.js"
7 |
8 |
9 | function install_pm2() {
10 | echo "Installing PM2..."
11 | npm install -g pm2 || { echo "Error installing PM2"; exit 1; }
12 | }
13 |
14 | function create_pm2_config() {
15 | echo "Creating PM2 config file..."
16 | cat > $CONFIG_FILE < {
10 | data: T
11 | error?: string
12 | status: number
13 | headers: any
14 | ok: boolean
15 | }
16 |
17 | async function get (url: string, axiosConfig?: AxiosRequestConfig): Promise> {
18 | try {
19 | const response: AxiosResponse = await instance.get(url, axiosConfig)
20 | return {
21 | data: response.data,
22 | status: response.status,
23 | headers: response.headers,
24 | ok: response.status >= 200 && response.status < 300
25 | }
26 | } catch (error) {
27 | const { response } = error
28 | const ok = response?.status ? response.status >= 200 && response.status < 300 : false
29 | return {
30 | data: null,
31 | error: response?.data?.error || "Couldn't reach server",
32 | status: response?.status || 500,
33 | headers: response?.headers,
34 | ok
35 | }
36 | }
37 | }
38 |
39 | async function post (url: string, data?: any, axiosConfig?: AxiosRequestConfig): Promise> {
40 | try {
41 | const response: AxiosResponse = await instance.post(url, data, axiosConfig)
42 | return {
43 | data: response.data,
44 | status: response.status,
45 | headers: response.headers,
46 | ok: response.status >= 200 && response.status < 300
47 | }
48 | } catch (error) {
49 | const { response } = error
50 | const ok = response?.status ? response.status >= 200 && response.status < 300 : false
51 | return {
52 | data: null,
53 | error: response?.data?.error || "Couldn't reach server",
54 | status: response?.status || 500,
55 | headers: response?.headers,
56 | ok
57 | }
58 | }
59 | }
60 |
61 | const apiClient = {
62 | get,
63 | post
64 | }
65 |
66 | export default apiClient
67 |
--------------------------------------------------------------------------------
/src/constants/config.ts:
--------------------------------------------------------------------------------
1 | export enum Env {
2 | production = 'production',
3 | development = 'development',
4 | test = 'test',
5 | }
6 | export const initConfig = app => {
7 | switch (config.env) {
8 | case Env.production:
9 | console.log('Production environment')
10 | break
11 | case Env.development:
12 | console.log('Development environment')
13 | break
14 | case Env.test:
15 | console.log('Test environment')
16 | break
17 | default:
18 | throw new Error(`Unknown environment: ${config.env}`)
19 | }
20 | }
21 |
22 | export const config = {
23 | env: process.env.NODE_ENV,
24 | port: process.env.PORT,
25 | baseUrl: process.env.BASE_URL,
26 | logLevel: process.env.LOG_LEVEL,
27 | mongoDB: {
28 | host: process.env.MONGODB_HOST,
29 | port: process.env.MONGODB_PORT,
30 | dbName: process.env.MONGODB_DB_NAME,
31 | username: process.env.MONGODB_USERNAME,
32 | password: process.env.MONGODB_PASSWORD
33 | },
34 | secrets: {
35 | jwt: process.env.JWT_SECRET,
36 | jwtExp: 31557600 // 1 year
37 | },
38 | saltWorkFactor: 10
39 | }
40 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | import { config } from './config'
2 | import { termcolors } from './termcolors'
3 |
4 | export { termcolors, config }
5 |
--------------------------------------------------------------------------------
/src/constants/termcolors.ts:
--------------------------------------------------------------------------------
1 | interface TermColors {
2 | reset: string
3 | bright: string
4 | dim: string
5 | underscore: string
6 | blink: string
7 | reverse: string
8 | hidden: string
9 | fgBlack: string
10 | fgRed: string
11 | fgGreen: string
12 | fgYellow: string
13 | fgBlue: string
14 | fgMagenta: string
15 | fgCyan: string
16 | fgWhite: string
17 | bgBlack: string
18 | bgRed: string
19 | bgGreen: string
20 | bgYellow: string
21 | bgBlue: string
22 | bgMagenta: string
23 | bgCyan: string
24 | bgWhite: string
25 | }
26 |
27 | export const termcolors: TermColors = {
28 | reset: '\x1b[0m',
29 | bright: '\x1b[1m',
30 | dim: '\x1b[2m',
31 | underscore: '\x1b[4m',
32 | blink: '\x1b[5m',
33 | reverse: '\x1b[7m',
34 | hidden: '\x1b[8m',
35 | fgBlack: '\x1b[30m',
36 | fgRed: '\x1b[31m',
37 | fgGreen: '\x1b[32m',
38 | fgYellow: '\x1b[33m',
39 | fgBlue: '\x1b[34m',
40 | fgMagenta: '\x1b[35m',
41 | fgCyan: '\x1b[36m',
42 | fgWhite: '\x1b[37m',
43 | bgBlack: '\x1b[40m',
44 | bgRed: '\x1b[41m',
45 | bgGreen: '\x1b[42m',
46 | bgYellow: '\x1b[43m',
47 | bgBlue: '\x1b[44m',
48 | bgMagenta: '\x1b[45m',
49 | bgCyan: '\x1b[46m',
50 | bgWhite: '\x1b[47m'
51 | }
52 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { server } from './server'
2 |
3 | server()
4 |
--------------------------------------------------------------------------------
/src/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import { type NextFunction, type Response } from 'express'
2 | import jwt from 'jsonwebtoken'
3 |
4 | import { config } from '../constants/config'
5 | import { User } from '../resources/user/model'
6 | import apiError from '../utils/apiError'
7 | import { type Req } from '../utils/types'
8 |
9 | export const validateEmail = (email: string): boolean => {
10 | const regex = /\S+@\S+\.\S+/
11 | return regex.test(email)
12 | }
13 |
14 | export const validatePhone = (phone: string): boolean => {
15 | const regex = /^[+]?\d{6,14}/
16 | return regex.test(phone)
17 | }
18 |
19 | export const newToken = (user): string => jwt.sign({ id: user._id }, config.secrets.jwt, { expiresIn: config.secrets.jwtExp })
20 |
21 | export const verifyToken = async (token: string): Promise =>
22 | await new Promise((resolve, reject) => {
23 | jwt.verify(token, config.secrets.jwt, (err: any, payload: any) => {
24 | if (err) {
25 | reject(err)
26 | return
27 | }
28 | resolve(payload)
29 | })
30 | })
31 |
32 | export const signup = async (req: Req, res: Response, next: NextFunction): Promise => {
33 | try {
34 | const { username, password, first_name, last_name } = req.body
35 | if (!username || !password || !first_name || !last_name) {
36 | next(apiError.badRequest('Not all required values were provided', 'signup'))
37 | return
38 | }
39 | const existingUser = await User.findOne({ username }).lean()
40 | if (existingUser) {
41 | next(apiError.badRequest('User already exists', 'signup'))
42 | return
43 | }
44 |
45 | const user = await User.create({ username, password, first_name, last_name })
46 | const token = newToken(user)
47 | const [userData] = await User.aggregate([{ $match: { _id: user._id } }, { $project: { password: 0 } }])
48 | return res.status(201).send({ token, success: true, data: userData })
49 | } catch (error) {
50 | next(apiError.internal(error, 'signup'))
51 | }
52 | }
53 |
54 | export const signin = async (req: Req, res: Response, next: NextFunction): Promise => {
55 | try {
56 | const { username, password } = req.body
57 | if (!username || !password) {
58 | next(apiError.badRequest('Username & Password must be provided', 'signin'))
59 | return
60 | }
61 | const user = await User.findOne({ username }).select('username password').exec()
62 | if (!user) {
63 | next(apiError.badRequest('Username & Password mismatch', 'signin'))
64 | return
65 | }
66 | const match = await user.checkPassword(password)
67 | if (!match) {
68 | next(apiError.badRequest('Username & Password mismatch', 'signin'))
69 | return
70 | }
71 |
72 | const token = newToken(user)
73 |
74 | const [userInfo] = await User.aggregate([{ $match: { _id: user._id } }, { $project: { password: 0 } }])
75 |
76 | if (userInfo.is_banned) {
77 | next(apiError.badRequest("We can't log you in at the moment.", 'signin'))
78 | return
79 | }
80 | return res.status(201).send({ token, data: userInfo })
81 | } catch (error) {
82 | next(apiError.internal(error, 'signin'))
83 | }
84 | }
85 |
86 | export const protect = async (req: Req, res: Response, next: NextFunction): Promise => {
87 | const bearer = req.headers.authorization
88 | if (!bearer || !bearer.startsWith('Bearer ')) {
89 | return res.status(401).end()
90 | }
91 | const token = bearer.split('Bearer ')[1].trim()
92 | let payload
93 | try {
94 | payload = await verifyToken(token)
95 | } catch (error) {
96 | console.error(`[protect] ${error}`)
97 | return res.status(401).end()
98 | }
99 |
100 | const user = await User.findById(payload.id).select('-password').lean().exec()
101 | if (!user) {
102 | return res.status(401).end()
103 | } else if (user.is_banned) {
104 | next(apiError.badRequest('Access restricted', 'protect'))
105 | return
106 | }
107 | req.requester = user
108 | next()
109 | }
110 |
111 | export const ifLoginExists = async (req: Req, res: Response, next: NextFunction) => {
112 | try {
113 | const { login, type } = req.body
114 | if (!login || !type) {
115 | next(apiError.badRequest('Login & type must be provided', 'ifLoginExists'))
116 | return
117 | }
118 | if (typeof login !== 'string' || typeof type !== 'string') {
119 | next(apiError.badRequest('Login & type must be a string', 'ifLoginExists'))
120 | return
121 | }
122 | if (type !== 'phone' && type !== 'email') {
123 | next(apiError.badRequest('Type must be either phone or email', 'ifLoginExists'))
124 | return
125 | }
126 | if (type === 'phone' && !validatePhone(login)) {
127 | next(apiError.badRequest('Incorrect phone number format', 'ifLoginExists'))
128 | return
129 | }
130 | if (type === 'email' && !validateEmail(login)) {
131 | next(apiError.badRequest('Incorrect email format', 'ifLoginExists'))
132 | return
133 | }
134 |
135 | const user = await User.findOne({ [type === 'phone' ? 'login_primary' : 'login_secondary']: [login] }).lean()
136 | if (user) {
137 | return res.status(200).send({ exists: true })
138 | } else {
139 | res.status(200).send({ exists: false })
140 | }
141 | } catch (error) {
142 | next(apiError.internal(error, 'ifLoginExists'))
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/src/middleware/compression.ts:
--------------------------------------------------------------------------------
1 | import compression from 'compression'
2 | import { type Request, type Response } from 'express'
3 |
4 | export const compressionMiddleware = () => {
5 | return compression({
6 | filter: (req: Request, res: Response): boolean => {
7 | if (req.headers['x-no-compression']) {
8 | // don't compress responses with this header
9 | return false
10 | }
11 | // fallback to standard filter function
12 | return compression.filter(req, res)
13 | }
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/src/middleware/limiter.ts:
--------------------------------------------------------------------------------
1 | import { type Request, type Response } from 'express'
2 | import rateLimit from 'express-rate-limit'
3 | import Redis from 'ioredis-mock'
4 | import RedisStore from 'rate-limit-redis'
5 | import { config } from '../constants/config'
6 |
7 | // Initialize Redis client
8 | const redisClient = new Redis({
9 | host: config.redisHost,
10 | port: parseInt(config.redisPort)
11 | })
12 |
13 | redisClient.on('error', err => {
14 | console.error(`Error connecting to Redis: ${err.message}`)
15 | })
16 |
17 | redisClient.on('connect', () => {
18 | console.log(`Connected to Redis at ${config.redisHost}:${config.redisPort}`)
19 | })
20 |
21 | // Rate limiting middleware
22 | export const limiter = rateLimit({
23 | windowMs: 60 * 1000, // 1 minute
24 | max: 100, // limit each IP to 100 requests per windowMs
25 | keyGenerator: function (req: Request) {
26 | // use the user's IP address as the key
27 | return req.ip
28 | },
29 | store: new RedisStore({
30 | client: redisClient,
31 | retryStrategy: times => {
32 | if (times <= 3) {
33 | return 200 // wait 200ms before trying again
34 | }
35 | }
36 | } as any),
37 | skip: function (req: Request) {
38 | // Skip rate limiting for requests coming from whitelisted IPs
39 | const whitelist = process.env.IP_WHITELIST?.split(',')
40 | return whitelist?.includes(req.ip)
41 | },
42 | onLimitReached: function (req: Request, res: Response, options: any) {
43 | // handle when a user exceeds the rate limit
44 | console.log(`Rate limit exceeded for IP ${req.ip}`)
45 | res.status(429).send('Too many requests, please try again later.')
46 | }
47 | })
48 |
--------------------------------------------------------------------------------
/src/middleware/multer.ts:
--------------------------------------------------------------------------------
1 | import { type Request } from 'express'
2 | import multer, { type StorageEngine } from 'multer'
3 |
4 | export const storage: StorageEngine = multer.memoryStorage()
5 |
6 | export const fileFilter = (req: Request, file: Express.Multer.File, cb: (error: Error | null, acceptFile: boolean) => void) => {
7 | if (file.mimetype === 'image/jpeg' || file.mimetype === 'image/jpg' || file.mimetype === 'image/png') {
8 | cb(null, true)
9 | } else {
10 | cb(new Error('Invalid file type: ' + file.mimetype), false)
11 | }
12 | }
13 |
14 | export const upload = multer({
15 | storage,
16 | limits: {
17 | fileSize: 1024 * 1024 * 5
18 | },
19 | fileFilter
20 | })
21 |
--------------------------------------------------------------------------------
/src/resources/user/controller.ts:
--------------------------------------------------------------------------------
1 | import { type NextFunction, type Response } from 'express'
2 | import sharp from 'sharp'
3 | import { apiError } from '../../utils'
4 | import { type FormDataReq, type Req } from '../../utils/types'
5 |
6 | import { User } from './model'
7 |
8 | export const viewProfile = async (req: Req, res: Response, next: NextFunction) => {
9 | try {
10 | const { user_target } = req.body
11 | if (!user_target) {
12 | next(apiError.badRequest('No target specified', 'viewProfile'))
13 | return
14 | }
15 | const user = await User.findOne({ _id: user_target })
16 | if (!user) {
17 | next(apiError.notFound('User not found', 'viewProfile'))
18 | return
19 | }
20 | res.status(200).json({
21 | success: true,
22 | data: user
23 | })
24 | } catch (error) {
25 | next(apiError.internal(error, 'viewProfile'))
26 | }
27 | }
28 |
29 | export const imageUpload = async (req: FormDataReq, res: Response, next: NextFunction) => {
30 | try {
31 | req.body = JSON.parse(req.body.data)
32 | if (!req.body.type || !req.file) {
33 | next(apiError.badRequest('Either image or type not specified', 'imageUpload'))
34 | return
35 | }
36 | if (req.body.type !== 'event' && req.body.type !== 'user' && req.body.type !== 'group') {
37 | next(apiError.badRequest('Wrong image type', 'imageUpload'))
38 | return
39 | }
40 | const path = `/uploads/${req.body.type}s/`
41 | const name = `${req.requester._id}${new Date().getTime()}`
42 | const extension = '.jpg'
43 | await sharp(req.file.buffer).resize(1920).jpeg({ quality: 50 }).toFile(`.${path}${name}${extension}`)
44 |
45 | return res.status(200).send({ success: true, imagePath: path + name + extension })
46 | } catch (error) {
47 | next(apiError.internal(error, 'imageUpload'))
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/resources/user/model.ts:
--------------------------------------------------------------------------------
1 | import bcrypt from 'bcrypt'
2 | import mongoose from 'mongoose'
3 | import { config } from '../../constants/config'
4 | import { apiError } from '../../utils'
5 |
6 | const Schema = mongoose.Schema
7 |
8 | export interface UserType extends mongoose.Document {
9 | _id: mongoose.Types.ObjectId
10 | first_name: string
11 | last_name: string
12 | username: string
13 | password: string
14 | is_admin: boolean
15 | is_banned: boolean
16 | plan: string
17 | plan_expires_at: Date
18 | is_email_verified: boolean
19 | checkPassword: (password: string) => Promise
20 | getUpdate: () => any
21 | createdAt: Date
22 | updatedAt: Date
23 | }
24 |
25 | const userSchema: mongoose.Schema = new Schema(
26 | {
27 | first_name: {
28 | type: String,
29 | required: true,
30 | trim: true,
31 | minlength: 2,
32 | maxlength: 50
33 | },
34 | last_name: {
35 | type: String,
36 | required: true,
37 | trim: true,
38 | minlength: 2,
39 | maxlength: 50
40 | },
41 | username: {
42 | type: String,
43 | required: true,
44 | trim: true
45 | },
46 | password: {
47 | type: String,
48 | required: true
49 | },
50 | is_admin: {
51 | type: Boolean,
52 | default: false
53 | },
54 | is_banned: {
55 | type: Boolean,
56 | default: false
57 | },
58 | plan: {
59 | type: String,
60 | enum: ['free', 'pro'],
61 | default: 'free'
62 | },
63 | plan_expires_at: {
64 | type: Date,
65 | default: Date.now
66 | },
67 | is_email_verified: {
68 | type: Boolean,
69 | default: false
70 | }
71 | },
72 | { timestamps: true }
73 | )
74 |
75 | userSchema.pre('save', async function () {
76 | try {
77 | if (!this.isModified('password')) return
78 | const salt = await bcrypt.genSalt(config.saltWorkFactor)
79 | this.password = await bcrypt.hash(this.password, salt)
80 | } catch (error) {
81 | throw apiError.internal(error, 'pre save hook')
82 | }
83 | })
84 |
85 | userSchema.pre('findOneAndUpdate', async function () {
86 | try {
87 | if (!this.getUpdate().password) return
88 | const salt = await bcrypt.genSalt(config.saltWorkFactor)
89 | this.getUpdate().password = await bcrypt.hash(this.getUpdate().password, salt)
90 | } catch (error) {
91 | throw apiError.internal(error, 'pre findOneAndUpdate hook')
92 | }
93 | })
94 |
95 | userSchema.methods.checkPassword = async function (password: string): Promise {
96 | try {
97 | const same = await bcrypt.compare(password, this.password)
98 | return same
99 | } catch (error) {
100 | throw apiError.internal(error, 'checkPassword')
101 | }
102 | }
103 | export const User = mongoose.model('User', userSchema)
104 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import * as http from 'http'
3 | import apicache from 'apicache'
4 | import morgan from 'morgan'
5 | import compression from 'compression'
6 | import { config } from './constants'
7 | import { apiError, apiErrorHandler, connect } from './utils'
8 | import { killProcessOnPort } from './utils/killProcessOnPort'
9 | import { socketIO } from './utils/socketio'
10 |
11 | export const app = express()
12 | app.use(compression())
13 | app.use(apicache.middleware('5 minutes'))
14 | app.use(express.json())
15 | app.disable('etag')
16 | app.use(morgan('dev'))
17 |
18 | // app.use('/uploads', express.static('uploads'))
19 | // app.use('/api', protect, router)
20 | // app.post('/signup', use(signup))
21 | // app.post('/signin', use(signin))
22 | // app.use('/static', express.static('static'))
23 |
24 | app.use(({ next }) => {
25 | next(new apiError(404, 'Not found', 'server'))
26 | })
27 | app.use(apiErrorHandler)
28 |
29 | export const server = () => {
30 | try {
31 | killProcessOnPort(config.port, () => {
32 | connect()
33 | const httpServer = http.createServer(app)
34 | httpServer.listen(config.port, () => {
35 | console.log(`Server listening on port ${config.port}`)
36 | })
37 | socketIO(httpServer)
38 | })
39 | } catch (error) {
40 | console.error('[server] ', error)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/utils/apiError.ts:
--------------------------------------------------------------------------------
1 | class apiError {
2 | code: number
3 | message: string
4 | from: string
5 | params: Object
6 |
7 | constructor (code: number, message: string, from: string, params?: Object) {
8 | this.code = code
9 | this.message = message
10 | this.from = from
11 | this.params = params
12 | }
13 |
14 | static forbidden (msg: string, from: string, params?: Object) {
15 | return new apiError(403, msg, from, params)
16 | }
17 |
18 | static unauthorized (msg: string, from: string, params?: Object) {
19 | return new apiError(401, msg, from, params)
20 | }
21 |
22 | static badRequest (msg: string, from: string, params?: Object) {
23 | return new apiError(400, msg, from, params)
24 | }
25 |
26 | static notFound (msg: string, from: string, params?: Object) {
27 | return new apiError(404, msg, from, params)
28 | }
29 |
30 | static internal (msg: string, from: string) {
31 | return new apiError(500, msg, from)
32 | }
33 | }
34 |
35 | export default apiError
36 |
--------------------------------------------------------------------------------
/src/utils/apiErrorHandler.ts:
--------------------------------------------------------------------------------
1 | import { type NextFunction, type Request, type Response } from 'express'
2 | import { termcolors } from '../constants/termcolors'
3 | import apiError from './apiError'
4 |
5 | interface ApiError {
6 | from: string
7 | code: number
8 | message: string
9 | params?: object
10 | }
11 |
12 | function apiErrorHandler (err: ApiError, req: Request, res: Response, next: NextFunction) {
13 | const from = err.from ? `${termcolors.fgRed}[${err.from}] ${err.code}: ${termcolors.reset}` : ''
14 | if (err instanceof apiError) {
15 | console.error(from + err.message)
16 | if (err.code === 500) res.status(500).json({ error: 'Something went wrong.' })
17 | else res.status(err.code).json({ error: err.message, ...err.params })
18 | return
19 | }
20 | console.error(from + err)
21 | res.status(500).json({ error: 'Something went horribly wrong.' })
22 | }
23 |
24 | export default apiErrorHandler
25 |
--------------------------------------------------------------------------------
/src/utils/db.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { type ConnectOptions } from 'mongoose'
2 |
3 | import { config, termcolors } from '../constants'
4 |
5 | const connect = async (): Promise => {
6 | const dbUrl = `mongodb://${config.mongoDB.host}:${config.mongoDB.port}`
7 | return await mongoose
8 | .connect(dbUrl, {
9 | useNewUrlParser: true,
10 | useUnifiedTopology: true,
11 | dbName: config.mongoDB.dbName
12 | } as ConnectOptions)
13 | .then(() => {
14 | console.log(termcolors.fgGreen + 'Connected to database' + termcolors.reset)
15 | return mongoose
16 | })
17 | .catch(err => {
18 | console.error("Couldn't connect to database. " + err)
19 | process.exit(1)
20 | })
21 | }
22 | mongoose.set('strictQuery', false)
23 |
24 | export default connect
25 |
--------------------------------------------------------------------------------
/src/utils/email.ts:
--------------------------------------------------------------------------------
1 | import nodemailer from 'nodemailer'
2 | import { config } from '../constants'
3 |
4 | export const sendEmail = async (from: string, to: string, subject: string, html: string) => {
5 | const transporter = nodemailer.createTransport({
6 | host: config.email.host,
7 | port: config.email.port,
8 | secure: false, // set to true for SSL
9 | auth: {
10 | user: config.email.address,
11 | pass: config.email.password
12 | }
13 | })
14 |
15 | const mailOptions = {
16 | from,
17 | to,
18 | subject,
19 | html
20 | }
21 |
22 | await transporter.sendMail(mailOptions)
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/formatting.ts:
--------------------------------------------------------------------------------
1 | import relativeTime from 'dayjs/plugin/relativeTime'
2 | import updateLocale from 'dayjs/plugin/updateLocale'
3 | import dayjs from 'dayjs'
4 |
5 | dayjs.extend(relativeTime)
6 | dayjs.extend(updateLocale)
7 | dayjs.updateLocale('en', {
8 | relativeTime: {
9 | future: 'in %s',
10 | past: '%s',
11 | s: 'now',
12 | m: 'a min',
13 | mm: '%dm',
14 | h: '1h',
15 | hh: '%dh',
16 | d: 'a day',
17 | dd: '%dd',
18 | M: 'a month',
19 | MM: '%dm',
20 | y: 'a year',
21 | yy: '%dy'
22 | }
23 | })
24 | export default dayjs
25 |
26 | // Date and time formatting
27 | const formatDate = (date: string | Date, format = 'YYYY-MM-DD'): string => dayjs(date).format(format)
28 |
29 | export const formatDateTime = (date: string | Date, format = 'YYYY-MM-DD HH:mm'): string => dayjs(date).format(format)
30 |
31 | export const formatTime = (date: string | Date, format = 'HH:mm'): string => dayjs(date).format(format)
32 |
33 | export const fromNow = (date: string | Date): string => dayjs(date).fromNow()
34 |
35 | export const unixtimeFromNow = (unixtime: number): string => dayjs.unix(unixtime).fromNow()
36 |
37 | export const unixTimeFormat = (unixtime: number, format = 'YYYY-MM-DD HH:mm'): string => dayjs.unix(unixtime).format(format)
38 |
39 | // Number and currency formatting
40 | export const formatNumber = (num: number, decimalPlaces = 2): string => num.toFixed(decimalPlaces)
41 |
42 | export const formatCurrency = (amount: number, currency = 'USD'): string =>
43 | new Intl.NumberFormat('en-US', {
44 | style: 'currency',
45 | currency
46 | }).format(amount)
47 |
48 | // String formatting
49 | export const toTitleCase = (str: string): string => str.replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase())
50 |
51 | export const toCamelCase = (str: string): string => str.replace(/([-_][a-z])/g, group => group.toUpperCase().replace('-', '').replace('_', ''))
52 |
53 | export const toSnakeCase = (str: string): string => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`).replace(/^-/, '')
54 |
55 | // URL formatting
56 | export const slugify = (str: string): string =>
57 | str
58 | .toLowerCase()
59 | .replace(/ /g, '-')
60 | .replace(/[^\w-]+/g, '')
61 |
62 | export const getHostname = (url: string): string => new URL(url).hostname
63 |
64 | // turns 'https://cointelegraph.com/abcd' into cointelegraph
65 | export const getDomain = (url: string): string => getHostname(url).replace('www.', '').split('.')[0]
66 | // String manipulation
67 |
68 | export const truncate = (str: string, length = 100, ending = '...'): string => (str.length > length ? str.substring(0, length - ending.length) + ending : str)
69 |
70 | // trucate wallet address to 6 characters on the end
71 | export const truncateWallet = (str: string): string => `${str.slice(0, 6)}...${str.slice(-4)}`
72 |
73 | export const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1)
74 |
75 | export const removeWhitespace = (str: string): string => str.replace(/\s/g, '')
76 |
77 | export const removeNonNumeric = (str: string): string => str.replace(/\D/g, '')
78 |
79 | export const removeNonAlphaNumeric = (str: string): string => str.replace(/\W/g, '')
80 |
81 | export const addingDecimals = (number: number, decimals: number) => {
82 | while (number % 1 !== 0) {
83 | number *= 10
84 | decimals--
85 | }
86 |
87 | return number + '0'.repeat(decimals)
88 | }
89 |
90 | export const secondsConverter = (seconds: number): string => {
91 | if (seconds > 60) return `${Math.floor(seconds / 60)}m ${seconds % 60 ? `${(seconds % 60).toString()}s` : ''}`
92 | return `${seconds}s`
93 | }
94 |
95 | export const numberToFormatString = (number: number, decimals = 4, isTransformNeeded = false): string => {
96 | const result = parseFloat(number?.toFixed(decimals))
97 | if (isTransformNeeded && result === 0) return '< 0.01'
98 |
99 | return result?.toString()
100 | }
101 |
102 | export const roundNumberByDecimals = (number: number, decimals = 4): number => {
103 | const decimalPart = number.toString().split('.')[1]
104 | let count = 0
105 | if (decimalPart) {
106 | while (decimalPart[count] === '0') count++
107 | count++
108 | }
109 | const factor = Math.max(count, decimals)
110 | return parseFloat(number?.toFixed(factor))
111 | }
112 |
113 | export const addingTokenDecimals = (amount: number, decimals: number): string => {
114 | return numberToFormatString(amount / Math.pow(10, decimals), 4)
115 | }
116 |
117 | export const timestampToLocalTime = (timestamp: number): number => {
118 | const currentTime = new Date()
119 | const timeZoneOffsetInSeconds = currentTime.getTimezoneOffset() * 60
120 | return Number(timestamp) - timeZoneOffsetInSeconds
121 | }
122 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { type NextFunction } from 'express'
2 | import { ifLoginExists, protect, signin, signup } from '../middleware/auth'
3 | import apiError from './apiError'
4 | import apiErrorHandler from './apiErrorHandler'
5 | import connect from './db'
6 | import { terminate } from './process'
7 | import { router } from './router'
8 | import { type FormDataReq, type Req } from './types'
9 |
10 | const use = (fn: any) => async (req: Req, res: Response, next: NextFunction) => await Promise.resolve(fn(req, res, next)).catch(next)
11 |
12 | export { apiError, apiErrorHandler, protect, ifLoginExists, signin, signup, terminate, connect, router, type Req, type FormDataReq, use }
13 |
--------------------------------------------------------------------------------
/src/utils/killProcessOnPort.ts:
--------------------------------------------------------------------------------
1 | export function killProcessOnPort (port, callback) {
2 | const exec = require('child_process').exec
3 | exec(`lsof -t -i:${port}`, (err, stdout, stderr) => {
4 | if (stdout) {
5 | exec(`kill -9 ${stdout}`, callback)
6 | } else {
7 | callback() // Nothing to kill
8 | }
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/measured.ts:
--------------------------------------------------------------------------------
1 | // cmcTokensService()
2 | export async function measured (fn, label) {
3 | // console.log(`Starting ${label}`)
4 | const t0 = performance.now()
5 | await fn()
6 | const t1 = performance.now()
7 | console.log(`${label} took ${t1 - t0} ms`)
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/process.ts:
--------------------------------------------------------------------------------
1 | import type http from 'http'
2 |
3 | //! ISSUE: does not clear up the port after server is closed. (eg when ctrl+c is pressed)
4 | export function terminate (server, options = { coredump: false, timeout: 500 }) {
5 | const exit = code => {
6 | options.coredump ? process.abort() : process.exit(code)
7 | }
8 |
9 | const shutdown = () => {
10 | if (server.listening) {
11 | server.close(() => {
12 | console.log('Server closed')
13 | exit(0)
14 | })
15 | } else {
16 | exit(0)
17 | }
18 | }
19 |
20 | return (code, reason) => (err, promise) => {
21 | if (err && err instanceof Error) {
22 | console.error(err.message, err.stack)
23 | }
24 |
25 | // Attempt a graceful shutdown
26 | shutdown()
27 |
28 | setTimeout(() => {
29 | console.log('Forcing server termination')
30 | exit(1)
31 | }, options.timeout).unref()
32 | }
33 | }
34 |
35 | export const addProcessListeners = (server: http.Server) => {
36 | const exitHandler = terminate(server, { coredump: false, timeout: 500 })
37 | process.on('uncaughtException', () => exitHandler(1, 'Unexpected Error'))
38 | process.on('unhandledRejection', () => exitHandler(1, 'Unhandled Promise'))
39 | process.on('SIGTERM', () => exitHandler(0, 'SIGTERM'))
40 | process.on('SIGINT', () => exitHandler(0, 'SIGINT'))
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/router.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import { upload } from '../middleware/multer'
3 | import * as user from '../resources/user/controller'
4 |
5 | export const router = Router()
6 | const use = fn => async (req, res, next) => await Promise.resolve(fn(req, res, next)).catch(next)
7 |
8 | router.post('/view_user', use(user.viewProfile))
9 | router.post('/image_upload', upload.single('image'), use(user.imageUpload))
10 |
--------------------------------------------------------------------------------
/src/utils/socketio.ts:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken'
2 | import { type LeanDocument } from 'mongoose'
3 | import * as socketio from 'socket.io'
4 | import { config } from '../constants'
5 | import { User, type UserType } from '../resources/user/model'
6 |
7 | const getUser = async (id: any) => {
8 | const user = await User.findOne({ _id: id }).lean()
9 | // If no user found or banned user , return null
10 | return user && !user.is_banned ? user : null
11 | }
12 |
13 | export const socketEvents = (socket: socketio.Socket, user: LeanDocument) => {
14 | socket.handshake.query &&
15 | socket.handshake.query.token &&
16 | jwt.verify(socket.handshake.query.token.toString(), config.secrets.jwt, async (err: any, decoded: any) => {
17 | if (err) {
18 | console.error('[Socket.io]: Authentication error')
19 | return
20 | }
21 | try {
22 | user = await getUser(decoded.id)
23 | if (!user) {
24 | console.error('[Socket.io]: Authentication failed.')
25 | return
26 | }
27 | await User.findOneAndUpdate(
28 | { _id: user._id },
29 | {
30 | $set: {
31 | is_online: true,
32 | last_seen_online: Date.now()
33 | }
34 | },
35 | { lean: true }
36 | )
37 | } catch (error) {
38 | console.error(error.message)
39 | }
40 | })
41 |
42 | socket.on('disconnect', async () => {
43 | if (user) {
44 | try {
45 | await User.findOneAndUpdate({ _id: user._id }, { $set: { is_online: false } }, { lean: true })
46 | } catch (error) {
47 | console.error(error.message)
48 | }
49 | }
50 | })
51 | }
52 |
53 | export const socketIO = async (server: any) => {
54 | try {
55 | const io = new socketio.Server().attach(server)
56 | io.on('connection', async (socket: socketio.Socket) => {
57 | const user: LeanDocument | null = null
58 | // manage connection / disconnection events
59 | socketEvents(socket, user)
60 | })
61 | } catch (err) {
62 | console.error(err)
63 | throw new Error('[Socket.IO] could not be started.')
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/utils/stripeWebhooks.ts:
--------------------------------------------------------------------------------
1 | import { type NextFunction, type Response } from 'express'
2 | import Stripe from 'stripe'
3 | import { User } from '../resources/user/model'
4 | import { type Req } from './types'
5 | import apiError from './apiError'
6 |
7 | const SECRET_KEY = process.env.STRIPE_SECRET_KEY
8 | const stripeApi = new Stripe(SECRET_KEY, { apiVersion: '2022-11-15' })
9 | const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET
10 |
11 | export const stripeWebhooks = async (req: Req, res: Response, next: NextFunction) => {
12 | const sig = req.headers['stripe-signature']
13 | let event
14 | try {
15 | event = stripeApi.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET)
16 | } catch (err) {
17 | return res.status(400).send(`Webhook Error: ${err.message}`)
18 | }
19 |
20 | // Handle the event
21 | switch (event.type) {
22 | case 'charge.succeeded': {
23 | if (!event.data.object.metadata._id) {
24 | next(apiError.badRequest('No _id in metadata', 'stripeWebhooks'))
25 | return
26 | }
27 | await User.findOneAndUpdate({ _id: event.data.object.metadata._id }, { plan: 'paid' }).lean()
28 | // const email = event["data"]["object"]["receipt_email"]; // contains the email that will receive the receipt for the payment (users email usually)
29 | console.log(`PaymentIntent was successful for ${event.data.object.metadata}!`)
30 | break
31 | }
32 | default:
33 | // Unexpected event type
34 | return res.status(400).end()
35 | }
36 |
37 | // Return a 200 response to acknowledge receipt of the event
38 | res.json({ received: true })
39 | }
40 |
--------------------------------------------------------------------------------
/src/utils/terminate.ts:
--------------------------------------------------------------------------------
1 | export function terminate (server, options = { coredump: false, timeout: 500 }) {
2 | // Exit function
3 |
4 | const exit = code => {
5 | options.coredump ? process.abort() : process.exit(code)
6 | }
7 |
8 | return (code: number, reason: string) => (err: Error | null, promise: Promise | null) => {
9 | if (err && err instanceof Error) {
10 | console.error('trying to exit with logging')
11 | console.error(err.message, err.stack)
12 | }
13 |
14 | // Attempt a graceful shutdown
15 | server.close(exit)
16 | setTimeout(exit, options.timeout).unref()
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | import { type Request } from 'express'
2 | import { type LeanDocument } from 'mongoose'
3 | import { type UserType } from '../resources/user/model'
4 |
5 | export interface Req extends Request {
6 | requester: LeanDocument
7 | }
8 |
9 | export interface FormDataReq extends Request {
10 | requester: LeanDocument
11 | body: {
12 | data: string
13 | type: string
14 | }
15 | file: any
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/validation.ts:
--------------------------------------------------------------------------------
1 | // Email validation
2 | export const isValidEmail = (email: string): boolean => {
3 | const emailRegex = /^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/
4 | return emailRegex.test(email)
5 | }
6 |
7 | // Phone number validation (simplified, may not cover all cases)
8 | export const isValidPhoneNumber = (phoneNumber: string): boolean => {
9 | const phoneRegex = /^\+?[\d\s\-()]{7,}$/
10 | return phoneRegex.test(phoneNumber)
11 | }
12 |
13 | // Password validation (example: at least 8 characters, 1 uppercase letter, 1 lowercase letter, 1 number)
14 | export const isValidPassword = (password: string): boolean => {
15 | const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/
16 | return passwordRegex.test(password)
17 | }
18 |
19 | // Password strength level (0-4)
20 | export const passwordStrengthLevel = (password: string): number => {
21 | let strength = 0
22 | if (password.length >= 12) strength++
23 | if (/[a-z]/.test(password)) strength++
24 | if (/[A-Z]/.test(password)) strength++
25 | if (/[0-9]/.test(password)) strength++
26 | if (/[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/.test(password)) strength++
27 | return strength
28 | }
29 |
30 | // Non-empty string validation
31 | export const isNotEmpty = (str: string): boolean => str.length > 0
32 |
33 | // Numeric validation
34 | export const isNumeric = (num: string): boolean => !isNaN(parseFloat(num)) && isFinite(Number(num))
35 |
36 | export const isDigit = (num: string): boolean => /^\d+$/.test(num)
37 |
38 | // Float validation
39 | export const isFloat = (num: string): boolean => {
40 | const floatRegex = /^-?\d*(\.\d+)?$/
41 | return floatRegex.test(num)
42 | }
43 |
44 | export const isFloatInput = (num: string): boolean => {
45 | const floatRegex = /^(?!00)\d+(\.\d*)?$/
46 | return floatRegex.test(num)
47 | }
48 | // Non-numeric validation
49 | export const isNotNumeric = (str: string): boolean => !/\d/.test(str)
50 |
51 | // URL validation
52 | export const isValidURL = (url: string): boolean => {
53 | const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/
54 | return urlRegex.test(url)
55 | }
56 |
57 | // IP address validation
58 | export const isValidIPAddress = (ipAddress: string): boolean => {
59 | const ipAddressRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/
60 | return ipAddressRegex.test(ipAddress)
61 | }
62 |
63 | // Credit card validation
64 | export const isValidCreditCard = (creditCard: string): boolean => {
65 | const creditCardRegex = /^([0-9]{4}-){3}[0-9]{4}$/
66 | return creditCardRegex.test(creditCard)
67 | }
68 |
69 | // Hex color validation
70 | export const isValidHexColor = (hexColor: string): boolean => {
71 | const hexColorRegex = /^#([0-9a-f]{3}){1,2}$/i
72 | return hexColorRegex.test(hexColor)
73 | }
74 |
--------------------------------------------------------------------------------
/src/utils/withInterval.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Execute a function regularly based on a specified interval.
3 | *
4 | * @param {Function} fn - The function to execute.
5 | * @param {number} interval - The interval (in milliseconds) to wait between executions.
6 | * @returns {Function} A function that, when called, will clear the interval and stop future executions.
7 | */
8 |
9 | export function withInterval (fn, interval, initialFetch = false) {
10 | if (initialFetch) {
11 | try {
12 | fn()
13 | } catch (error) {
14 | console.error('Error encountered in initial execution of withInterval function:', error)
15 | // Depending on your needs, you might want to exit the entire function if an error occurs during initial fetch.
16 | // return;
17 | }
18 | }
19 |
20 | // This is the main interval handler.
21 | const intervalId = setInterval(() => {
22 | try {
23 | fn()
24 | } catch (error) {
25 | console.error('Error encountered in withInterval function:', error)
26 | // Depending on your needs, you might want to clear the interval if an error occurs.
27 | // clearInterval(intervalId);
28 | }
29 | }, interval)
30 |
31 | // Return a function that can be used to clear the interval.
32 | return () => {
33 | clearInterval(intervalId)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "esModuleInterop": true,
5 | "target": "es6",
6 | "moduleResolution": "node",
7 | "sourceMap": true,
8 | "outDir": "dist",
9 | "resolveJsonModule": true
10 | },
11 | "lib": [
12 | "es2015"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------