├── .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 | node-ts-api 3 | 4 | [![Stars](https://img.shields.io/github/stars/olegkron/node-ts-api-template.svg?style=social)](https://github.com/olegkron/node-ts-api-template/stargazers) [![Forks](https://img.shields.io/github/forks/olegkron/node-ts-api-template.svg?style=social)](https://github.com/olegkron/node-ts-api-template/network/members) [![Contributors](https://img.shields.io/github/contributors/olegkron/node-ts-api-template.svg)](https://github.com/olegkron/node-ts-api-template/graphs/contributors) [![Issues](https://img.shields.io/github/issues/olegkron/node-ts-api-template.svg)](https://github.com/olegkron/node-ts-api-template/issues) [![MIT License](https://img.shields.io/github/license/olegkron/node-ts-api-template.svg)](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 | --------------------------------------------------------------------------------