├── .cliamrc.js ├── .env.example ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.production ├── LICENSE ├── README.md ├── apidoc.json ├── docker-compose.yml ├── ecosystem.config.js ├── insomnia.workspace.json ├── package-lock.json ├── package.json ├── src ├── .eslintrc.js ├── api │ ├── app.bootstrap.ts │ ├── config │ │ ├── app.config.ts │ │ ├── authentication.config.ts │ │ ├── cache.config.ts │ │ ├── database.config.ts │ │ ├── datasource.config.ts │ │ ├── environment.config.ts │ │ ├── logger.config.ts │ │ ├── server.config.ts │ │ └── upload.config.ts │ ├── core │ │ ├── controllers │ │ │ ├── auth.controller.ts │ │ │ ├── main.controller.ts │ │ │ ├── media.controller.ts │ │ │ └── user.controller.ts │ │ ├── factories │ │ │ ├── error.factory.ts │ │ │ └── refresh-token.factory.ts │ │ ├── middlewares │ │ │ ├── cache.middleware.ts │ │ │ ├── catch.middleware.ts │ │ │ ├── cors.middleware.ts │ │ │ ├── guard.middleware.ts │ │ │ ├── resolve.middleware.ts │ │ │ ├── sanitize.middleware.ts │ │ │ ├── uploader.middleware.ts │ │ │ └── validator.middleware.ts │ │ ├── models │ │ │ ├── media.model.ts │ │ │ ├── refresh-token.model.ts │ │ │ └── user.model.ts │ │ ├── repositories │ │ │ ├── media.repository.ts │ │ │ ├── refresh-token.repository.ts │ │ │ └── user.repository.ts │ │ ├── routes │ │ │ └── v1 │ │ │ │ ├── auth.route.ts │ │ │ │ ├── main.route.ts │ │ │ │ ├── media.route.ts │ │ │ │ └── user.route.ts │ │ ├── services │ │ │ ├── auth.service.ts │ │ │ ├── cache.service.ts │ │ │ ├── logger.service.ts │ │ │ ├── media.service.ts │ │ │ ├── proxy-router.service.ts │ │ │ └── sanitizer.service.ts │ │ ├── subscribers │ │ │ ├── media.subscriber.ts │ │ │ └── user.subscriber.ts │ │ ├── types │ │ │ ├── classes │ │ │ │ ├── controller.class.ts │ │ │ │ ├── index.ts │ │ │ │ └── router.class.ts │ │ │ ├── decorators │ │ │ │ ├── index.ts │ │ │ │ └── safe.decorator.ts │ │ │ ├── enums │ │ │ │ ├── archive-mime-type.enum.ts │ │ │ │ ├── audio-mime-type.enum.ts │ │ │ │ ├── content-type.enum.ts │ │ │ │ ├── database-engine.enum.ts │ │ │ │ ├── document-mime-type.enum.ts │ │ │ │ ├── environment.enum.ts │ │ │ │ ├── fieldname.enum.ts │ │ │ │ ├── image-mime-type.enum.ts │ │ │ │ ├── index.ts │ │ │ │ ├── media-type.enum.ts │ │ │ │ ├── mime-type.enum.ts │ │ │ │ ├── role.enum.ts │ │ │ │ ├── status.enum.ts │ │ │ │ └── video-mime-type.enum.ts │ │ │ ├── errors │ │ │ │ ├── business.error.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mysql.error.ts │ │ │ │ ├── not-found.error.ts │ │ │ │ ├── server.error.ts │ │ │ │ ├── typeplate.error.ts │ │ │ │ ├── upload.error.ts │ │ │ │ └── validation.error.ts │ │ │ ├── events │ │ │ │ ├── email.event.ts │ │ │ │ └── index.ts │ │ │ ├── interfaces │ │ │ │ ├── cache.interface.ts │ │ │ │ ├── error.interface.ts │ │ │ │ ├── http-error.interface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── media-query-string.interface.ts │ │ │ │ ├── media-request.interface.ts │ │ │ │ ├── media.interface.ts │ │ │ │ ├── model.interface.ts │ │ │ │ ├── oauth-response.interface.ts │ │ │ │ ├── query-string.interface.ts │ │ │ │ ├── registrable.interface.ts │ │ │ │ ├── request.interface.ts │ │ │ │ ├── response.interface.ts │ │ │ │ ├── route.interface.ts │ │ │ │ ├── storage.interface.ts │ │ │ │ ├── token-options.interface.ts │ │ │ │ ├── upload-multer-options.interface.ts │ │ │ │ ├── upload-options.interface.ts │ │ │ │ ├── upload.interface.ts │ │ │ │ ├── user-query-string.interface.ts │ │ │ │ └── user-request.interface.ts │ │ │ ├── schemas │ │ │ │ ├── email.schema.ts │ │ │ │ ├── fieldname.schema.ts │ │ │ │ ├── file.schema.ts │ │ │ │ ├── filename.schema.ts │ │ │ │ ├── id.schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mimetype.schema.ts │ │ │ │ ├── pagination.schema.ts │ │ │ │ ├── password.schema.ts │ │ │ │ ├── path.schema.ts │ │ │ │ └── username.schema.ts │ │ │ └── types │ │ │ │ ├── archive-mime-type.type.ts │ │ │ │ ├── audio-mime-type.type.ts │ │ │ │ ├── content-type.type.ts │ │ │ │ ├── database-engine.type.ts │ │ │ │ ├── document-mime-type.type.ts │ │ │ │ ├── environment-cluster.type.ts │ │ │ │ ├── fieldname.type.ts │ │ │ │ ├── http-method.type.ts │ │ │ │ ├── image-mime-type.type.ts │ │ │ │ ├── index.ts │ │ │ │ ├── media-type.type.ts │ │ │ │ ├── mime-type.type.ts │ │ │ │ ├── moment-unit.type.ts │ │ │ │ ├── oauth-provider.type.ts │ │ │ │ ├── role.type.ts │ │ │ │ ├── status.type.ts │ │ │ │ └── video-mime-type.type.ts │ │ ├── utils │ │ │ ├── date.util.ts │ │ │ ├── enum.util.ts │ │ │ ├── error.util.ts │ │ │ ├── http.util.ts │ │ │ ├── object.util.ts │ │ │ ├── pagination.util.ts │ │ │ └── string.util.ts │ │ └── validations │ │ │ ├── auth.validation.ts │ │ │ ├── media.validation.ts │ │ │ └── user.validation.ts │ └── shared │ │ ├── services │ │ └── business.service.ts │ │ └── types │ │ └── business-rule.type.ts └── templates │ ├── business.service.txt │ ├── controller.txt │ ├── data-layer.service.txt │ ├── fixture.txt │ ├── model.txt │ ├── query-string.interface.txt │ ├── repository.txt │ ├── request.interface.txt │ ├── route.txt │ ├── subscriber.txt │ ├── test.txt │ └── validation.txt ├── test ├── e2e │ ├── 00-api.e2e.test.js │ ├── 01-api-routes.e2e.test.js │ ├── 02-auth-routes.e2e.test.js │ ├── 03-user-routes.e2e.test.js │ └── 04-media-routes.e2e.test.js ├── units │ ├── 00-application.unit.test.js │ ├── 01-express-app.unit.test.js │ ├── 02-config.unit.test.js │ ├── 03-utils.unit.test.js │ ├── 04-services.unit.test.js │ ├── 05-middlewares.unit.test.js │ ├── 06-factories.unit.test.js │ └── 07-errors.unit.test.js └── utils │ ├── fixtures │ ├── entities │ │ ├── index.js │ │ ├── media.fixture.js │ │ ├── token.fixture.js │ │ └── user.fixture.js │ ├── files │ │ ├── Responsive_Webdesign.pdf │ │ ├── Vue-Handbook.pdf │ │ ├── documents.rar │ │ ├── electric-bulb-2.mp4 │ │ ├── electric-bulb.mp4 │ │ ├── javascript.jpg │ │ ├── kill-bill-vol-1-the-whistle-song.mp3 │ │ ├── syllabus_web.doc │ │ └── tags.tif │ └── index.js │ └── index.js └── tsconfig.json /.cliamrc.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | module.exports = { 3 | sandbox: true, 4 | variables: { 5 | domain: "https://wwww.example.com", 6 | addresses: { 7 | from: { 8 | name: "YOUR_FROM_NAME", 9 | email: "info@example.com" 10 | }, 11 | replyTo: { 12 | name: "YOUR_REPLY_TO_NAME", 13 | email: "test@example.be" 14 | } 15 | } 16 | }, 17 | transporters: [ 18 | { 19 | id: 'smtp-transporter', 20 | auth: { 21 | username: "noemie2@ethereal.email", 22 | password: "5cDumYP68aFcpT15uV" 23 | }, 24 | options: { 25 | host: "smtp.ethereal.email", 26 | port: 587, 27 | secure: false, 28 | } 29 | } 30 | ] 31 | }; -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Access token secret passphrase 2 | ACCESS_TOKEN_SECRET = "hello-i-am-an-access-token-secret-passphrase" 3 | 4 | # Access token duration (3 months in dev, always in minutes) 5 | ACCESS_TOKEN_DURATION = 120960 6 | 7 | # API version 8 | API_VERSION = "v1" 9 | 10 | # CORS authorized domains, by coma separated WITHOUT spacing IE http://localhost:4200,http://my-domain.com 11 | AUTHORIZED = "http://localhost:4200" 12 | 13 | # Content delivery network 14 | # CDN = "" 15 | 16 | # Content-type for communication between api/clients 17 | # CONTENT_TYPE = "application/json" 18 | 19 | # API domain 20 | DOMAIN = "localhost" 21 | 22 | # Facebook oauth consumer ID 23 | # FACEBOOK_CONSUMER_ID = "" 24 | 25 | # Facebook oauth consumer secret 26 | # FACEBOOK_CONSUMER_SECRET = "" 27 | 28 | # Github oauth consumer id 29 | # GITHUB_CONSUMER_ID = "" 30 | 31 | # Github oauth consumer secret 32 | # GITHUB_CONSUMER_SECRET = "" 33 | 34 | # Google oauth consumer id 35 | # GOOGLE_CONSUMER_ID = "" 36 | 37 | # Google oauth consumer secret 38 | # GOOGLE_CONSUMER_SECRET = "" 39 | 40 | # Linkedin oauth consumer id 41 | # LINKEDIN_CONSUMER_ID = "" 42 | 43 | # Linkedin oauth consumer secret 44 | # LINKEDIN_CONSUMER_SECRET = "" 45 | 46 | # Logs directory path starting from process.cwd()/dist 47 | # LOGS_PATH = "logs" 48 | 49 | # Morgan logs pattern. See morgan doc. 50 | # LOGS_TOKEN = ":remote-addr HTTP/:http-version :status :method :url :total-time[2]ms" 51 | 52 | # Memory cache activated 53 | # MEMORY_CACHE = 0 54 | 55 | # Memory cache lifetime duration in milliseconds 56 | # MEMORY_CACHE_DURATION = 5000 57 | 58 | # Application port. Keep it different in development.env and test.env if you wish launch your tests when your api is running 59 | PORT = 8101 60 | 61 | # Refresh token lifetime duration 62 | # REFRESH_TOKEN_DURATION = 30 63 | 64 | # Refresh token secret 65 | REFRESH_TOKEN_SECRET = "hello-i-am-a-refresh-token-secret-passphrase" 66 | 67 | # Refresh token lifetime unity of duration hours|days|weeks|months 68 | # REFRESH_TOKEN_UNIT = "days" 69 | 70 | # Image resize activated 71 | # RESIZE_IS_ACTIVE = 1 72 | 73 | # Image master directory name 74 | # RESIZE_PATH_MASTER = "master-copy" 75 | 76 | # Image scales directory name 77 | # RESIZE_PATH_SCALE = "rescale" 78 | 79 | # Image extra-small size (px) 80 | # RESIZE_SIZE_XS = 260 81 | 82 | # Image small size (px) 83 | # RESIZE_SIZE_SM = 320 84 | 85 | # Image medium size (px) 86 | # RESIZE_SIZE_MD = 768 87 | 88 | # Image large size (px) 89 | # RESIZE_SIZE_LG = 1024 90 | 91 | # Image extra-large size (px) 92 | # RESIZE_SIZE_XL = 1366 93 | 94 | # SSL certificate path 95 | # SSL_CERT = "path-to-my-ssl-key.pem" 96 | 97 | # SSL key path 98 | # SSL_KEY = "path-to-my-ssl-key.pem" 99 | 100 | # Database engine 101 | TYPEORM_TYPE = "mariadb" 102 | 103 | # Database connection identifier 104 | # TYPEORM_NAME = "default" 105 | 106 | # Database server host. Use "db" with docker-compose, localhost otherwise. 107 | TYPEORM_HOST = "localhost" 108 | 109 | # Database name. Keep it different from your developement database. 110 | TYPEORM_DB = "typeplate_test" 111 | 112 | # Database user 113 | TYPEORM_USER = "root" 114 | 115 | # Database password 116 | TYPEORM_PWD = "passw0rd" 117 | 118 | # Database port 119 | TYPEORM_PORT = "3306" 120 | 121 | # TypeORM synchronization activated 122 | # TYPEORM_SYNC = 0 123 | 124 | # TypeORM queries logs activated 125 | # TYPEORM_LOG = 0 126 | 127 | # TypeORM cache activated. Relevant only if MEMORY_CACHE is disabled. 128 | # TYPEORM_CACHE = 0 129 | 130 | # TypeORM cache duration. Relevant only if MEMORY_CACHE is disabled. 131 | # TYPEORM_CACHE_DURATION = 5000 132 | 133 | # File upload directory path starting from process.cwd()/dist 134 | # UPLOAD_PATH = "public" 135 | 136 | # File upload max file size 137 | # UPLOAD_MAX_FILE_SIZE = 2000000 138 | 139 | # File upload max files number 140 | # UPLOAD_MAX_FILES = 5 141 | 142 | # File upload accepted file types in ARCHIVE,AUDIO,DOCUMENT,IMAGE,VIDEO 143 | # UPLOAD_WILDCARDS = "ARCHIVE,AUDIO,DOCUMENT,IMAGE,VIDEO" 144 | 145 | # Main app URL 146 | URL = "http://localhost:8101" -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: stevelebleu 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: [ dev, master, hotfix/*, feature/*, release/* ] 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | env: 10 | RUNNER: github 11 | NODE_ENV: test 12 | steps: 13 | - name: Github checkout 14 | uses: actions/checkout@v4 15 | - name: Setup node.js environment 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '20.9.0' 19 | - name: Install global dependencies 20 | run: npm i typescript@5.7.2 -g 21 | - name: Install local dependencies 22 | run: npm i 23 | - name: Compile Typescript files 24 | run: tsc 25 | - name: Upload artifact 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: build-files 29 | path: | 30 | dist 31 | retention-days: 3 32 | test: 33 | name: Test 34 | runs-on: ubuntu-latest 35 | needs: [ build ] 36 | env: 37 | RUNNER: github 38 | NODE_ENV: test 39 | steps: 40 | - name: Github checkout 41 | uses: actions/checkout@v4 42 | - name: Setup node.js environment 43 | uses: actions/setup-node@v4 44 | with: 45 | node-version: '20.9.0' 46 | - name: Setup MySQL server 47 | uses: mirromutth/mysql-action@v1.1 48 | with: 49 | mysql version: '5.7' 50 | mysql database: 'typeplate_test' 51 | mysql root password: passw0rd 52 | - name: Install global dependencies 53 | run: npm i typescript@5.7.2 -g 54 | - name: Install local dependencies 55 | run: npm i 56 | - name: Copy .env.example to .env.test 57 | run: cp .env.example .env.test 58 | - name: Setup .env file 59 | run: | 60 | echo FACEBOOK_CONSUMER_ID = "${{ secrets.FACEBOOK_CONSUMER_ID }}" >> .env.test 61 | echo FACEBOOK_CONSUMER_SECRET = "${{ secrets.FACEBOOK_CONSUMER_SECRET }}" >> .env.test 62 | echo GITHUB_CONSUMER_ID = "${{ secrets.GTHB_CONSUMER_ID }}" >> .env.test 63 | echo GITHUB_CONSUMER_SECRET = "${{ secrets.GTHB_CONSUMER_SECRET }}" >> .env.test 64 | echo GOOGLE_CONSUMER_ID = "${{ secrets.GOOGLE_CONSUMER_ID }}" >> .env.test 65 | echo GOOGLE_CONSUMER_SECRET = "${{ secrets.GOOGLE_CONSUMER_SECRET }}" >> .env.test 66 | echo LINKEDIN_CONSUMER_ID = "${{ secrets.LINKEDIN_CONSUMER_ID }}" >> .env.test 67 | echo LINKEDIN_CONSUMER_SECRET = "${{ secrets.LINKEDIN_CONSUMER_SECRET }}" >> .env.test 68 | - name: Create dist directory 69 | run: mkdir dist 70 | - name: Download build artifact 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: build-files 74 | path: dist 75 | - name: Synchronize database schema 76 | run: npm run schema:sync 77 | - name: Execute tests suites 78 | run: npm run ci:test 79 | - name: Publish to coveralls.io 80 | uses: coverallsapp/github-action@v1.1.2 81 | with: 82 | github-token: ${{ secrets.GITHUB_TOKEN }} 83 | path-to-lcov: ./reports/coverage/lcov.info -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | env: 11 | RUNNER: github 12 | NODE_ENV: test 13 | steps: 14 | - name: Github checkout 15 | uses: actions/checkout@v4 16 | - name: Setup node.js environment 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20.9.0' 20 | - name: Install global dependencies 21 | run: npm i typescript@5.7.2 -g 22 | - name: Install local dependencies 23 | run: npm i 24 | - name: Compile Typescript files 25 | run: tsc 26 | - name: Upload artifact 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: build-files 30 | path: | 31 | dist 32 | retention-days: 3 33 | test: 34 | name: Test 35 | runs-on: ubuntu-latest 36 | needs: [ build ] 37 | env: 38 | RUNNER: github 39 | NODE_ENV: test 40 | steps: 41 | - name: Github checkout 42 | uses: actions/checkout@v4 43 | - name: Setup node.js environment 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: '20.9.0' 47 | - name: Setup MySQL server 48 | uses: mirromutth/mysql-action@v1.1 49 | with: 50 | mysql version: '5.7' 51 | mysql database: 'typeplate_test' 52 | mysql root password: passw0rd 53 | - name: Install global dependencies 54 | run: npm i typescript@5.7.2 -g 55 | - name: Install local dependencies 56 | run: npm i 57 | - name: Copy .env.example to .env.test 58 | run: cp .env.example .env.test 59 | - name: Setup .env file 60 | run: | 61 | echo FACEBOOK_CONSUMER_ID = "${{ secrets.FACEBOOK_CONSUMER_ID }}" >> .env.test 62 | echo FACEBOOK_CONSUMER_SECRET = "${{ secrets.FACEBOOK_CONSUMER_SECRET }}" >> .env.test 63 | echo GITHUB_CONSUMER_ID = "${{ secrets.GTHB_CONSUMER_ID }}" >> .env.test 64 | echo GITHUB_CONSUMER_SECRET = "${{ secrets.GTHB_CONSUMER_SECRET }}" >> .env.test 65 | echo GOOGLE_CONSUMER_ID = "${{ secrets.GOOGLE_CONSUMER_ID }}" >> .env.test 66 | echo GOOGLE_CONSUMER_SECRET = "${{ secrets.GOOGLE_CONSUMER_SECRET }}" >> .env.test 67 | echo LINKEDIN_CONSUMER_ID = "${{ secrets.LINKEDIN_CONSUMER_ID }}" >> .env.test 68 | echo LINKEDIN_CONSUMER_SECRET = "${{ secrets.LINKEDIN_CONSUMER_SECRET }}" >> .env.test 69 | - name: Create dist directory 70 | run: mkdir dist 71 | - name: Download build artifact 72 | uses: actions/download-artifact@v4 73 | with: 74 | name: build-files 75 | path: dist 76 | - name: Synchronize database schema 77 | run: npm run schema:sync 78 | - name: Execute tests suites 79 | run: npm run ci:test 80 | - name: Publish coverage to coveralls.io 81 | uses: coverallsapp/github-action@v1.1.2 82 | with: 83 | github-token: ${{ secrets.GITHUB_TOKEN }} 84 | path-to-lcov: ./reports/coverage/lcov.info 85 | release: 86 | name: Release on Github 87 | runs-on: ubuntu-latest 88 | needs: [ build, test ] 89 | steps: 90 | - name: Cache dependencies 91 | uses: actions/cache@v2 92 | with: 93 | path: '**/node_modules' 94 | key: node-modules-${{ hashfiles('**/package-lock.json') }} 95 | - name: Github checkout 96 | uses: actions/checkout@v4 97 | with: 98 | fetch-depth: 0 99 | - name: Setup node.js environment 100 | uses: actions/setup-node@v4 101 | with: 102 | node-version: '20.9.0' 103 | - name: Create release 104 | uses: konfer-be/action-create-release-from-tag@v1.0.12 105 | with: 106 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # VScode settings 21 | .vscode 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | 66 | # NVM local directory 67 | nvm/ 68 | 69 | # Dist directory 70 | dist/ 71 | 72 | # Docs directory 73 | docs/ 74 | 75 | # Reports directory 76 | reports/ 77 | 78 | # .env files 79 | .env.development 80 | .env.test 81 | .env.staging 82 | .env.production 83 | 84 | # Husky configuration 85 | .husky/ 86 | 87 | # Roadmap 88 | ROADMAP.md 89 | 90 | # Todo 91 | TODO.md -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:20.18.0-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Install build dependencies 7 | COPY package*.json ./ 8 | RUN npm ci 9 | 10 | # Copy source code 11 | COPY . . 12 | 13 | # Build TypeScript code and create env files 14 | RUN npm run init:copy && npm run init:compile 15 | 16 | # Production stage 17 | FROM node:20.18.0-alpine AS production 18 | 19 | WORKDIR /app 20 | 21 | # Install production dependencies only 22 | COPY package*.json ./ 23 | RUN npm i 24 | RUN npm i -g nodemon 25 | 26 | # Copy built files and config files 27 | COPY --from=builder /app/dist ./dist 28 | COPY --from=builder /app/.cliamrc.js ./.cliamrc.js 29 | 30 | # Create necessary directories for uploads and logs 31 | RUN mkdir -p ./dist/public/archives \ 32 | && mkdir -p ./dist/public/documents \ 33 | && mkdir -p ./dist/public/images/master-copy \ 34 | && mkdir -p ./dist/public/images/rescale \ 35 | && mkdir -p ./dist/public/audios \ 36 | && mkdir -p ./dist/public/videos \ 37 | && mkdir -p ./dist/logs 38 | 39 | # Set environment variables 40 | ENV NODE_ENV=development 41 | ENV PORT=8101 42 | 43 | # Expose the port 44 | EXPOSE 8101 45 | 46 | # Start the application 47 | CMD ["nodemon", "."] 48 | -------------------------------------------------------------------------------- /Dockerfile.production: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:20.18.0-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Install build dependencies 7 | COPY package*.json ./ 8 | RUN npm ci 9 | 10 | # Copy source code 11 | COPY . . 12 | 13 | # Build TypeScript code and create env files 14 | RUN npm run init:copy && npm run init:compile 15 | 16 | # Production stage 17 | FROM node:20.18.0-alpine AS production 18 | 19 | WORKDIR /app 20 | 21 | # Install production dependencies only 22 | COPY package*.json ./ 23 | RUN npm ci --only=production 24 | 25 | # Copy built files and config files 26 | COPY --from=builder /app/dist ./dist 27 | COPY --from=builder /app/.cliamrc.js ./.cliamrc.js 28 | 29 | # Create necessary directories for uploads and logs 30 | RUN mkdir -p ./dist/public/archives \ 31 | && mkdir -p ./dist/public/documents \ 32 | && mkdir -p ./dist/public/images/master-copy \ 33 | && mkdir -p ./dist/public/images/rescale \ 34 | && mkdir -p ./dist/public/audios \ 35 | && mkdir -p ./dist/public/videos \ 36 | && mkdir -p ./dist/logs 37 | 38 | # Set environment variables 39 | ENV NODE_ENV=production 40 | ENV PORT=8101 41 | 42 | # Expose the port 43 | EXPOSE 8101 44 | 45 | # Start the application 46 | CMD ["node", "./dist/api/app.bootstrap.js"] 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 TRIPTYK SPRL 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 | -------------------------------------------------------------------------------- /apidoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Typeplate", 3 | "version": "1.0.0", 4 | "description": "REST API consumer documentation.", 5 | "title": "[Typeplate] API consumer documentation", 6 | "url": "https://www.your-project.com/api/v1", 7 | "order": [ 8 | "Main", 9 | "Auth", 10 | "User", 11 | "Media" 12 | ], 13 | "template": { 14 | "withGenerator": false 15 | } 16 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | args: 7 | - NODE_ENV=development 8 | ports: 9 | - ${PORT}:${PORT} 10 | depends_on: 11 | db: 12 | condition: service_healthy 13 | env_file: 14 | - .env 15 | volumes: 16 | - .:/app 17 | - /app/node_modules 18 | develop: 19 | watch: 20 | - action: sync 21 | path: ./ 22 | target: /app 23 | 24 | db: 25 | image: mariadb 26 | restart: always 27 | user: ${TYPEORM_USER} 28 | volumes: 29 | - db-data:/var/lib/mysql 30 | env_file: 31 | - development.env 32 | environment: 33 | - MARIADB_ROOT_PASSWORD=${TYPEORM_PWD} 34 | - MARIADB_DATABASE=${TYPEORM_DB} 35 | expose: 36 | - 3306 37 | healthcheck: 38 | test: 39 | [ 40 | "CMD", 41 | "/usr/local/bin/healthcheck.sh", 42 | "--su-mysql", 43 | "--connect", 44 | "--innodb_initialized", 45 | ] 46 | interval: 5s 47 | timeout: 5s 48 | retries: 5 49 | 50 | phpmyadmin: 51 | image: phpmyadmin 52 | ports: 53 | - 8080:80 54 | depends_on: 55 | - db 56 | environment: 57 | - PMA_HOST=db 58 | 59 | volumes: 60 | db-data: -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | // Options reference: https://pm2.io/doc/en/runtime/reference/ecosystem-file/ 2 | module.exports = { 3 | apps : [{ 4 | name: 'API', 5 | script: './api/app.bootstrap.js', 6 | args: 'one two', 7 | exec_mode: 'cluster', 8 | instances: -1, 9 | autorestart: true, 10 | watch: false, 11 | max_memory_restart: '1G', 12 | env: { 13 | NODE_ENV: 'development' 14 | }, 15 | env_staging: { 16 | NODE_ENV: 'staging' 17 | }, 18 | env_production: { 19 | NODE_ENV: 'production' 20 | } 21 | }], 22 | deploy : { 23 | staging : { 24 | user : 'node', 25 | host : '212.83.163.1', 26 | ref : 'origin/master', 27 | repo : 'git@github.com:repo.git', 28 | ssh_options: ['StrictHostKeyChecking=no', 'PasswordAuthentication=yes', 'ForwardAgent=yes'], 29 | path : '/var/www/staging', 30 | 'post-setup' : 'npm run kickstart:staging && pm2 reload ecosystem.config.js --env staging', 31 | 'post-deploy' : 'npm i && tsc && pm2 reload ecosystem.config.js --env staging' 32 | }, 33 | production : { 34 | user : 'node', 35 | host : '212.83.163.1', 36 | ref : 'origin/master', 37 | repo : 'git@github.com:repo.git', 38 | ssh_options: ['StrictHostKeyChecking=no', 'PasswordAuthentication=yes', 'ForwardAgent=yes'], 39 | path : '/var/www/production', 40 | 'post-setup' : 'npm run kickstart:production && pm2 reload ecosystem.config.js --env production', 41 | 'post-deploy' : 'npm i && tsc && pm2 reload ecosystem.config.js --env production' 42 | } 43 | } 44 | }; -------------------------------------------------------------------------------- /src/api/app.bootstrap.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import { TYPEORM, ENV } from '@config/environment.config'; 4 | import { Logger } from '@services/logger.service'; 5 | import { ApplicationDataSource } from '@config/database.config'; 6 | import { Server } from '@config/server.config'; 7 | 8 | ApplicationDataSource.initialize() 9 | .then(() => { 10 | Logger.log('info', `Connection to MySQL server established on port ${TYPEORM.PORT} (${ENV})`); 11 | }) 12 | .catch((error: Error) => { 13 | console.error('Error while connecting to database:', error.message); 14 | process.exit(1); 15 | }); 16 | 17 | import { Application } from '@config/app.config'; 18 | 19 | const application = Application; 20 | const server = Server.init(application).listen() as unknown; 21 | 22 | export { application, server }; -------------------------------------------------------------------------------- /src/api/config/authentication.config.ts: -------------------------------------------------------------------------------- 1 | import * as passport from 'passport'; 2 | import { Strategy } from 'passport'; 3 | import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; 4 | import { Strategy as FacebookStrategy } from 'passport-facebook'; 5 | import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth'; 6 | import { Strategy as GithubStrategy } from 'passport-github2'; 7 | import { Strategy as LinkedInStrategy } from 'passport-linkedin-oauth2'; 8 | 9 | import { ACCESS_TOKEN, FACEBOOK, GOOGLE, GITHUB, LINKEDIN } from '@config/environment.config'; 10 | import { AuthService } from '@services/auth.service'; 11 | 12 | const ExtractJwtAlias = ExtractJwt as { fromAuthHeaderWithScheme: (type: string) => string }; 13 | 14 | /** 15 | * Authentication configuration 16 | */ 17 | class Authentication { 18 | 19 | /** 20 | * @description Authentication instance 21 | */ 22 | private static instance: Authentication; 23 | 24 | /** 25 | * @description Default options 26 | */ 27 | private options = { 28 | jwt: { 29 | secretOrKey: ACCESS_TOKEN.SECRET, 30 | jwtFromRequest: ExtractJwtAlias.fromAuthHeaderWithScheme('Bearer') 31 | } 32 | }; 33 | 34 | private constructor() {} 35 | 36 | /** 37 | * @description Authentication singleton getter 38 | */ 39 | static get(): Authentication { 40 | if (!Authentication.instance) { 41 | Authentication.instance = new Authentication(); 42 | } 43 | return Authentication.instance; 44 | } 45 | 46 | /** 47 | * @description Wrap passport method 48 | */ 49 | initialize = (): any => { 50 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 51 | return passport.initialize() as unknown; 52 | } 53 | 54 | /** 55 | * @description Enable available auth strategies 56 | */ 57 | plug(): void { 58 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 59 | passport.use(this.factory('jwt')); 60 | [ FACEBOOK, GITHUB, GOOGLE, LINKEDIN ] 61 | .filter(provider => provider.IS_ACTIVE) 62 | .forEach(provider => { 63 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 64 | passport.use(this.factory(provider.KEY)) 65 | }); 66 | } 67 | 68 | /** 69 | * @description Provide a passport strategy instance 70 | * 71 | * @param strategy Strategy to instanciate 72 | */ 73 | private factory (strategy: string): Strategy { 74 | switch(strategy) { 75 | case 'jwt': 76 | return new JwtStrategy( this.options.jwt, AuthService.jwt ) as Strategy; 77 | case 'facebook': 78 | return new FacebookStrategy({ 79 | clientID: FACEBOOK.ID, 80 | clientSecret: FACEBOOK.SECRET, 81 | callbackURL: FACEBOOK.CALLBACK_URL, 82 | profileFields: ['id', 'link', 'email', 'name', 'picture', 'address'] 83 | }, AuthService.oAuth ) as Strategy; 84 | case 'google': 85 | return new GoogleStrategy({ 86 | clientID: GOOGLE.ID, 87 | clientSecret: GOOGLE.SECRET, 88 | callbackURL: GOOGLE.CALLBACK_URL, 89 | scope: ['profile', 'email'] 90 | }, AuthService.oAuth ) as Strategy; 91 | case 'github': 92 | return new GithubStrategy({ 93 | clientID: GITHUB.ID, 94 | clientSecret: GITHUB.SECRET, 95 | callbackURL: GITHUB.CALLBACK_URL, 96 | scope: ['profile', 'email'] 97 | }, AuthService.oAuth ) as Strategy; 98 | case 'linkedin': 99 | return new LinkedInStrategy({ 100 | clientID: LINKEDIN.ID, 101 | clientSecret: LINKEDIN.SECRET, 102 | callbackURL: LINKEDIN.CALLBACK_URL, 103 | scope: ['profile', 'email'] 104 | }, AuthService.oAuth ) as Strategy; 105 | } 106 | } 107 | } 108 | 109 | const instance = Authentication.get(); 110 | 111 | export { instance as Authentication }; -------------------------------------------------------------------------------- /src/api/config/cache.config.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as mcache from 'memory-cache'; 3 | 4 | import { MEMORY_CACHE } from '@config/environment.config'; 5 | import { ICache } from '@interfaces'; 6 | 7 | /** 8 | * @description Cache service interface with memory cache module 9 | */ 10 | class CacheConfiguration { 11 | 12 | /** 13 | * @description 14 | */ 15 | private static instance: CacheConfiguration; 16 | 17 | /** 18 | * @description 19 | */ 20 | options = MEMORY_CACHE; 21 | 22 | /** 23 | * @description 24 | */ 25 | private engine: ICache; 26 | 27 | private constructor() {} 28 | 29 | /** 30 | * @description 31 | */ 32 | get key() { 33 | return '__mcache_' 34 | } 35 | 36 | /** 37 | * @description 38 | */ 39 | get start(): ICache { 40 | if (!this.engine) { 41 | this.engine = mcache as ICache; 42 | } 43 | return this.engine; 44 | } 45 | 46 | static get(): CacheConfiguration { 47 | if (!CacheConfiguration.instance) { 48 | CacheConfiguration.instance = new CacheConfiguration(); 49 | } 50 | return CacheConfiguration.instance; 51 | } 52 | 53 | } 54 | 55 | const cacheConfiguration = CacheConfiguration.get(); 56 | 57 | export { cacheConfiguration as CacheConfiguration } -------------------------------------------------------------------------------- /src/api/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { DataSource, MixedList } from 'typeorm'; 4 | import { TYPEORM } from '@config/environment.config'; 5 | 6 | /** 7 | * Typeorm default configuration 8 | * 9 | * @see https://http://typeorm.io 10 | */ 11 | export const ApplicationDataSource = new DataSource({ 12 | type: TYPEORM?.TYPE as 'mariadb' | 'mysql', 13 | name: TYPEORM.NAME, 14 | host: TYPEORM.HOST, 15 | port: TYPEORM.PORT, 16 | username: TYPEORM.USER, 17 | password: TYPEORM.PWD, 18 | database: TYPEORM.DB, 19 | entities: TYPEORM.ENTITIES as unknown as MixedList, 20 | subscribers: TYPEORM.SUBSCRIBERS as unknown as MixedList, 21 | synchronize: TYPEORM.SYNC, 22 | logging: TYPEORM.LOG, 23 | cache: TYPEORM.CACHE 24 | }); -------------------------------------------------------------------------------- /src/api/config/datasource.config.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { DataSource } from 'typeorm'; 4 | 5 | /** 6 | * Typeorm default configuration used by CLI. This one is the new ormconfig.json 7 | * 8 | * @see https://http://typeorm.io 9 | */ 10 | export const dataSource = new DataSource({ 11 | type: 'mysql', 12 | name: 'default', 13 | host: process.env.CI_DB_HOST || 'localhost', 14 | port: 3306, 15 | username: process.env.CI_DB_USER || 'root', 16 | password: process.env.CI_DB_PWD || 'passw0rd', 17 | database: process.env.CI_DB_NAME || 'typeplate_test', 18 | entities: [ 19 | 'dist/api/core/models/**/*.model.js', 20 | 'dist/api/resources/**/*.model.js' 21 | ], 22 | migrations: [ 23 | 'dist/migrations/**/*.js' 24 | ], 25 | subscribers: [ 26 | 'dist/api/core/subscribers/**/*.subscriber.js', 27 | 'dist/api/resources/**/*.subscriber.js' 28 | ], 29 | synchronize: false, 30 | logging: false, 31 | cache: false 32 | }); -------------------------------------------------------------------------------- /src/api/config/logger.config.ts: -------------------------------------------------------------------------------- 1 | import { createWriteStream } from 'fs'; 2 | 3 | import * as Winston from 'winston'; 4 | import * as Morgan from 'morgan'; 5 | 6 | import { format, Logger as WinstonLogger } from 'winston'; 7 | 8 | import { ENV, LOGS } from '@config/environment.config'; 9 | import { ENVIRONMENT } from '@enums'; 10 | 11 | /** 12 | * @see https://github.com/winstonjs/winston 13 | * @see https://github.com/expressjs/morgan 14 | */ 15 | class LoggerConfiguration { 16 | 17 | /** 18 | * @description 19 | */ 20 | private static instance: LoggerConfiguration; 21 | 22 | /** 23 | * @description Wrapped Winston instance 24 | */ 25 | logger: WinstonLogger; 26 | 27 | /** 28 | * @description 29 | */ 30 | private stream: any = { 31 | write:(message: string) => { 32 | this.logger.info(message.substring(0, message.lastIndexOf('\n'))); 33 | } 34 | }; 35 | 36 | /** 37 | * @description Output format 38 | */ 39 | private formater = format.printf( ( { level, message, label, timestamp } ) => { 40 | return `${timestamp as string} [${level}] ${message as string}`; 41 | }); 42 | 43 | /** 44 | * @description Default options 45 | */ 46 | private options = { 47 | error: { 48 | level: 'error', 49 | format: format.combine( 50 | format.timestamp(), 51 | this.formater 52 | ), 53 | filename: `${LOGS.PATH}/error.log`, 54 | handleException: true, 55 | json: true, 56 | maxSize: 5242880, // 5MB 57 | maxFiles: 5, 58 | colorize: false, 59 | }, 60 | info: { 61 | level: 'info', 62 | format: format.combine( 63 | format.timestamp(), 64 | this.formater 65 | ), 66 | filename: `${LOGS.PATH}/combined.log`, 67 | handleException: false, 68 | json: true, 69 | maxSize: 5242880, // 5MB 70 | maxFiles: 5, 71 | colorize: false, 72 | }, 73 | console: { 74 | format: Winston.format.simple(), 75 | level: 'debug', 76 | handleExceptions: true, 77 | json: false, 78 | colorize: true, 79 | } 80 | }; 81 | 82 | private constructor() {} 83 | 84 | /** 85 | * @description 86 | */ 87 | static get(): LoggerConfiguration { 88 | if (!LoggerConfiguration.instance) { 89 | LoggerConfiguration.instance = new LoggerConfiguration(); 90 | } 91 | return LoggerConfiguration.instance; 92 | } 93 | 94 | /** 95 | * @description Initialize default logger configuration 96 | */ 97 | init(): LoggerConfiguration { 98 | 99 | if (LoggerConfiguration.instance && this.logger) { 100 | return this; 101 | } 102 | 103 | this.logger = Winston.createLogger({ 104 | level: 'info', 105 | transports: [ 106 | // 107 | // - Write to all logs with level `info` and below to `combined.log` 108 | // - Write all logs error (and below) to `error.log`. 109 | // 110 | new Winston.transports.File(this.options.error), 111 | new Winston.transports.File(this.options.info), 112 | ], 113 | exitOnError: false 114 | }); 115 | 116 | if ( !['production', 'test'].includes(ENV) ) { 117 | this.logger.add( new Winston.transports.Console(this.options.console) ); 118 | } 119 | 120 | return this; 121 | } 122 | 123 | /** 124 | * @description 125 | */ 126 | writeStream(): ReadableStream { 127 | return Morgan(LOGS.TOKEN, { stream: ( ENV as ENVIRONMENT === ENVIRONMENT.production ? createWriteStream(`${LOGS.PATH}/access.log`, { flags: 'a+' }) : this.stream ) as ReadableStream } ) as ReadableStream 128 | } 129 | 130 | } 131 | 132 | const config = LoggerConfiguration 133 | .get() 134 | .init(); 135 | 136 | export { config as LoggerConfiguration } -------------------------------------------------------------------------------- /src/api/config/server.config.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { Server as HTTPServer, createServer } from 'https'; 3 | import { RequestListener } from 'http'; 4 | import { Application } from 'express'; 5 | 6 | import { ENV, SSL, PORT } from '@config/environment.config'; 7 | 8 | import { Logger } from '@services/logger.service'; 9 | 10 | /** 11 | * @description 12 | */ 13 | 14 | export class ServerConfiguration { 15 | 16 | private static instance: ServerConfiguration; 17 | 18 | /** 19 | * @description 20 | */ 21 | private options = { 22 | credentials: { 23 | key: SSL.IS_ACTIVE ? readFileSync(SSL.KEY, 'utf8') : null, 24 | cert: SSL.IS_ACTIVE ? readFileSync(SSL.CERT, 'utf8') : null, 25 | }, 26 | port: PORT 27 | } 28 | 29 | /** 30 | * @description 31 | */ 32 | private server: Application|HTTPServer 33 | 34 | private constructor() {} 35 | 36 | /** 37 | * @description 38 | */ 39 | static get(): ServerConfiguration { 40 | if (!ServerConfiguration.instance) { 41 | ServerConfiguration.instance = new ServerConfiguration(); 42 | } 43 | return ServerConfiguration.instance; 44 | } 45 | 46 | /** 47 | * @description 48 | * 49 | * @param app Express application 50 | */ 51 | init(app: Application): ServerConfiguration { 52 | this.server = !this.server 53 | ? SSL.IS_ACTIVE 54 | ? createServer(this.options.credentials, app as RequestListener) 55 | : app 56 | : this.server; 57 | return this; 58 | } 59 | 60 | /** 61 | * @description 62 | */ 63 | listen(): any { 64 | const port = SSL.IS_ACTIVE ? 443 : PORT; 65 | const protocol = SSL.IS_ACTIVE ? 'HTTPS' : 'HTTP'; 66 | return this.server.listen(port, () => { 67 | Logger.log('info', `${protocol} server is now running on port ${port} (${ENV})`); 68 | }); 69 | } 70 | } 71 | 72 | const Server = ServerConfiguration.get(); 73 | 74 | export { Server } -------------------------------------------------------------------------------- /src/api/config/upload.config.ts: -------------------------------------------------------------------------------- 1 | import * as Multer from 'multer'; 2 | import * as filenamify from 'filenamify'; 3 | 4 | import { existsSync, mkdirSync } from 'fs'; 5 | import { unsupportedMediaType } from '@hapi/boom'; 6 | 7 | import { UPLOAD, SCALING } from '@config/environment.config'; 8 | 9 | import { IUploadMulterOptions, IUploadOptions, IMedia, IStorage, IUpload } from '@interfaces'; 10 | 11 | import { foldername, extension, getTypeOfMedia } from '@utils/string.util'; 12 | import { FIELDNAME, IMAGE_MIME_TYPE } from '@enums'; 13 | import { list } from '@utils/enum.util'; 14 | 15 | class UploadConfiguration { 16 | 17 | private static instance: UploadConfiguration; 18 | 19 | /** 20 | * @description Default options 21 | */ 22 | options: IUploadOptions = { 23 | destination: UPLOAD.PATH, 24 | maxFiles: UPLOAD.MAX_FILES, 25 | filesize: UPLOAD.MAX_FILE_SIZE, 26 | wildcards: UPLOAD.WILDCARDS 27 | }; 28 | 29 | /** 30 | * @description 31 | */ 32 | engine = Multer as (options?) => IUpload; 33 | 34 | private constructor() { } 35 | 36 | /** 37 | * @description 38 | */ 39 | static get(): UploadConfiguration { 40 | if (!UploadConfiguration.instance) { 41 | UploadConfiguration.instance = new UploadConfiguration(); 42 | } 43 | return UploadConfiguration.instance; 44 | } 45 | 46 | /** 47 | * @description Set Multer instance 48 | * 49 | * @param destination Directory where file will be uploaded 50 | * @param filesize Max file size authorized 51 | * @param wildcards Array of accepted mime types 52 | */ 53 | configuration( options?: IUploadOptions ): IUploadMulterOptions { 54 | return { 55 | storage: this.storage(options.destination), 56 | limits: { 57 | fileSize: options.filesize 58 | }, 59 | fileFilter: (req: Request, file: IMedia, next: (e?: Error, v?: boolean) => void) => { 60 | if(options.wildcards.filter( mime => file.mimetype === mime ).length === 0) { 61 | return next( unsupportedMediaType('File mimetype not supported'), false ); 62 | } 63 | return next(null, true); 64 | } 65 | }; 66 | } 67 | 68 | /** 69 | * @description Set storage config 70 | * @param destination As main destination 71 | */ 72 | private storage(destination?: string): IStorage { 73 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 74 | return Multer.diskStorage({ 75 | destination: (req: Request, file: IMedia, next: (e?: Error, v?: string) => void) => { 76 | let towards = `${destination}/${getTypeOfMedia(file.mimetype)}s`; 77 | if (IMAGE_MIME_TYPE[file.mimetype]) { 78 | towards += `/${SCALING.PATH_MASTER}`; 79 | } 80 | towards += `/${file.fieldname}`; 81 | if ( list(FIELDNAME).includes(file.fieldname) && !existsSync(towards) ) { 82 | mkdirSync(towards); 83 | } 84 | next(null, towards); 85 | }, 86 | filename: (req: Request, file: IMedia, next: (e?: Error, v?: string) => void) => { 87 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 88 | const name = filenamify( foldername(file.originalname), { replacement: '-', maxLength: 128 } ) 89 | .replace(' ', '-') 90 | .replace('_', '-') 91 | .toLowerCase() 92 | .concat('-') 93 | .concat(Date.now().toString()) 94 | .concat('.') 95 | .concat(extension(file.originalname).toLowerCase()); 96 | next(null, name); 97 | } 98 | }) as IStorage; 99 | } 100 | } 101 | 102 | const configuration = UploadConfiguration.get(); 103 | 104 | export { configuration as UploadConfiguration } -------------------------------------------------------------------------------- /src/api/core/controllers/main.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { Logger } from '@services/logger.service'; 4 | 5 | /** 6 | * Manage incoming requests from api/{version}/. 7 | * End points of this router resolve response by itself. 8 | */ 9 | class MainController { 10 | 11 | /** 12 | * @description 13 | */ 14 | private static instance: MainController; 15 | 16 | private constructor() {} 17 | 18 | /** 19 | * @description 20 | */ 21 | static get(): MainController { 22 | if (!MainController.instance) { 23 | MainController.instance = new MainController(); 24 | } 25 | return MainController.instance; 26 | } 27 | 28 | /** 29 | * @description Ping api 30 | * 31 | * @param req Express request object derived from http.incomingMessage 32 | * @param res Express response object 33 | */ 34 | async status(req: Request, res: Response, next: () => void): Promise { 35 | res.status(200); 36 | res.end(); 37 | } 38 | 39 | /** 40 | * @description Log CSP report violation. This endpoint is called programmaticaly by helmet. 41 | * 42 | * @param req Express request object derived from http.incomingMessage 43 | * @param res Express response object 44 | */ 45 | async report(req: Request, res: Response, next: () => void): Promise { 46 | Logger.log('error', req.body ? `CSP Violation: ${JSON.stringify(req.body)}` : 'CSP Violation'); 47 | res.status(204); 48 | res.end(); 49 | } 50 | 51 | } 52 | 53 | const mainController = MainController.get(); 54 | 55 | export { mainController as MainController } -------------------------------------------------------------------------------- /src/api/core/controllers/media.controller.ts: -------------------------------------------------------------------------------- 1 | import { clone } from 'lodash'; 2 | 3 | import { ApplicationDataSource } from '@config/database.config'; 4 | import { IMedia, IMediaRequest, IResponse } from '@interfaces'; 5 | import { Safe } from '@decorators/safe.decorator'; 6 | import { MediaRepository } from '@repositories/media.repository'; 7 | import { Media } from '@models/media.model'; 8 | 9 | import { paginate } from '@utils/pagination.util'; 10 | 11 | /** 12 | * Manage incoming requests for api/{version}/medias 13 | */ 14 | class MediaController { 15 | 16 | /** 17 | * @description 18 | */ 19 | private static instance: MediaController; 20 | 21 | private constructor() {} 22 | 23 | /** 24 | * @description 25 | */ 26 | static get(): MediaController { 27 | if (!MediaController.instance) { 28 | MediaController.instance = new MediaController(); 29 | } 30 | return MediaController.instance; 31 | } 32 | 33 | /** 34 | * @description Retrieve one document according to :documentId 35 | * 36 | * @param req Express request object derived from http.incomingMessage 37 | * @param res Express response object 38 | * 39 | * @public 40 | */ 41 | @Safe() 42 | async get(req: IMediaRequest, res: IResponse): Promise { 43 | const repository = ApplicationDataSource.getRepository(Media); 44 | const media = await repository.findOneOrFail({ where: { id: req.params.mediaId }, relations: ['owner'] }); 45 | res.locals.data = media; 46 | } 47 | 48 | /** 49 | * @description Retrieve a list of documents, according to some parameters 50 | * 51 | * @param req Express request object derived from http.incomingMessage 52 | * @param res Express response object 53 | */ 54 | @Safe() 55 | async list (req: IMediaRequest, res: IResponse): Promise { 56 | const response = await MediaRepository.list(req.query); 57 | res.locals.data = response.result; 58 | res.locals.meta = { 59 | total: response.total, 60 | pagination: paginate( parseInt(req.query.page, 10), parseInt(req.query.perPage, 10), response.total ) 61 | } 62 | } 63 | 64 | /** 65 | * @description Create a new document 66 | * 67 | * @param req Express request object derived from http.incomingMessage 68 | * @param res Express response object 69 | * 70 | * @public 71 | */ 72 | @Safe() 73 | async create(req: IMediaRequest, res: IResponse): Promise { 74 | const repository = ApplicationDataSource.getRepository(Media); 75 | const medias = [].concat(req.files).map( (file) => new Media(file as IMedia)); 76 | await repository.save(medias); 77 | res.locals.data = medias; 78 | } 79 | 80 | /** 81 | * @description Update one document according to :documentId 82 | * 83 | * @param req Express request object derived from http.incomingMessage 84 | * @param res Express response object 85 | * 86 | * @public 87 | */ 88 | @Safe() 89 | async update(req: IMediaRequest, res: IResponse): Promise { 90 | const repository = ApplicationDataSource.getRepository(Media); 91 | const media = clone(res.locals.data) as Media; 92 | repository.merge(media, req.files[0] as unknown); 93 | await repository.save(media); 94 | res.locals.data = media; 95 | } 96 | 97 | /** 98 | * @description Delete one document according to :documentId 99 | * 100 | * @param req Express request object derived from http.incomingMessage 101 | * @param res Express response object 102 | * 103 | * @public 104 | */ 105 | @Safe() 106 | async remove (req: IMediaRequest, res: IResponse): Promise { 107 | const repository = ApplicationDataSource.getRepository(Media); 108 | const media = clone(res.locals.data) as Media; 109 | await repository.remove(media); 110 | } 111 | } 112 | 113 | const mediaController = MediaController.get(); 114 | 115 | export { mediaController as MediaController } -------------------------------------------------------------------------------- /src/api/core/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { forbidden, notFound } from '@hapi/boom'; 2 | 3 | import { User } from '@models/user.model'; 4 | import { UserRepository } from '@repositories/user.repository'; 5 | import { IUserRequest, IResponse } from '@interfaces'; 6 | import { Safe } from '@decorators/safe.decorator'; 7 | import { paginate } from '@utils/pagination.util'; 8 | import { ApplicationDataSource } from '@config/database.config'; 9 | 10 | /** 11 | * Manage incoming requests for api/{version}/users 12 | */ 13 | class UserController { 14 | 15 | /** 16 | * @description 17 | */ 18 | private static instance: UserController; 19 | 20 | private constructor() {} 21 | 22 | /** 23 | * @description 24 | */ 25 | static get(): UserController { 26 | if (!UserController.instance) { 27 | UserController.instance = new UserController(); 28 | } 29 | return UserController.instance; 30 | } 31 | 32 | /** 33 | * @description Get user 34 | * 35 | * @param req Express request object derived from http.incomingMessage 36 | * @param res Express response object 37 | */ 38 | @Safe() 39 | async get(req: IUserRequest, res: IResponse): Promise { 40 | res.locals.data = await UserRepository.one(parseInt(req.params.userId as string, 10)); 41 | } 42 | 43 | /** 44 | * @description Get logged in user info 45 | * 46 | * @param req Express request object derived from http.incomingMessage 47 | * @param res Express response object 48 | */ 49 | @Safe() 50 | async loggedIn (req: IUserRequest, res: IResponse): Promise { 51 | res.locals.data = new User(req.user as Record); 52 | } 53 | 54 | /** 55 | * @description Creates and save new user 56 | * 57 | * @param req Express request object derived from http.incomingMessage 58 | * @param res Express response object 59 | */ 60 | @Safe() 61 | async create (req: IUserRequest, res: IResponse): Promise { 62 | const repository = ApplicationDataSource.getRepository(User); 63 | const user = new User(req.body); 64 | const savedUser = await repository.save(user); 65 | res.locals.data = savedUser; 66 | } 67 | 68 | /** 69 | * @description Update existing user 70 | * 71 | * @param req Express request object derived from http.incomingMessage 72 | * @param res Express response object 73 | */ 74 | @Safe() 75 | async update (req: IUserRequest, res: IResponse): Promise { 76 | const repository = ApplicationDataSource.getRepository(User); 77 | const user = await repository.findOneOrFail({ where: { id: req.params.userId } }); 78 | if (req.body.password && req.body.isUpdatePassword) { 79 | const pwdMatch = await user.passwordMatches(req.body.passwordToRevoke); 80 | if (!pwdMatch) { 81 | throw forbidden('Password to revoke does not match'); 82 | } 83 | } 84 | repository.merge(user, req.body); 85 | await repository.save(user); 86 | res.locals.data = user; 87 | } 88 | 89 | /** 90 | * @description Get user list 91 | * 92 | * @param req Express request object derived from http.incomingMessage 93 | * @param res Express response object 94 | */ 95 | @Safe() 96 | async list (req: IUserRequest, res: IResponse): Promise { 97 | const response = await UserRepository.list(req.query); 98 | res.locals.data = response.result; 99 | res.locals.meta = { 100 | total: response.total, 101 | pagination: paginate( parseInt(req.query.page, 10), parseInt(req.query.perPage, 10), response.total ) 102 | } 103 | } 104 | 105 | /** 106 | * @description Delete user 107 | * 108 | * @param req Express request object derived from http.incomingMessage 109 | * @param res Express response object 110 | */ 111 | @Safe() 112 | async remove (req: IUserRequest, res: IResponse): Promise { 113 | const repository = ApplicationDataSource.getRepository(User); 114 | const user = await repository.findOneOrFail({ where: { id: req.params.userId } }); 115 | 116 | if (!user) { 117 | throw notFound('User not found'); 118 | } 119 | 120 | void repository.remove(user); 121 | } 122 | } 123 | 124 | const userController = UserController.get(); 125 | 126 | export { userController as UserController } -------------------------------------------------------------------------------- /src/api/core/factories/error.factory.ts: -------------------------------------------------------------------------------- 1 | import { badImplementation } from '@hapi/boom'; 2 | import { MySQLError, NotFoundError, UploadError, ValidationError, ServerError, BusinessError } from '@errors'; 3 | import { IError, IHTTPError } from '@interfaces'; 4 | import { getErrorStatusCode } from '@utils/error.util'; 5 | 6 | /** 7 | * @description 8 | */ 9 | export class ErrorFactory { 10 | 11 | /** 12 | * @description 13 | * 14 | * @param error 15 | */ 16 | static get(error: IError): IHTTPError { 17 | 18 | // Custom errors first 19 | switch (error.name) { 20 | case 'QueryFailedError': 21 | return new MySQLError(error); 22 | case 'MulterError': 23 | return new UploadError(error); 24 | case 'EntityNotFound': 25 | case 'EntityNotFoundError': 26 | case 'MustBeEntityError': 27 | return new NotFoundError(error); 28 | case 'ValidationError': 29 | return new ValidationError(error); 30 | case 'BusinessError': 31 | return error as BusinessError; 32 | } 33 | 34 | // JS native errors ( Error | EvalError | RangeError | SyntaxError | TypeError | URIError ) 35 | if (!error.httpStatusCode && !error.statusCode && !error.status && !error?.output?.statusCode) { 36 | switch(error.constructor.name) { 37 | case 'Error': 38 | case 'EvalError': 39 | case 'TypeError': 40 | case 'SyntaxError': 41 | case 'RangeError': 42 | case 'URIError': 43 | return new ServerError(error); 44 | default: 45 | error = badImplementation(error.message) as IError; 46 | } 47 | } 48 | 49 | // Fallback with Boom error 50 | if (error.isBoom) { 51 | return { 52 | statusCode: getErrorStatusCode(error), 53 | statusText: error.output.payload.error, 54 | errors: [error.output.payload.message] 55 | }; 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /src/api/core/factories/refresh-token.factory.ts: -------------------------------------------------------------------------------- 1 | import * as Dayjs from 'dayjs'; 2 | 3 | import { randomBytes } from 'crypto'; 4 | import { User } from '@models/user.model'; 5 | import { RefreshToken } from '@models/refresh-token.model'; 6 | import { REFRESH_TOKEN } from '@config/environment.config'; 7 | 8 | /** 9 | * @description 10 | */ 11 | export class RefreshTokenFactory { 12 | 13 | /** 14 | * @description 15 | * 16 | * @param user 17 | */ 18 | static get(user: User): RefreshToken { 19 | const token = `${user.id}.${randomBytes(40).toString('hex')}`; 20 | const expires = Dayjs().add(REFRESH_TOKEN.DURATION, REFRESH_TOKEN.UNIT).toDate(); 21 | return new RefreshToken( token, user, expires ); 22 | } 23 | } -------------------------------------------------------------------------------- /src/api/core/middlewares/cache.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { CacheService } from '@services/cache.service'; 3 | 4 | /** 5 | * @description 6 | */ 7 | class Cache { 8 | 9 | /** 10 | * @description 11 | */ 12 | private static instance: Cache; 13 | 14 | private constructor() {} 15 | 16 | /** 17 | * @description 18 | */ 19 | static get(): Cache { 20 | if (!Cache.instance) { 21 | Cache.instance = new Cache(); 22 | } 23 | return Cache.instance; 24 | } 25 | 26 | /** 27 | * @description Request cache middleware 28 | * 29 | * @param req Express request 30 | * @param res Express response 31 | * @param next Middleware function 32 | */ 33 | read(req: Request, res: Response, next: NextFunction): void { 34 | if ( !CacheService.isCachable(req) ) { 35 | return next(); 36 | } 37 | const cached = CacheService.engine.get( CacheService.key(req) ) as unknown ; 38 | if (cached) { 39 | res.status(200); 40 | res.json(cached); 41 | return; 42 | } 43 | next(); 44 | } 45 | } 46 | 47 | const cache = Cache.get(); 48 | 49 | export { cache as Cache }; -------------------------------------------------------------------------------- /src/api/core/middlewares/catch.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { Logger } from '@services/logger.service'; 4 | import { ErrorFactory } from '@factories/error.factory'; 5 | import { IHTTPError, IRequest } from '@interfaces'; 6 | 7 | /** 8 | * Error catch/output middleware 9 | * 10 | * @dependency libnotify-bin 11 | * @dependency node-notifier 12 | * 13 | * @see https://www.npmjs.com/package/node-notifier 14 | */ 15 | class Catch { 16 | 17 | /** 18 | * @description 19 | */ 20 | private static instance: Catch; 21 | 22 | private constructor() {} 23 | 24 | /** 25 | * @description 26 | */ 27 | static get(): Catch { 28 | if (!Catch.instance) { 29 | Catch.instance = new Catch(); 30 | } 31 | return Catch.instance; 32 | } 33 | 34 | /** 35 | * @description 36 | * 37 | * @param err 38 | * @param req 39 | * @param res 40 | * @param next 41 | */ 42 | factory(err: Error, req: Request, res: Response, next: (e: IHTTPError, req, res) => void): void { 43 | next(ErrorFactory.get(err), req, res); 44 | } 45 | 46 | /** 47 | * @description Write errors in a log file 48 | * 49 | * @param err Error object 50 | * @param req Express request object derived from http.incomingMessage 51 | * @param res Express response object 52 | * @param next Callback function 53 | */ 54 | log(err: IHTTPError, req: IRequest, res: Response, next: (e: IHTTPError, req, res) => void): void { 55 | const { user } = req as { user: { id: number } }; 56 | if (err.statusCode >= 500) { 57 | Logger.log('error', `${req.headers['x-forwarded-for'] as string || req.connection.remoteAddress} HTTP/${req.httpVersion} ${err.statusCode} ${req.method} ${req.url} - ${err.message} (#${user ? user.id : 'unknown'}) : ${err.stack ? '\n ' + err.stack : ''} ${req.body ? '\n Payload :' + JSON.stringify(req.body) : ''}`); 58 | } else { 59 | Logger.log('error', `${req.headers['x-forwarded-for'] as string || req.connection.remoteAddress} HTTP/${req.httpVersion} ${err.statusCode} ${req.method} ${req.url} - ${err.statusText} (#${user ? user.id : 'unknown'}) : ${err.errors.slice().shift()} ${req.body ? '\n Payload :' + JSON.stringify(req.body) : ''}`); 60 | } 61 | next(err, req, res); 62 | } 63 | /** 64 | * @description Display clean error for final user 65 | * 66 | * @param err Error object 67 | * @param req Express request object derived from http.incomingMessage 68 | * @param res Express response object 69 | */ 70 | exit(err: IHTTPError, req: Request, res: Response, next: (e: Error, req, res) => void): void { 71 | res.status( err.statusCode ); 72 | res.json( { statusCode: err.statusCode, statusText: err.statusText, errors: err.errors } ); 73 | } 74 | 75 | /** 76 | * @description Display clean 404 error for final user 77 | * 78 | * @param req Express request object derived from http.incomingMessage 79 | * @param res Express response object 80 | */ 81 | notFound(req: Request, res: Response): void { 82 | res.status( 404 ); 83 | res.json( { statusCode: 404, statusText: 'Ooops... end point was not found', errors: ['Looks like someone\'s gone mushroom picking\''] } ); 84 | } 85 | 86 | } 87 | 88 | const catcher = Catch.get(); 89 | 90 | export { catcher as Catch } -------------------------------------------------------------------------------- /src/api/core/middlewares/cors.middleware.ts: -------------------------------------------------------------------------------- 1 | import { notAcceptable } from '@hapi/boom'; 2 | import { Request, Response } from 'express'; 3 | import { CONTENT_TYPE } from '@config/environment.config'; 4 | import { CONTENT_TYPE as CONTENT_TYPE_ENUM } from '@enums'; 5 | 6 | /** 7 | * @description 8 | */ 9 | class Cors { 10 | 11 | /** 12 | * @description 13 | */ 14 | private static instance: Cors; 15 | 16 | private constructor() {} 17 | 18 | /** 19 | * @description 20 | */ 21 | static get(): Cors { 22 | if (!Cors.instance) { 23 | Cors.instance = new Cors(); 24 | } 25 | return Cors.instance; 26 | } 27 | 28 | /** 29 | * @description Check header validity according to current request and current configuration requirements 30 | * 31 | * @param contentType Configuration content-type 32 | * 33 | * @param req Express request object derived from http.incomingMessage 34 | * @param res Express response object 35 | * @param next Callback function 36 | */ 37 | validate(req: Request, res: Response, next: (e?: Error) => void): void { 38 | 39 | if (req.method === 'OPTIONS') { 40 | res.writeHead(200, { 41 | 'Access-Control-Allow-Origin': '*', 42 | 'Access-Control-Allow-Headers': ['Content-Type', 'Authorization', 'Origin'], 43 | 'Access-Control-Allow-Methods': '*' 44 | }); 45 | res.end(); 46 | return ; 47 | } 48 | 49 | if (!req.headers['content-type']) { 50 | return next( notAcceptable(`Content-Type headers must be ${CONTENT_TYPE} or 'multipart/form-data', ${req.headers['content-type']} given`) ); 51 | } 52 | 53 | if ( CONTENT_TYPE_ENUM[CONTENT_TYPE] !== req.headers['content-type'] && req.headers['content-type'].lastIndexOf(CONTENT_TYPE_ENUM['multipart/form-data']) === -1 ) { 54 | return next( notAcceptable(`Content-Type head must be ${CONTENT_TYPE} or 'multipart/form-data, ${req.headers['content-type']} given`) ); 55 | } 56 | 57 | if (!req.headers.origin) { 58 | return next( notAcceptable('Origin header must be specified') ); 59 | } 60 | 61 | next(); 62 | } 63 | } 64 | 65 | const cors = Cors.get(); 66 | 67 | export { cors as Cors }; -------------------------------------------------------------------------------- /src/api/core/middlewares/guard.middleware.ts: -------------------------------------------------------------------------------- 1 | import * as passport from 'passport'; 2 | import { promisify } from 'es6-promisify'; 3 | import { forbidden, badRequest, notFound } from '@hapi/boom'; 4 | 5 | import { User } from '@models/user.model'; 6 | import { ROLE } from '@enums'; 7 | import { list } from '@utils/enum.util'; 8 | import { IUserRequest, IResponse } from '@interfaces'; 9 | 10 | import { OAuthProvider } from '@types'; 11 | 12 | /** 13 | * @description 14 | */ 15 | class Guard { 16 | 17 | /** 18 | * @description 19 | */ 20 | private static instance: Guard; 21 | 22 | private constructor() {} 23 | 24 | /** 25 | * @description 26 | */ 27 | static get(): Guard { 28 | if (!Guard.instance) { 29 | Guard.instance = new Guard(); 30 | } 31 | return Guard.instance; 32 | } 33 | 34 | /** 35 | * @description Callback function provided to passport.authenticate with JWT strategy 36 | * 37 | * @param req Express request object derived from http.incomingMessage 38 | * @param res Express response object 39 | * @param next Callback function 40 | * @param roles Authorized roles 41 | */ 42 | handleJWT = (req: IUserRequest, res: IResponse, next: (error?: Error) => void, roles: string|string[]) => async (err: Error, user: User, info: string): Promise => { 43 | const error = err || info; 44 | const logIn = promisify(req.logIn) as ( user, { session } ) => Promise; 45 | 46 | try { 47 | if (error || !user) throw error; 48 | await logIn(user, { session: false }); 49 | } catch (e) { 50 | const scopedError = e as Error; 51 | return next( forbidden(scopedError.message) ); 52 | } 53 | 54 | if (!roles.includes(user.role)) { 55 | return next( forbidden('Forbidden area') ); 56 | } else if (user.role as ROLE !== ROLE.admin && ( req.params.userId && parseInt(req.params.userId as string, 10) !== user.id ) ) { 57 | return next( forbidden('Forbidden area') ); 58 | } 59 | 60 | req.user = user; 61 | 62 | return next(); 63 | } 64 | 65 | /** 66 | * @description 67 | * 68 | * @param req 69 | * @param res 70 | * @param nex 71 | */ 72 | handleOauth = (req: IUserRequest, res: IResponse, next: (error?: Error) => void) => async (err: Error, user: User): Promise => { 73 | if (err) { 74 | return next( badRequest(err?.message) ); 75 | } else if (!user) { 76 | return next( notFound(err?.message) ); 77 | } else if (!list(ROLE).includes(user.role)) { 78 | return next( forbidden('Forbidden area') ); 79 | } 80 | req.user = user 81 | next(); 82 | } 83 | 84 | /** 85 | * @description 86 | * 87 | * @param req 88 | * @param res 89 | * @param next 90 | * @param roles Authorized roles 91 | * @param cb 92 | * 93 | * @dependency passport 94 | * @see http://www.passportjs.org/ 95 | */ 96 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access 97 | authentify = ( req: IUserRequest, res: IResponse, next: (error?: Error) => void, roles: ROLE|ROLE[], callback: (req: IUserRequest, res: IResponse, next: (error?: Error) => void, roles: string | string[]) => (err: Error, user: User, info: string) => Promise ) => passport.authenticate('jwt', { session: false }, callback(req, res, next, roles) ) (req, res, next) 98 | 99 | /** 100 | * @description 101 | * 102 | * @param req 103 | * @param res 104 | * @param next 105 | * @param service jwt | oAuth service provider 106 | * @param cb 107 | * 108 | * @dependency passport 109 | * @see http://www.passportjs.org/ 110 | */ 111 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access 112 | oAuthentify = ( req: IUserRequest, res: IResponse, next: (error?: Error) => void, service: 'jwt'|OAuthProvider, callback: (req: IUserRequest, res: IResponse, next: (error?: Error) => void) => (err: Error, user: User, info: string) => Promise ) => passport.authenticate(service, { session: false }, callback(req, res, next) ) (req, res, next) 113 | 114 | /** 115 | * @description Authorize user access according to role(s) in arguments 116 | * 117 | * @param roles 118 | */ 119 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 120 | authorize = ( roles: ROLE|ROLE[] ) => (req: IUserRequest, res: IResponse, next: (e?: Error) => void): void => this.authentify(req, res, next, roles, this.handleJWT ); 121 | 122 | /** 123 | * @description Authorize user access according to external service access_token 124 | * 125 | * @param service OAuthProvider 126 | */ 127 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access 128 | oAuth = (service: OAuthProvider) => passport.authenticate(service, { session: false }); 129 | 130 | /** 131 | * @description Authorize user access according to API rules 132 | * 133 | * @param service OAuthProvider 134 | */ 135 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 136 | oAuthCallback = (service: OAuthProvider) => (req: IUserRequest, res: IResponse, next: (e?: Error) => void): void => this.oAuthentify(req, res, next, service, this.handleOauth ); 137 | 138 | } 139 | 140 | const guard = Guard.get(); 141 | 142 | export { guard as Guard } -------------------------------------------------------------------------------- /src/api/core/middlewares/resolve.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, NextFunction } from 'express'; 2 | import { NOT_FOUND } from 'http-status'; 3 | import { expectationFailed } from '@hapi/boom'; 4 | 5 | import { IResponse } from '@interfaces'; 6 | import { getStatusCode } from '@utils/http.util'; 7 | import { CacheService } from '@services/cache.service'; 8 | 9 | /** 10 | * @description 11 | */ 12 | class Resolve { 13 | 14 | /** 15 | * @description 16 | */ 17 | private static instance: Resolve; 18 | 19 | private constructor() {} 20 | 21 | /** 22 | * @description 23 | */ 24 | static get(): Resolve { 25 | if (!Resolve.instance) { 26 | Resolve.instance = new Resolve(); 27 | } 28 | return Resolve.instance; 29 | } 30 | 31 | /** 32 | * @description Resolve the current request and get output. The princip is that we becomes here, it means that none error has been encountered except a potential and non declared as is 404 error 33 | * 34 | * @param req Express request object derived from http.incomingMessage 35 | * @param res Express response object 36 | * @param next Callback function 37 | */ 38 | write(req: Request, res: IResponse, next: NextFunction): void { 39 | 40 | const hasContent = typeof res.locals?.data !== 'undefined'; 41 | const hasNullContent = res.locals.data === null; 42 | const hasStatusCodeOnResponse = typeof res.statusCode !== 'undefined'; 43 | const status = getStatusCode(req.method, ( hasContent && !hasNullContent )); 44 | 45 | if ( req.method === 'DELETE' ) { 46 | // As trick but necessary because if express don't match route we can't give a 204/404 on DELETE 47 | if ( isNaN( parseInt(req.url.split('/').pop(), 10) )) { 48 | return next( expectationFailed('ID parameter must be a number') ); 49 | } 50 | res.status( status ) 51 | res.end(); 52 | return; 53 | } 54 | 55 | // The door for 404 candidates 56 | if ( !hasContent ) { 57 | next(); 58 | } 59 | 60 | // The end for the rest 61 | if ( ( hasContent && ['GET', 'POST', 'PUT', 'PATCH'].includes(req.method) ) || ( hasStatusCodeOnResponse && res.statusCode !== NOT_FOUND ) ) { 62 | if ( CacheService.isCachable(req) ) { 63 | CacheService.engine.put( CacheService.key(req), res.locals.data, CacheService.duration ); 64 | } 65 | res.status( status ); 66 | if (res.locals.meta) { 67 | res.json(res.locals); 68 | } else { 69 | res.json(res.locals.data); 70 | } 71 | } 72 | } 73 | } 74 | 75 | const resolve = Resolve.get(); 76 | 77 | export { resolve as Resolve }; -------------------------------------------------------------------------------- /src/api/core/middlewares/sanitize.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request } from 'express'; 2 | 3 | import { CONTENT_TYPE } from '@config/environment.config'; 4 | import { CONTENT_TYPE as CONTENT_TYPE_ENUM } from '@enums'; 5 | import { IResponse } from '@interfaces'; 6 | 7 | import { SanitizeService } from '@services/sanitizer.service'; 8 | 9 | /** 10 | * @description 11 | */ 12 | class Sanitize { 13 | 14 | /** 15 | * @description 16 | */ 17 | private static instance: Sanitize; 18 | 19 | private constructor() {} 20 | 21 | /** 22 | * @description 23 | */ 24 | static get(): Sanitize { 25 | if (!Sanitize.instance) { 26 | Sanitize.instance = new Sanitize(); 27 | } 28 | return Sanitize.instance; 29 | } 30 | 31 | /** 32 | * @description Clean current data before output if the context requires it 33 | * 34 | * @param req Express Request instance 35 | * @param res Express Response instance 36 | * @param next Callback function 37 | */ 38 | sanitize(req: Request, res: IResponse, next: NextFunction): void { 39 | const hasContent = typeof res.locals.data !== 'undefined'; 40 | 41 | if (req.method === 'DELETE' || CONTENT_TYPE !== CONTENT_TYPE_ENUM['application/json'].toString() || !hasContent) { 42 | return next(); 43 | } 44 | 45 | if ( res.locals.data === null || !SanitizeService.hasEligibleMember(res.locals.data) ) { 46 | return next(); 47 | } 48 | 49 | res.locals.data = SanitizeService.process(res.locals.data); 50 | 51 | next(); 52 | } 53 | } 54 | 55 | const sanitize = Sanitize.get(); 56 | 57 | export { sanitize as Sanitize }; -------------------------------------------------------------------------------- /src/api/core/middlewares/uploader.middleware.ts: -------------------------------------------------------------------------------- 1 | import { MulterError } from 'multer'; 2 | 3 | import { UploadConfiguration } from '@config/upload.config' 4 | import { Media } from '@models/media.model'; 5 | import { IMediaRequest, IResponse, IUploadOptions, IMedia } from '@interfaces'; 6 | 7 | /** 8 | * @description 9 | */ 10 | class Uploader { 11 | 12 | /** 13 | * @description 14 | */ 15 | private static instance: Uploader; 16 | 17 | /** 18 | * @description 19 | */ 20 | private options: IUploadOptions; 21 | 22 | private constructor(options: IUploadOptions) { 23 | this.options = options; 24 | } 25 | 26 | /** 27 | * @description 28 | */ 29 | static get(options: IUploadOptions): Uploader { 30 | if (!Uploader.instance) { 31 | Uploader.instance = new Uploader(options); 32 | } 33 | return Uploader.instance; 34 | } 35 | 36 | /** 37 | * @description Uploader file(s) middleware 38 | * 39 | * @param options Uploader parameters (destination, maxFileSize, wildcards) 40 | * 41 | * @param req Express request object derived from http.incomingMessage 42 | * @param res Express response object 43 | * @param next Callback function 44 | * 45 | * @fixme many files with the same fieldname is not managed if not media route 46 | */ 47 | upload = ( options?: IUploadOptions ) => (req: IMediaRequest, res: IResponse, next: (error?: Error) => void): void => { 48 | 49 | this.options = options ? Object.keys(options) 50 | .filter(key => this.options[key]) 51 | .reduce((acc: IUploadOptions, current: string) => { 52 | acc[current] = options[current] as string|number|Record; 53 | return acc; 54 | }, this.options) : this.options; 55 | 56 | if (!req || !res || !this.options) { 57 | return next(new Error('Middleware requirements not found')) 58 | } 59 | 60 | const middleware = UploadConfiguration.engine( UploadConfiguration.configuration(this.options) ).any(); 61 | 62 | middleware(req, res, (err: Error) => { 63 | if(err) { 64 | return next(err instanceof MulterError ? err : new MulterError(err.message) as Error); 65 | } else if (typeof req.files === 'undefined') { 66 | if (req.url.includes('medias')) { 67 | return next(new Error('Binary data cannot be found')); 68 | } 69 | return next(); 70 | } 71 | 72 | if (req.baseUrl.includes('medias')) { 73 | Object.keys(req.body) 74 | .filter(key => key !== 'files') 75 | .forEach(key => { 76 | delete req.body[key]; 77 | }); 78 | req.body.files = req.files 79 | .slice(0, this.options.maxFiles) 80 | .map( ( media: IMedia ) => { 81 | media.owner = req.user.id; 82 | delete media.originalname; 83 | delete media.encoding; 84 | delete media.destination; 85 | return media; 86 | }) || []; 87 | } else { 88 | req.files 89 | .reduce((acc, current) => { 90 | if (!acc.includes(current.fieldname)) { 91 | acc.push(current.fieldname); 92 | } 93 | return acc; 94 | }, [] as string[]) 95 | .forEach(field => { 96 | const media = req.files.find(file => file.fieldname = field); 97 | req.body[field] = new Media({ 98 | fieldname: media.fieldname, 99 | filename: media.filename, 100 | owner: req.user.id, 101 | mimetype: media.mimetype, 102 | size: media.size, 103 | path: media.path 104 | }); 105 | }); 106 | } 107 | next(); 108 | }); 109 | } 110 | 111 | } 112 | 113 | const upload = Uploader.get(UploadConfiguration.options); 114 | 115 | export { upload as Uploader }; -------------------------------------------------------------------------------- /src/api/core/middlewares/validator.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { ObjectSchema } from 'joi'; 3 | 4 | /** 5 | * @description 6 | */ 7 | class Validator { 8 | 9 | /** 10 | * @description 11 | */ 12 | private static instance: Validator; 13 | 14 | private constructor() {} 15 | 16 | /** 17 | * @description 18 | */ 19 | static get(): Validator { 20 | if (!Validator.instance) { 21 | Validator.instance = new Validator(); 22 | } 23 | return Validator.instance; 24 | } 25 | 26 | /** 27 | * @description Custom validation middleware using Joi 28 | */ 29 | check = ( schema: Record ) => ( req: Request, res: Response, next: (e?: Error) => void ) : void => { 30 | 31 | const error = ['query', 'body', 'params'] 32 | .filter( (property: string) => schema[property] && req[property]) 33 | .map( (property: string): { error: any } => schema[property].validate(req[property], { abortEarly: true, allowUnknown: false } ) as { error: any }) 34 | .filter(result => result.error) 35 | .map(result => result.error as Error) 36 | .slice() 37 | .shift(); 38 | 39 | if (error) { 40 | return next(error) 41 | } 42 | 43 | next() 44 | } 45 | } 46 | 47 | const validator = Validator.get(); 48 | 49 | export { validator as Validator }; -------------------------------------------------------------------------------- /src/api/core/models/media.model.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import * as Dayjs from 'dayjs'; 4 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; 5 | 6 | import { MIME_TYPE, FIELDNAME } from '@enums'; 7 | import { User } from '@models/user.model'; 8 | import { IModel } from '@interfaces'; 9 | 10 | import { MimeType, Fieldname } from '@types'; 11 | 12 | @Entity() 13 | export class Media implements IModel { 14 | 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column({ 19 | type: 'enum', 20 | enum: FIELDNAME 21 | }) 22 | fieldname: Fieldname; 23 | 24 | @Column({ 25 | type: String, 26 | length: 128 27 | }) 28 | filename; 29 | 30 | @Column() 31 | path: string; 32 | 33 | @Column({ 34 | type: 'enum', 35 | enum: MIME_TYPE 36 | }) 37 | mimetype: MimeType 38 | 39 | @Column({ 40 | type: Number 41 | }) 42 | size; 43 | 44 | @ManyToOne(type => User, user => user.medias, { 45 | onDelete: 'CASCADE' // Remove all documents when user is deleted 46 | }) 47 | owner: User; 48 | 49 | @Column({ 50 | type: Date, 51 | default: Dayjs( new Date() ).format('YYYY-MM-DD HH:ss') 52 | }) 53 | createdAt; 54 | 55 | @Column({ 56 | type: Date, 57 | default: null 58 | }) 59 | updatedAt; 60 | 61 | @Column({ 62 | type: Date, 63 | default: null 64 | }) 65 | deletedAt; 66 | 67 | /** 68 | * @param payload Object data to assign 69 | */ 70 | constructor(payload: Record) { 71 | Object.assign(this, payload); 72 | } 73 | 74 | /** 75 | * @description Filter on allowed entity fields 76 | */ 77 | get whitelist(): string[] { 78 | return [ 79 | 'id', 80 | 'fieldname', 81 | 'filename', 82 | 'mimetype', 83 | 'size', 84 | 'owner', 85 | 'createdAt', 86 | 'updatedAt' 87 | ]; 88 | } 89 | 90 | } -------------------------------------------------------------------------------- /src/api/core/models/refresh-token.model.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm'; 4 | import { User } from '@models/user.model'; 5 | 6 | @Entity() 7 | export class RefreshToken { 8 | 9 | @PrimaryGeneratedColumn() 10 | id: number; 11 | 12 | @Column() 13 | token: string; 14 | 15 | @OneToOne(type => User, { 16 | eager : true, 17 | onDelete: 'CASCADE' // Remove refresh-token when user is deleted 18 | }) 19 | @JoinColumn() 20 | user: User; 21 | 22 | @Column() 23 | expires: Date; 24 | 25 | /** 26 | * 27 | * @param token 28 | * @param user 29 | * @param expires 30 | */ 31 | constructor(token: string, user: User, expires: Date) { 32 | this.token = token; 33 | this.expires = expires; 34 | this.user = user; 35 | } 36 | } -------------------------------------------------------------------------------- /src/api/core/models/user.model.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import * as Dayjs from 'dayjs'; 4 | import * as Jwt from 'jwt-simple'; 5 | import * as Bcrypt from 'bcrypt'; 6 | import { Entity, PrimaryGeneratedColumn, Column, BeforeUpdate, AfterLoad, BeforeInsert, OneToMany, OneToOne, JoinColumn } from 'typeorm'; 7 | import { badImplementation } from '@hapi/boom'; 8 | 9 | import { ACCESS_TOKEN } from '@config/environment.config'; 10 | import { ROLE, STATUS } from '@enums'; 11 | import { Role, Status } from '@types'; 12 | import { Media } from '@models/media.model'; 13 | import { IModel } from '@interfaces'; 14 | 15 | @Entity() 16 | export class User implements IModel { 17 | 18 | @PrimaryGeneratedColumn() 19 | id: number; 20 | 21 | @Column({ 22 | length: 32, 23 | unique: true 24 | }) 25 | username: string; 26 | 27 | @OneToOne(() => Media, { nullable: true }) 28 | @JoinColumn() 29 | avatar: Media; 30 | 31 | @Column({ 32 | length: 128, 33 | unique: true 34 | }) 35 | email: string; 36 | 37 | @Column({ 38 | type: 'enum', 39 | enum: STATUS, 40 | default: STATUS.REGISTERED 41 | }) 42 | status: Status; 43 | 44 | @Column({ 45 | length: 128 46 | }) 47 | password: string; 48 | 49 | @Column({ 50 | length: 128, 51 | unique: true 52 | }) 53 | apikey: string; 54 | 55 | @Column({ 56 | type: 'enum', 57 | enum: ROLE, 58 | default: ROLE.user 59 | }) 60 | role: Role 61 | 62 | @OneToMany( () => Media, media => media.owner, { 63 | eager: true 64 | }) 65 | medias: Media[]; 66 | 67 | @Column({ 68 | type: Date, 69 | default: Dayjs( new Date() ).format('YYYY-MM-DD HH:ss') 70 | }) 71 | createdAt; 72 | 73 | @Column({ 74 | type: Date, 75 | default: null 76 | }) 77 | updatedAt; 78 | 79 | @Column({ 80 | type: Date, 81 | default: null 82 | }) 83 | deletedAt; 84 | 85 | /** 86 | * @description 87 | */ 88 | private temporaryPassword; 89 | 90 | /** 91 | * @param payload Object data to assign 92 | */ 93 | constructor(payload: Record) { 94 | Object.assign(this, payload); 95 | } 96 | 97 | /** 98 | * @description Filter on allowed entity fields 99 | */ 100 | get whitelist(): string[] { 101 | return [ 102 | 'id', 103 | 'username', 104 | 'avatar', 105 | 'email', 106 | 'role', 107 | 'createdAt' , 108 | 'updatedAt' 109 | ] 110 | } 111 | 112 | @AfterLoad() 113 | storeTemporaryPassword() : void { 114 | this.temporaryPassword = this.password; 115 | } 116 | 117 | @BeforeInsert() 118 | @BeforeUpdate() 119 | async hashPassword(): Promise { 120 | try { 121 | if (this.temporaryPassword === this.password) { 122 | return true; 123 | } 124 | this.password = await Bcrypt.hash(this.password, 10); 125 | return true; 126 | } catch (error) { 127 | throw badImplementation(); 128 | } 129 | } 130 | 131 | /** 132 | * @description Check that password matches 133 | * 134 | * @param password 135 | */ 136 | async passwordMatches(password: string): Promise { 137 | return await Bcrypt.compare(password, this.password); 138 | } 139 | 140 | /** 141 | * @description Generate JWT access token 142 | */ 143 | token(duration: number = null): string { 144 | const payload = { 145 | exp: Dayjs().add(duration || ACCESS_TOKEN.DURATION, 'minutes').unix(), 146 | iat: Dayjs().unix(), 147 | sub: this.id 148 | }; 149 | return Jwt.encode(payload, ACCESS_TOKEN.SECRET); 150 | } 151 | } -------------------------------------------------------------------------------- /src/api/core/repositories/media.repository.ts: -------------------------------------------------------------------------------- 1 | import { omitBy, isNil } from 'lodash'; 2 | 3 | import { Media } from '@models/media.model'; 4 | import { IMediaQueryString } from '@interfaces'; 5 | import { getMimeTypesOfType } from '@utils/string.util'; 6 | import { ApplicationDataSource } from '@config/database.config'; 7 | 8 | export const MediaRepository = ApplicationDataSource.getRepository(Media).extend({ 9 | 10 | list: async ({ page = 1, perPage = 30, path, fieldname, filename, size, mimetype, owner, type }: IMediaQueryString): Promise<{result: Media[], total: number}> => { 11 | const repository = ApplicationDataSource.getRepository(Media); 12 | 13 | const options = omitBy({ path, fieldname, filename, size, mimetype, owner, type }, isNil) as IMediaQueryString; 14 | 15 | const query = repository 16 | .createQueryBuilder('media') 17 | .leftJoinAndSelect('media.owner', 'u'); 18 | 19 | if(options.fieldname) { 20 | query.andWhere('fieldname = :fieldname', { fieldname: options.fieldname }) 21 | } 22 | 23 | if(options.filename) { 24 | query.andWhere('filename LIKE :filename', { filename: `%${options.filename}%` }); 25 | } 26 | 27 | if(options.mimetype) { 28 | query.andWhere('mimetype LIKE :mimetype', { mimetype: `%${options.mimetype}%` }); 29 | } 30 | 31 | if(options.type) { 32 | query.andWhere('mimetype IN (:mimetypes)', { mimetypes: getMimeTypesOfType(options.type) }); 33 | } 34 | 35 | if(options.size) { 36 | query.andWhere('size >= :size', { size: `%${options.size}%` }); 37 | } 38 | 39 | const [ result, total ] = await query 40 | .skip( ( parseInt(page as string, 10) - 1 ) * parseInt(perPage as string, 10) ) 41 | .take( parseInt(perPage as string, 10) ) 42 | .getManyAndCount(); 43 | 44 | return { result, total } 45 | } 46 | }); -------------------------------------------------------------------------------- /src/api/core/repositories/refresh-token.repository.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@models/user.model'; 2 | import { RefreshToken } from '@models/refresh-token.model'; 3 | import { RefreshTokenFactory } from '@factories/refresh-token.factory'; 4 | import { ApplicationDataSource } from '@config/database.config'; 5 | 6 | export const RefreshTokenRepository = ApplicationDataSource.getRepository(RefreshToken).extend({ 7 | /** 8 | * @description Generate a new refresh token 9 | * 10 | * @param user 11 | */ 12 | generate: (user: User): RefreshToken => { 13 | const refreshToken = RefreshTokenFactory.get(user); 14 | void ApplicationDataSource.getRepository(RefreshToken).save(refreshToken); 15 | return refreshToken; 16 | } 17 | }); -------------------------------------------------------------------------------- /src/api/core/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | import * as Dayjs from 'dayjs'; 2 | 3 | import { omitBy, isNil } from 'lodash'; 4 | import { badRequest, notFound, unauthorized } from '@hapi/boom'; 5 | 6 | import { User } from '@models/user.model'; 7 | import { IRegistrable, ITokenOptions, IUserQueryString } from '@interfaces'; 8 | import { ApplicationDataSource } from '@config/database.config'; 9 | 10 | export const UserRepository = ApplicationDataSource.getRepository(User).extend({ 11 | /** 12 | * @description Get one user 13 | * 14 | * @param id - The id of user 15 | * 16 | */ 17 | one: async (id: number): Promise => { 18 | 19 | const repository = ApplicationDataSource.getRepository(User); 20 | const options: { id: number } = omitBy({ id }, isNil) as { id: number }; 21 | 22 | const user = await repository.findOne({ 23 | where: options 24 | }); 25 | 26 | if (!user) { 27 | throw notFound('User not found'); 28 | } 29 | 30 | return user; 31 | }, 32 | 33 | /** 34 | * @description Get a list of users according to current query parameters 35 | */ 36 | list: async ({ page = 1, perPage = 30, username, email, role, status }: IUserQueryString): Promise<{result: User[], total: number}> => { 37 | 38 | const repository = ApplicationDataSource.getRepository(User); 39 | const options = omitBy({ username, email, role, status }, isNil) as IUserQueryString; 40 | 41 | const query = repository 42 | .createQueryBuilder('user') 43 | .leftJoinAndSelect('user.medias', 'd'); 44 | 45 | if(options.username) { 46 | query.andWhere('user.username = :username', { username }); 47 | } 48 | 49 | if(options.email) { 50 | query.andWhere('email = :email', { email }); 51 | } 52 | 53 | if(options.role){ 54 | query.andWhere('role = :role', { role }); 55 | } 56 | 57 | if(options.status){ 58 | query.andWhere('status = :status', { status }); 59 | } 60 | 61 | const [ result, total ] = await query 62 | .skip( ( parseInt(page as string, 10) - 1 ) * parseInt(perPage as string, 10) ) 63 | .take( parseInt(perPage as string, 10) ) 64 | .getManyAndCount(); 65 | 66 | return { result, total }; 67 | }, 68 | 69 | /** 70 | * @description Find user by email and try to generate a JWT token 71 | * 72 | * @param options Payload data 73 | */ 74 | findAndGenerateToken: async (options: ITokenOptions): Promise<{user: User, accessToken: string}> => { 75 | 76 | const { email, password, refreshToken, apikey } = options; 77 | 78 | if (!email && !apikey) { 79 | throw badRequest('An email or an API key is required to generate a token') 80 | } 81 | 82 | const user = await ApplicationDataSource.getRepository(User).findOne({ 83 | where : email ? { email } : { apikey } 84 | }); 85 | 86 | if (!user) { 87 | throw notFound('User not found'); 88 | } else if (password && await user.passwordMatches(password) === false) { 89 | throw unauthorized('Password must match to authorize a token generating'); 90 | } else if (refreshToken && refreshToken.user.email === email && Dayjs(refreshToken.expires).isBefore( Dayjs() )) { 91 | throw unauthorized('Invalid refresh token'); 92 | } 93 | 94 | return { user, accessToken: user.token() }; 95 | }, 96 | 97 | /** 98 | * @description Create / save user for oauth connexion 99 | * 100 | * @param options 101 | * 102 | * @fixme user should always retrieved from her email address. If not, possible collision on username value 103 | */ 104 | oAuthLogin: async (options: IRegistrable): Promise => { 105 | 106 | const { email, username, password } = options; 107 | 108 | const userRepository = ApplicationDataSource.getRepository(User); 109 | 110 | let user = await userRepository.findOne({ 111 | where: [ { email }, { username } ], 112 | }); 113 | 114 | if (user) { 115 | if (!user.username) { 116 | user.username = username; 117 | await userRepository.save(user) 118 | } 119 | if (user.email.includes('externalprovider') && !email.includes('externalprovider')) { 120 | user.email = email; 121 | await userRepository.save(user) 122 | } 123 | return user; 124 | } 125 | 126 | user = userRepository.create({ email, password, username }); 127 | user = await userRepository.save(user); 128 | 129 | return user; 130 | } 131 | }); -------------------------------------------------------------------------------- /src/api/core/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import * as Dayjs from 'dayjs'; 2 | 3 | import { badData } from '@hapi/boom'; 4 | 5 | import { ACCESS_TOKEN } from '@config/environment.config'; 6 | 7 | import { UserRepository } from '@repositories/user.repository'; 8 | import { RefreshTokenRepository } from '@repositories/refresh-token.repository'; 9 | 10 | import { User } from '@models/user.model'; 11 | import { RefreshToken } from '@models/refresh-token.model'; 12 | 13 | import { IOauthResponse } from '@interfaces'; 14 | 15 | import { hash } from '@utils/string.util'; 16 | import { ApplicationDataSource } from '@config/database.config'; 17 | 18 | /** 19 | * @description 20 | */ 21 | class AuthService { 22 | 23 | /** 24 | * @description 25 | */ 26 | private static instance: AuthService; 27 | 28 | private constructor() {} 29 | 30 | /** 31 | * @description 32 | */ 33 | static get(): AuthService { 34 | if (!AuthService.instance) { 35 | AuthService.instance = new AuthService(); 36 | } 37 | return AuthService.instance; 38 | } 39 | 40 | /** 41 | * @description Build a token response and return it 42 | * 43 | * @param user 44 | * @param accessToken 45 | */ 46 | async generateTokenResponse(user: User, accessToken: string): Promise<{ tokenType, accessToken, refreshToken, expiresIn }|Error> { 47 | if (!user || !(user instanceof User) || !user.id) { 48 | return badData('User is not an instance of User'); 49 | } 50 | if (!accessToken) { 51 | return badData('Access token cannot be retrieved'); 52 | } 53 | const tokenType = 'Bearer'; 54 | const oldToken = await ApplicationDataSource.getRepository(RefreshToken).findOne({ where : { user: { id: user.id } } }); 55 | if (oldToken) { 56 | await ApplicationDataSource.getRepository(RefreshToken).remove(oldToken) 57 | } 58 | const refreshToken = RefreshTokenRepository.generate(user).token; 59 | const expiresIn = Dayjs().add(ACCESS_TOKEN.DURATION, 'minutes'); 60 | return { tokenType, accessToken, refreshToken, expiresIn }; 61 | } 62 | 63 | /** 64 | * @description Revoke a refresh token 65 | * 66 | * @param user 67 | */ 68 | async revokeRefreshToken(user: User): Promise { 69 | 70 | if (!user || !(user instanceof User) || !user.id) { 71 | return badData('User is not an instance of User'); 72 | } 73 | 74 | const oldToken = await ApplicationDataSource.getRepository(RefreshToken).findOne({ where : { user: { id: user.id } } }); 75 | 76 | if (oldToken) { 77 | await ApplicationDataSource.getRepository(RefreshToken).remove(oldToken) 78 | } 79 | } 80 | 81 | /** 82 | * @description Authentication by oAuth processing 83 | * 84 | * @param token Access token of provider 85 | * @param refreshToken Refresh token of provider 86 | * @param profile Shared profile information 87 | * @param next Callback function 88 | * 89 | * @async 90 | */ 91 | async oAuth(token: string, refreshToken: string, profile: IOauthResponse, next: (e?: Error, v?: User|boolean) => void): Promise { 92 | try { 93 | const email = profile.emails ? profile.emails.filter(mail => ( mail.hasOwnProperty('verified') && mail.verified ) || !mail.hasOwnProperty('verified') ).slice().shift().value : `${profile.name.givenName.toLowerCase()}${profile.name.familyName.toLowerCase()}@externalprovider.com`; 94 | const iRegistrable = { 95 | id: profile.id, 96 | username: profile.username ? profile.username : `${profile.name.givenName.toLowerCase()}${profile.name.familyName.toLowerCase()}`, 97 | email, 98 | picture: profile.photos.slice().shift()?.value, 99 | password: hash(email, 16) 100 | } 101 | const user = await UserRepository.oAuthLogin(iRegistrable); 102 | return next(null, user); 103 | } catch (err) { 104 | return next(err as Error, false); 105 | } 106 | } 107 | 108 | /** 109 | * @description Authentication by JWT middleware function 110 | * 111 | * @async 112 | */ 113 | async jwt(payload: { sub }, next: (e?: Error, v?: User|boolean) => void): Promise { 114 | try { 115 | const userRepository = ApplicationDataSource.getRepository(User); 116 | const user = await userRepository.findOne( { where: { id: payload.sub } }); 117 | if (user) { 118 | return next(null, user); 119 | } 120 | return next(null, false); 121 | } catch (err) { 122 | return next(err as Error, false); 123 | } 124 | } 125 | } 126 | 127 | const authService = AuthService.get(); 128 | 129 | export { authService as AuthService } -------------------------------------------------------------------------------- /src/api/core/services/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | import { ICache } from '@interfaces'; 4 | 5 | import { CacheConfiguration } from '@config/cache.config'; 6 | import { encrypt } from '@utils/string.util'; 7 | 8 | /** 9 | * @description Cache service interface with memory cache module 10 | */ 11 | class CacheService { 12 | 13 | /** 14 | * @description 15 | */ 16 | private static instance: CacheService; 17 | 18 | private constructor() {} 19 | 20 | /** 21 | * @description 22 | */ 23 | get engine(): ICache { 24 | return CacheConfiguration.start 25 | } 26 | 27 | /** 28 | * @description 29 | */ 30 | get duration(): number { 31 | return CacheConfiguration.options.DURATION; 32 | } 33 | 34 | /** 35 | * @description 36 | */ 37 | get isActive(): boolean { 38 | return CacheConfiguration.options.IS_ACTIVE; 39 | } 40 | 41 | static get(): CacheService { 42 | if (!CacheService.instance) { 43 | CacheService.instance = new CacheService(); 44 | } 45 | return CacheService.instance; 46 | } 47 | 48 | /** 49 | * @description 50 | * 51 | * @param req Express request 52 | */ 53 | key(req: Request): string { 54 | let queryParams = ''; 55 | if (req.query) { 56 | queryParams = encrypt(Object.keys(req.query).sort().map(key => req.query[key]).join('')); 57 | } 58 | return `${CacheConfiguration.key}${req.baseUrl}${req.path}?q=${queryParams}`; 59 | } 60 | 61 | /** 62 | * @description 63 | * 64 | * @param req 65 | */ 66 | isCachable(req: Request): boolean { 67 | return CacheConfiguration.options.IS_ACTIVE && req.method === 'GET'; 68 | } 69 | 70 | /** 71 | * @description Refresh cache after insert / update 72 | * 73 | * @param segment 74 | */ 75 | refresh(segment: string): void|boolean { 76 | if (!CacheConfiguration.options.IS_ACTIVE) { 77 | return false; 78 | } 79 | this.engine 80 | .keys() 81 | .slice() 82 | .filter(key => key.includes(segment)) 83 | .forEach(key => this.engine.del(key)) 84 | } 85 | 86 | } 87 | 88 | const cacheService = CacheService.get(); 89 | 90 | export { cacheService as CacheService } -------------------------------------------------------------------------------- /src/api/core/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { LoggerConfiguration } from '@config/logger.config'; 2 | import { Logger as WinstonLogger } from 'winston'; 3 | 4 | /** 5 | * Log service 6 | */ 7 | class Logger { 8 | 9 | /** 10 | * @description Wrapped logger instance, here winston 11 | */ 12 | private static instance: Logger; 13 | 14 | /** 15 | * @description 16 | */ 17 | engine: WinstonLogger; 18 | 19 | private constructor(engine: WinstonLogger) { 20 | this.engine = engine; 21 | } 22 | 23 | static get(engine: WinstonLogger): Logger { 24 | if ( !Logger.instance ) { 25 | Logger.instance = new Logger(engine) 26 | } 27 | return Logger.instance; 28 | } 29 | 30 | /** 31 | * @description Do log action 32 | * 33 | * @param level 34 | * @param message 35 | */ 36 | log(level: string, message: string ): void { 37 | this.engine[level](message); 38 | } 39 | } 40 | 41 | const logger = Logger.get( LoggerConfiguration.logger ); 42 | 43 | export { logger as Logger } -------------------------------------------------------------------------------- /src/api/core/services/media.service.ts: -------------------------------------------------------------------------------- 1 | import { Jimp } from 'jimp'; 2 | 3 | import { unlink, existsSync } from 'fs'; 4 | import { promisify } from 'es6-promisify'; 5 | import { expectationFailed } from '@hapi/boom'; 6 | 7 | import { SCALING } from '@config/environment.config'; 8 | 9 | import { Media } from '@models/media.model'; 10 | import { IMAGE_MIME_TYPE } from '@enums'; 11 | import { EnvImageScaling } from '@types' 12 | 13 | /** 14 | * @description 15 | */ 16 | class MediaService { 17 | 18 | /** 19 | * @description 20 | */ 21 | private static instance: MediaService; 22 | 23 | /** 24 | * @description 25 | */ 26 | private readonly OPTIONS: EnvImageScaling; 27 | 28 | /** 29 | * @description 30 | */ 31 | private readonly SIZES: string[]; 32 | 33 | private constructor(config: EnvImageScaling) { 34 | this.OPTIONS = config; 35 | this.SIZES = Object.keys(this.OPTIONS.SIZES).map(key => key.toLowerCase()) 36 | } 37 | 38 | /** 39 | * @description 40 | */ 41 | static get(config: any): MediaService { 42 | if (!MediaService.instance) { 43 | MediaService.instance = new MediaService(config as EnvImageScaling); 44 | } 45 | return MediaService.instance; 46 | } 47 | 48 | /** 49 | * @description 50 | * 51 | * @param media 52 | */ 53 | rescale(media: Media): void|boolean { 54 | if (!this.OPTIONS.IS_ACTIVE) { 55 | return false; 56 | } 57 | void Jimp.read(media.path) 58 | .then( (image) => { 59 | this.SIZES 60 | .forEach( size => { 61 | const path = `${media.path.split('/').slice(0, -1).join('/').replace(this.OPTIONS.PATH_MASTER, this.OPTIONS.PATH_SCALE)}/${size}/${media.filename}.jpg`; 62 | void image 63 | .clone() 64 | .resize({ w: this.OPTIONS.SIZES[size.toUpperCase() as keyof typeof this.OPTIONS.SIZES] }) 65 | .write(path as `${string}.${string}`) 66 | .catch(error => console.error(`Error resizing image for size ${size}:`, error)); 67 | }); 68 | }) 69 | .catch(); 70 | } 71 | 72 | /** 73 | * @description 74 | * 75 | * @param media 76 | */ 77 | remove (media: Media): void { 78 | const ulink = promisify(unlink) as (path: string) => Promise; 79 | if ( !IMAGE_MIME_TYPE[media.mimetype] && existsSync(media.path.toString()) ) { 80 | void ulink(media.path.toString()); 81 | } else { 82 | const promises = this.SIZES 83 | .map( size => media.path.toString().replace(`${this.OPTIONS.PATH_MASTER}/${media.fieldname}`, `${this.OPTIONS.PATH_SCALE}/${media.fieldname}/${size}`) ) 84 | .filter( path => existsSync(path) ) 85 | .map( path => ulink(path) ); 86 | void Promise.all( [ existsSync(media.path.toString()) ? ulink( media.path.toString() ) : Promise.resolve() ].concat( promises ) ); 87 | } 88 | } 89 | 90 | } 91 | 92 | const mediaService = MediaService.get(SCALING); 93 | 94 | export { mediaService as MediaService } -------------------------------------------------------------------------------- /src/api/core/services/proxy-router.service.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { IRoute } from '@interfaces'; 4 | 5 | import { MainRouter } from '@routes/main.route'; 6 | import { AuthRouter } from '@routes/auth.route'; 7 | import { MediaRouter } from '@routes/media.route'; 8 | import { UserRouter } from '@routes/user.route'; 9 | 10 | /** 11 | * Load all application routes and plug it on main router 12 | */ 13 | class ProxyRouter { 14 | 15 | /** 16 | * @description Wrapper Express.Router 17 | */ 18 | private static instance: ProxyRouter; 19 | 20 | /** 21 | * @decription 22 | */ 23 | private router: Router = Router(); 24 | 25 | /** 26 | * @description Routes descriptions 27 | */ 28 | private readonly routes: Array<{ segment: string, provider: any }> = [ 29 | { segment: '', provider: MainRouter }, 30 | { segment: '/auth/', provider: AuthRouter }, 31 | { segment: '/medias/', provider: MediaRouter }, 32 | { segment: '/users/', provider: UserRouter } 33 | ]; 34 | 35 | private constructor() {} 36 | 37 | /** 38 | * @description 39 | */ 40 | static get(): ProxyRouter { 41 | if ( !ProxyRouter.instance ) { 42 | ProxyRouter.instance = new ProxyRouter(); 43 | } 44 | return ProxyRouter.instance; 45 | } 46 | 47 | /** 48 | * @description Plug sub routes on main router 49 | */ 50 | map(): Router { 51 | this.routes.forEach( (route: IRoute) => { 52 | const instance = new route.provider() as { router: Router }; 53 | this.router.use( route.segment, instance.router ); 54 | }); 55 | return this.router; 56 | } 57 | } 58 | 59 | const proxyRouter = ProxyRouter.get(); 60 | 61 | export { proxyRouter as ProxyRouter } -------------------------------------------------------------------------------- /src/api/core/services/sanitizer.service.ts: -------------------------------------------------------------------------------- 1 | import * as Util from 'util'; 2 | import * as Pluralize from 'pluralize'; 3 | import { IModel } from '@interfaces'; 4 | import { isObject } from '@utils/object.util'; 5 | 6 | type SanitizableData = Record | unknown[] | string | number | boolean; 7 | 8 | class SanitizeService { 9 | 10 | /** 11 | * @description 12 | */ 13 | private static instance: SanitizeService; 14 | 15 | private constructor() {} 16 | 17 | /** 18 | * @description 19 | */ 20 | static get(): SanitizeService { 21 | if (!SanitizeService.instance) { 22 | SanitizeService.instance = new SanitizeService(); 23 | } 24 | return SanitizeService.instance; 25 | } 26 | 27 | /** 28 | * @description 29 | * 30 | * @param data 31 | */ 32 | hasEligibleMember(data: { [key: string]: any }): boolean { 33 | return ( this.implementsWhitelist(data) && !Array.isArray(data) ) || ( Array.isArray(data) && [].concat(data).some(obj => this.implementsWhitelist(obj) ) || ( isObject(data) && Object.keys(data).some(key => this.implementsWhitelist(data[key]) ) ) ) 34 | } 35 | 36 | /** 37 | * @description 38 | * 39 | * @param data 40 | */ 41 | process(data: { [key: string]: any }) { 42 | 43 | if ( Array.isArray(data) ) { 44 | return [] 45 | .concat(data) 46 | .map( (d: any ) => this.implementsWhitelist(d) ? this.sanitize(d as IModel) : d as Record); 47 | } 48 | 49 | if ( this.implementsWhitelist(data) ) { 50 | return this.sanitize(data as IModel); 51 | } 52 | 53 | if ( isObject(data) ) { 54 | return Object.keys(data) 55 | .reduce( (acc: Record, current: string) => { 56 | acc[current] = this.implementsWhitelist(data[current]) ? this.sanitize(data[current] as IModel) : data[current] 57 | return acc ; 58 | }, {}); 59 | } 60 | } 61 | 62 | /** 63 | * @description Whitelist an entity 64 | * 65 | * @param entity Entity to sanitize 66 | */ 67 | private sanitize(entity: IModel): Record { 68 | const output = {} as Record; 69 | Object.keys(entity) 70 | .map( (key: string) => { 71 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 72 | if (entity.whitelist.includes(key) || entity.whitelist.includes(Pluralize(key))) { 73 | output[key] = this.isSanitizable( entity[key] as Record ) 74 | ? Array.isArray(entity[key]) 75 | ? entity[key].length > 0 76 | ? entity[key].map(e => this.sanitize(e as IModel)) 77 | : [] 78 | : this.sanitize(entity[key] as IModel) 79 | : entity[key]; 80 | } 81 | }); 82 | return output; 83 | } 84 | 85 | /** 86 | * @description 87 | * 88 | * @param obj 89 | */ 90 | private implementsWhitelist(obj): boolean { 91 | return isObject(obj) && 'whitelist' in obj; 92 | } 93 | 94 | /** 95 | * @description Say if a value can be sanitized 96 | * 97 | * @param value Value to check as sanitizable 98 | */ 99 | private isSanitizable(value: { [key: string]: any }): boolean { 100 | 101 | if ( !value ) { 102 | return false; 103 | } 104 | 105 | if ( Util.types.isDate(value) || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { 106 | return false; 107 | } 108 | 109 | if ( isObject(value) && value.constructor === Object ) { 110 | return false; 111 | } 112 | 113 | if ( isObject(value) && Array.isArray(value) && value.filter( (v: { [key: string]: any }) => typeof v === 'string' || ( isObject(v) && v.constructor === Object ) ).length > 0 ) { 114 | return false; 115 | } 116 | 117 | return true; 118 | } 119 | } 120 | 121 | const sanitizeService = SanitizeService.get(); 122 | 123 | export { sanitizeService as SanitizeService } -------------------------------------------------------------------------------- /src/api/core/subscribers/media.subscriber.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import * as Dayjs from 'dayjs'; 4 | 5 | import { CacheService } from '@services/cache.service'; 6 | 7 | import { EventSubscriber, EntitySubscriberInterface, InsertEvent, UpdateEvent, RemoveEvent } from 'typeorm'; 8 | 9 | import { Media } from '@models/media.model'; 10 | import { MediaService } from '@services/media.service'; 11 | 12 | @EventSubscriber() 13 | export class MediaSubscriber implements EntitySubscriberInterface { 14 | 15 | /** 16 | * @description Indicates that this subscriber only listen to Media events. 17 | */ 18 | listenTo(): any { 19 | return Media; 20 | } 21 | 22 | /** 23 | * @description Called before media insertion. 24 | */ 25 | beforeInsert(event: InsertEvent): void { 26 | event.entity.createdAt = Dayjs( new Date() ).toDate(); 27 | } 28 | 29 | /** 30 | * @description Called after media insertion. 31 | */ 32 | afterInsert(event: InsertEvent): void { 33 | MediaService.rescale(event.entity); 34 | CacheService.refresh('medias'); 35 | } 36 | 37 | /** 38 | * @description Called before media update. 39 | */ 40 | beforeUpdate(event: UpdateEvent): void { 41 | event.entity.updatedAt = Dayjs( new Date() ).toDate(); 42 | } 43 | 44 | /** 45 | * @description Called after media update. 46 | */ 47 | afterUpdate(event: UpdateEvent): void { 48 | MediaService.rescale(event.entity as Media); 49 | MediaService.remove(event.databaseEntity); 50 | CacheService.refresh('medias'); 51 | } 52 | 53 | /** 54 | * @description Called after media deletetion. 55 | */ 56 | afterRemove(event: RemoveEvent): void { 57 | MediaService.remove(event.entity); 58 | CacheService.refresh('medias'); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/api/core/subscribers/user.subscriber.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import * as Dayjs from 'dayjs'; 4 | import { EventSubscriber, EntitySubscriberInterface, InsertEvent, UpdateEvent, RemoveEvent } from 'typeorm'; 5 | import { User } from '@models/user.model'; 6 | import { encrypt } from '@utils/string.util'; 7 | import { CacheService } from '@services/cache.service'; 8 | import { STATUS } from '@enums'; 9 | import { EmailEmitter } from '@events'; 10 | 11 | /** 12 | * 13 | */ 14 | @EventSubscriber() 15 | export class UserSubscriber implements EntitySubscriberInterface { 16 | 17 | previous: User; 18 | 19 | /** 20 | * @description Indicates that this subscriber only listen to Media events. 21 | */ 22 | listenTo(): any { 23 | return User; 24 | } 25 | 26 | /** 27 | * @description Called before user insertion. 28 | */ 29 | beforeInsert(event: InsertEvent): void { 30 | event.entity.apikey = !event.entity.apikey ? encrypt(event.entity.email) : event.entity.apikey; 31 | event.entity.status = STATUS.REGISTERED; 32 | event.entity.createdAt = Dayjs( new Date() ).toDate(); 33 | } 34 | 35 | /** 36 | * @description Called after media insertion. 37 | */ 38 | afterInsert(event: InsertEvent): void { 39 | CacheService.refresh('users'); 40 | EmailEmitter.emit('user.confirm', event.entity); 41 | } 42 | 43 | /** 44 | * @description Called before user update. 45 | */ 46 | beforeUpdate(event: UpdateEvent): void { 47 | event.entity.apikey = encrypt(event.entity.email as string); 48 | event.entity.updatedAt = Dayjs( new Date() ).toDate(); 49 | if (event.entity.email !== event.databaseEntity.email) { 50 | event.entity.status = STATUS.REVIEWED; 51 | } 52 | } 53 | 54 | /** 55 | * @description Called after user update. 56 | */ 57 | afterUpdate(event: UpdateEvent): void { 58 | CacheService.refresh('users'); 59 | if (event.entity.status === STATUS.CONFIRMED && event.databaseEntity.status === STATUS.REGISTERED) { 60 | EmailEmitter.emit('user.welcome', event.databaseEntity); 61 | } 62 | if (event.entity.email !== event.databaseEntity.email) { 63 | EmailEmitter.emit('user.confirm', event.entity); 64 | } 65 | } 66 | 67 | /** 68 | * @description Called after user deletetion. 69 | */ 70 | afterRemove(event: RemoveEvent): void { 71 | CacheService.refresh('users'); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/api/core/types/classes/controller.class.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main controller 3 | */ 4 | export abstract class Controller { 5 | constructor() {} 6 | } -------------------------------------------------------------------------------- /src/api/core/types/classes/index.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from './controller.class'; 2 | import { Router } from './router.class'; 3 | 4 | export { Controller, Router } -------------------------------------------------------------------------------- /src/api/core/types/classes/router.class.ts: -------------------------------------------------------------------------------- 1 | import { Router as ExpressRouter } from 'express'; 2 | 3 | /** 4 | * Router base class 5 | */ 6 | export abstract class Router { 7 | 8 | /** 9 | * @description Wrapped Express.Router 10 | */ 11 | router: ExpressRouter = null; 12 | 13 | constructor() { 14 | this.router = ExpressRouter(); 15 | this.define(); 16 | } 17 | 18 | define(): void {} 19 | } -------------------------------------------------------------------------------- /src/api/core/types/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { Safe } from './safe.decorator'; 2 | 3 | export { Safe } -------------------------------------------------------------------------------- /src/api/core/types/decorators/safe.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@classes'; 2 | import { Media } from '@models/media.model'; 3 | import { MediaService } from '@services/media.service'; 4 | 5 | /** 6 | * @decorator Safe 7 | * 8 | * @description Endpoint decorator which catch errors fired while endpoint execution 9 | * 10 | * @param target Endpoint method reference 11 | * @param key Endpoint name 12 | */ 13 | const Safe = (): any => { 14 | return ( target: Controller, key: string ): any => { 15 | const method = target[key] as (req, res, next) => Promise | void; 16 | target[key] = function (...args: any[]): void { 17 | const { files } = args[0] as { files: any[] }; 18 | const next = args[2] as (e?: Error) => void; 19 | const result = method.apply(this, args) as Promise | void; 20 | if (result && result instanceof Promise) { 21 | result 22 | .then(() => next()) 23 | .catch(e => { 24 | const scopedError = e as Error; 25 | if (files && files.length > 0) { 26 | files.map((f: Media) => MediaService.remove(f)) 27 | } 28 | next(scopedError); 29 | }); 30 | } 31 | } 32 | return target[key] as (req, res, next) => void; 33 | } 34 | } 35 | 36 | export { Safe } -------------------------------------------------------------------------------- /src/api/core/types/enums/archive-mime-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define supported archives mime-types 3 | */ 4 | export enum ARCHIVE_MIME_TYPE { 5 | 'application/vnd.rar' = 'application/vnd.rar', 6 | 'application/x-7z-compressed' = 'application/x-7z-compressed', 7 | 'application/x-rar-compressed' = 'application/x-rar-compressed', 8 | 'application/x-tar' = 'application/x-tar', 9 | 'application/zip' = 'application/zip', 10 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/audio-mime-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define supported audio mime-types 3 | */ 4 | export enum AUDIO_MIME_TYPE { 5 | 'audio/mpeg' = 'audio/mpeg', 6 | 'audio/mp3' = 'audio/mp3', 7 | 'audio/mid' = 'audio/mid', 8 | 'audio/mp4' = 'audio/mp4', 9 | 'audio/x-aiff' = 'audio/x-aiff', 10 | 'audio/ogg' = 'audio/ogg', 11 | 'audio/vorbis' = 'audio/vorbis', 12 | 'audio/vnd.wav' = 'audio/vnd.wav', 13 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/content-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define application supported mimes 3 | */ 4 | export enum CONTENT_TYPE { 5 | 'application/json' = 'application/json', 6 | 'multipart/form-data' = 'multipart/form-data' 7 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/database-engine.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define supported database engines 3 | */ 4 | export enum DATABASE_ENGINE { 5 | 'mysql' = 'mysql', 6 | 'mariadb' = 'mariadb', 7 | 'postgres' = 'postgres', 8 | 'cockroachdb' = 'cockroachdb', 9 | 'sqlite' = 'sqlite', 10 | 'mssql' = 'mssql', 11 | 'sap' = 'sap', 12 | 'oracle' = 'oracle', 13 | 'cordova' = 'cordova', 14 | 'nativescript' ='nativescript', 15 | 'react-native' = 'react-native', 16 | 'sqljs' = 'sqljs', 17 | 'mongodb' = 'mongodb', 18 | 'aurora-data-api' = 'aurora-data-api', 19 | 'aurora-data-api-pg' = 'aurora-data-api-pg', 20 | 'expo' = 'expo', 21 | 'better-sqlite3' = 'better-sqlite3' 22 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/document-mime-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define supported documents mime-types 3 | */ 4 | export enum DOCUMENT_MIME_TYPE { 5 | 'application/vnd.ms-excel' = 'application/vnd.ms-excel', 6 | 'application/vnd.ms-powerpoint' = 'application/vnd.ms-powerpoint', 7 | 'application/msword' = 'application/msword', 8 | 'application/pdf' = 'application/pdf', 9 | 'application/vnd.oasis.opendocument.presentation' = 'application/vnd.oasis.opendocument.presentation', 10 | 'application/vnd.oasis.opendocument.spreadsheet' = 'application/vnd.oasis.opendocument.spreadsheet', 11 | 'application/vnd.oasis.opendocument.text' = 'application/vnd.oasis.opendocument.text', 12 | 'text/csv' = 'text/csv' 13 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/environment.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define application environments 3 | */ 4 | export enum ENVIRONMENT { 5 | development = 'development', 6 | staging = 'staging', 7 | production = 'production', 8 | test = 'test' 9 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/fieldname.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FIELDNAME { 2 | 'avatar' = 'avatar', 3 | 'screenshot' = 'screenshot', 4 | 'banner' = 'banner', 5 | 'invoice' = 'invoice', 6 | 'teaser' = 'teaser', 7 | 'back-up' = 'back-up', 8 | 'song' = 'song' 9 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/image-mime-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define supported image mime-types 3 | */ 4 | export enum IMAGE_MIME_TYPE { 5 | 'image/bmp' = 'image/bmp', 6 | 'image/gif' = 'image/gif', 7 | 'image/jpg' = 'image/jpg', 8 | 'image/jpeg' = 'image/jpeg', 9 | 'image/png' = 'image/png' 10 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/index.ts: -------------------------------------------------------------------------------- 1 | import { ARCHIVE_MIME_TYPE } from './archive-mime-type.enum'; 2 | import { AUDIO_MIME_TYPE } from './audio-mime-type.enum'; 3 | import { CONTENT_TYPE } from './content-type.enum'; 4 | import { DATABASE_ENGINE } from './database-engine.enum'; 5 | import { DOCUMENT_MIME_TYPE } from './document-mime-type.enum'; 6 | import { ENVIRONMENT } from './environment.enum'; 7 | import { FIELDNAME } from './fieldname.enum'; 8 | import { IMAGE_MIME_TYPE } from './image-mime-type.enum'; 9 | import { MEDIA_TYPE } from './media-type.enum'; 10 | import { ROLE } from './role.enum'; 11 | import { STATUS } from './status.enum'; 12 | import { VIDEO_MIME_TYPE } from './video-mime-type.enum'; 13 | 14 | import { MIME_TYPE, MIME_TYPE_LIST } from './mime-type.enum'; 15 | 16 | export { 17 | ARCHIVE_MIME_TYPE, 18 | AUDIO_MIME_TYPE, 19 | CONTENT_TYPE, 20 | DATABASE_ENGINE, 21 | DOCUMENT_MIME_TYPE, 22 | ENVIRONMENT, 23 | FIELDNAME, 24 | IMAGE_MIME_TYPE, 25 | MEDIA_TYPE, 26 | MIME_TYPE, 27 | MIME_TYPE_LIST, 28 | ROLE, 29 | STATUS, 30 | VIDEO_MIME_TYPE 31 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/media-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describe supported media types 3 | */ 4 | export enum MEDIA_TYPE { 5 | audio = 'audio', 6 | archive = 'archive', 7 | document = 'document', 8 | image = 'image', 9 | video = 'video' 10 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/mime-type.enum.ts: -------------------------------------------------------------------------------- 1 | import { list } from '@utils/enum.util'; 2 | 3 | import { AUDIO_MIME_TYPE, ARCHIVE_MIME_TYPE, DOCUMENT_MIME_TYPE, IMAGE_MIME_TYPE, VIDEO_MIME_TYPE } from '@enums'; 4 | 5 | /** 6 | * @description Shortcut all media types mime-types as pseudo enum 7 | */ 8 | const MIME_TYPE = { ...AUDIO_MIME_TYPE, ...ARCHIVE_MIME_TYPE, ...DOCUMENT_MIME_TYPE, ...IMAGE_MIME_TYPE, ...VIDEO_MIME_TYPE }; 9 | 10 | /** 11 | * @description Shortcut all media types mime-types as pseudo enum 12 | */ 13 | const MIME_TYPE_LIST = [].concat( ...[ AUDIO_MIME_TYPE, ARCHIVE_MIME_TYPE, DOCUMENT_MIME_TYPE, IMAGE_MIME_TYPE, VIDEO_MIME_TYPE ].map( type => list(type) ) ); 14 | 15 | export { 16 | MIME_TYPE, 17 | MIME_TYPE_LIST 18 | } 19 | -------------------------------------------------------------------------------- /src/api/core/types/enums/role.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define supported roles 3 | */ 4 | export enum ROLE { 5 | admin = 'admin', 6 | user = 'user', 7 | ghost = 'ghost' 8 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum STATUS { 2 | 'REGISTERED' = 'REGISTERED', 3 | 'SUBMITTED' = 'SUBMITTED', 4 | 'CONFIRMED' = 'CONFIRMED', 5 | 'VALIDATED' = 'VALIDATED', 6 | 'QUARANTINED' = 'QUARANTINED', 7 | 'BANNED' = 'BANNED', 8 | 'REVIEWED' = 'REVIEWED' 9 | } -------------------------------------------------------------------------------- /src/api/core/types/enums/video-mime-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Define supported video mime-types 3 | */ 4 | export enum VIDEO_MIME_TYPE { 5 | 'video/mp4' = 'video/mp4', 6 | 'application/x-mpegURL' = 'application/x-mpegURL', 7 | 'video/3gpp' = 'video/3gpp', 8 | 'video/quicktime' = 'video/quicktime', 9 | 'video/x-msvideo' = 'video/x-msvideo', 10 | 'video/x-ms-wmv' = 'video/x-ms-wmv' 11 | } -------------------------------------------------------------------------------- /src/api/core/types/errors/business.error.ts: -------------------------------------------------------------------------------- 1 | import * as HTTP_STATUS from 'http-status'; 2 | 3 | import { IError, IHTTPError } from '@interfaces'; 4 | import { TypeplateError } from '@errors'; 5 | 6 | /** 7 | * @description Custom BusinessError 8 | */ 9 | export class BusinessError extends TypeplateError implements IHTTPError { 10 | 11 | /** 12 | * @description IError HTTP response status code 13 | */ 14 | statusCode: number; 15 | 16 | /** 17 | * @description IError HTTP response status message 18 | */ 19 | statusText: string; 20 | 21 | /** 22 | * @description Ierror HTTP response errors 23 | */ 24 | errors: Array; 25 | 26 | constructor(error: IError) { 27 | super('Business validation failed'); 28 | this.statusCode = error.statusCode; 29 | this.statusText = 'Business validation failed'; 30 | this.errors = [ error.message ]; 31 | } 32 | } -------------------------------------------------------------------------------- /src/api/core/types/errors/index.ts: -------------------------------------------------------------------------------- 1 | import { TypeplateError } from './typeplate.error'; 2 | 3 | import { BusinessError } from './business.error'; 4 | import { MySQLError } from './mysql.error'; 5 | import { NotFoundError } from './not-found.error'; 6 | import { ServerError } from './server.error'; 7 | import { UploadError } from './upload.error'; 8 | import { ValidationError } from './validation.error'; 9 | 10 | export { BusinessError, MySQLError, NotFoundError, ServerError, TypeplateError, UploadError, ValidationError } -------------------------------------------------------------------------------- /src/api/core/types/errors/mysql.error.ts: -------------------------------------------------------------------------------- 1 | import * as HTTP_STATUS from 'http-status'; 2 | 3 | import { IError, IHTTPError } from '@interfaces'; 4 | import { TypeplateError } from '@errors'; 5 | 6 | /** 7 | * @description Custom type MySQL error 8 | */ 9 | export class MySQLError extends TypeplateError implements IHTTPError { 10 | 11 | /** 12 | * @description HTTP response status code 13 | */ 14 | statusCode: number; 15 | 16 | /** 17 | * @description HTTP response status message 18 | */ 19 | statusText: string; 20 | 21 | /** 22 | * @description HTTP response errors 23 | */ 24 | errors: Array; 25 | 26 | constructor(error: IError) { 27 | super('MySQL engine was failed'); 28 | const converted = this.convertError(error.errno, error.message); 29 | this.statusCode = converted.statusCode; 30 | this.statusText = converted.statusText; 31 | this.errors = [ converted.error ]; 32 | } 33 | 34 | /** 35 | * @description Fallback MySQL error when creating / updating fail 36 | * 37 | * @param errno 38 | * @param message 39 | * 40 | * @example 1052 ER_NON_UNIQ_ERROR 41 | * @example 1054 ER_BAD_FIELD_ERROR 42 | * @example 1062 DUPLICATE_ENTRY 43 | * @example 1452 ER_NO_REFERENCED_ROW_2 44 | * @example 1364 ER_NO_DEFAULT_FOR_FIELD 45 | * @example 1406 ER_DATA_TOO_LONG 46 | */ 47 | private convertError(errno: number, message: string): { statusCode: number, statusText: string, error: string } { 48 | switch (errno) { 49 | case 1052: 50 | return { statusCode: 409, statusText: HTTP_STATUS['409_NAME'], error: message } 51 | case 1054: 52 | return { statusCode: 409, statusText: HTTP_STATUS['409_NAME'], error: message } 53 | case 1062: 54 | return { statusCode: 409, statusText: HTTP_STATUS['409_NAME'], error: message } // `Duplicate entry for ${/\'[a-z]{1,}\./.exec(message)[0].slice(1, -1).trim()}` not working in CI with MySQL < 8 55 | case 1452: 56 | return { statusCode: 409, statusText: HTTP_STATUS['409_NAME'], error: message } 57 | case 1364: 58 | return { statusCode: 422, statusText: HTTP_STATUS['422_NAME'], error: message } 59 | case 1406: 60 | return { statusCode: 422, statusText: HTTP_STATUS['422_NAME'], error: message } 61 | default: 62 | return { statusCode: 422, statusText: HTTP_STATUS['422_NAME'], error: message } 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /src/api/core/types/errors/not-found.error.ts: -------------------------------------------------------------------------------- 1 | import * as HTTP_STATUS from 'http-status'; 2 | 3 | import { IError, IHTTPError } from '@interfaces'; 4 | import { TypeplateError } from '@errors'; 5 | 6 | /** 7 | * @description Custom NotFoundError 8 | */ 9 | export class NotFoundError extends TypeplateError implements IHTTPError { 10 | 11 | /** 12 | * @description IError HTTP response status code 13 | */ 14 | statusCode: number; 15 | 16 | /** 17 | * @description IError HTTP response status message 18 | */ 19 | statusText: string; 20 | 21 | /** 22 | * @description Ierror HTTP response errors 23 | */ 24 | errors: Array; 25 | 26 | constructor(error: IError) { 27 | super('A resource was not found'); 28 | this.statusCode = 404; 29 | this.statusText = 'Resource not found'; 30 | this.errors = [ error.message ]; 31 | } 32 | } -------------------------------------------------------------------------------- /src/api/core/types/errors/server.error.ts: -------------------------------------------------------------------------------- 1 | import * as HTTP_STATUS from 'http-status'; 2 | 3 | import { IError, IHTTPError } from '@interfaces'; 4 | import { TypeplateError } from '@errors'; 5 | 6 | /** 7 | * @description Type native error 8 | */ 9 | export class ServerError extends TypeplateError implements IHTTPError { 10 | 11 | /** 12 | * @description HTTP response status code 13 | */ 14 | statusCode: number; 15 | 16 | /** 17 | * @description HTTP response status message 18 | */ 19 | statusText: string; 20 | 21 | /** 22 | * @description HTTP response errors 23 | */ 24 | errors: Array; 25 | 26 | constructor(error: IError) { 27 | super(error.message) 28 | this.statusCode = 500; 29 | this.statusText = 'Ooops... server seems to be broken'; 30 | this.errors = [ 'Looks like someone\'s was not there while the meeting\'' ]; 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/api/core/types/errors/typeplate.error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Custom type error 3 | */ 4 | export class TypeplateError extends Error { 5 | 6 | /** 7 | * @description 8 | */ 9 | name: string; 10 | 11 | constructor(message: string) { 12 | super(message); 13 | this.name = this.constructor.name; 14 | Error.captureStackTrace(this, this.constructor); 15 | } 16 | } -------------------------------------------------------------------------------- /src/api/core/types/errors/upload.error.ts: -------------------------------------------------------------------------------- 1 | import * as HTTP_STATUS from 'http-status'; 2 | 3 | import { IError, IHTTPError } from '@interfaces'; 4 | import { TypeplateError } from '@errors'; 5 | 6 | /** 7 | * @description Custom type upload error 8 | */ 9 | export class UploadError extends TypeplateError implements IHTTPError { 10 | 11 | /** 12 | * @description HTTP response status code 13 | */ 14 | statusCode: number; 15 | 16 | /** 17 | * @description HTTP response status message 18 | */ 19 | statusText: string; 20 | 21 | /** 22 | * @description HTTP response errors 23 | */ 24 | errors: Array; 25 | 26 | constructor(error: IError) { 27 | super('File upload was failed'); 28 | this.statusCode = 400; 29 | this.statusText = error.name; 30 | this.errors = [ error.message || error.code]; 31 | } 32 | } -------------------------------------------------------------------------------- /src/api/core/types/errors/validation.error.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrorItem } from 'joi'; 2 | import * as HTTP_STATUS from 'http-status'; 3 | 4 | import { IError, IHTTPError } from '@interfaces'; 5 | import { TypeplateError } from '@errors'; 6 | 7 | /** 8 | * Type upload error 9 | */ 10 | export class ValidationError extends TypeplateError implements IHTTPError { 11 | 12 | /** 13 | * @description HTTP response status code 14 | */ 15 | statusCode: number; 16 | 17 | /** 18 | * @description HTTP response status message 19 | */ 20 | statusText: string; 21 | 22 | /** 23 | * @description HTTP response errors 24 | */ 25 | errors: Array; 26 | 27 | constructor(error: IError) { 28 | super('A validation error was occurred') 29 | this.statusCode = 400; 30 | this.statusText = 'Validation failed'; 31 | this.errors = this.convertError(error.details); 32 | } 33 | 34 | /** 35 | * @description Convert Joi validation errors into strings 36 | * 37 | * @param errors Array of Joi validation errors 38 | */ 39 | private convertError(errors: ValidationErrorItem[]): string[] { 40 | return errors.map( (err: ValidationErrorItem) => err.message.replace(/"/g, '\'') ) 41 | } 42 | } -------------------------------------------------------------------------------- /src/api/core/types/events/email.event.ts: -------------------------------------------------------------------------------- 1 | import * as Events from 'events'; 2 | import { Cliam } from 'cliam'; 3 | import { RENDER_ENGINE } from 'cliam/dist/types/enums/render-engine.enum'; 4 | import { URL } from '@config/environment.config'; 5 | import { User } from '@models/user.model'; 6 | 7 | const EmailEmitter = new Events.EventEmitter(); 8 | 9 | EmailEmitter.on('user.confirm', (user: User) => { 10 | void Cliam.mail('user.confirm', { 11 | meta: { 12 | subject: 'Confirm your account', 13 | to: [ 14 | { name: user.username, email: user.email } 15 | ] 16 | }, 17 | data: { 18 | user: { 19 | username: user.username 20 | }, 21 | cta: `${URL}/auth/confirm?token=${user.token(120)}` 22 | }, 23 | renderEngine: 'cliam' as RENDER_ENGINE 24 | }); 25 | }); 26 | 27 | EmailEmitter.on('user.welcome', (user: User) => { 28 | void Cliam.mail('user.welcome', { 29 | meta: { 30 | subject: `Welcome on board, ${user.username}`, 31 | to: [ 32 | { name: user.username, email: user.email } 33 | ] 34 | }, 35 | data: { 36 | user: { 37 | username: user.username 38 | } 39 | }, 40 | renderEngine: 'cliam' as RENDER_ENGINE 41 | }); 42 | }); 43 | 44 | EmailEmitter.on('password.request', (user: User) => { 45 | void Cliam.mail('password.request', { 46 | meta: { 47 | subject: 'Update password request', 48 | to: [ 49 | { name: user.username, email: user.email } 50 | ] 51 | }, 52 | data: { 53 | user: { 54 | username: user.username 55 | }, 56 | cta: `${URL}/auth/change-password?token=${user.token()}` 57 | }, 58 | renderEngine: 'cliam' as RENDER_ENGINE 59 | }); 60 | }); 61 | 62 | export { EmailEmitter } -------------------------------------------------------------------------------- /src/api/core/types/events/index.ts: -------------------------------------------------------------------------------- 1 | import { EmailEmitter } from './email.event'; 2 | export { EmailEmitter } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/cache.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Describe memory cache interface 3 | */ 4 | export interface ICache { 5 | put: (key: string, data: any, duration: number, cb?: () => void ) => void, 6 | del: (key: string) => boolean, 7 | clear: () => void, 8 | get: (key: string) => Record | Record[], 9 | size: () => number, 10 | memsize: () => number, 11 | debug: (active: boolean) => void, 12 | hits: () => number, 13 | misses: () => number, 14 | keys: () => string[], 15 | exportJson: () => string, 16 | importJson: (json: string, options: { skipDuplicate: boolean }) => number, 17 | Cache: () => void, 18 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/error.interface.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrorItem } from 'joi'; 2 | 3 | /** 4 | * Define generic Error interface 5 | */ 6 | export interface IError { 7 | 8 | /** 9 | * @description Specific error message for some errors 10 | */ 11 | code?: string; 12 | 13 | /** 14 | * @description Error name 15 | */ 16 | name: string; 17 | 18 | /** 19 | * @description MySQL error status code 20 | */ 21 | errno?: number; 22 | 23 | /** 24 | * @description MySQL error message 25 | */ 26 | sqlMessage?: string; 27 | 28 | /** 29 | * @description Error message 30 | */ 31 | message?: string; 32 | 33 | /** 34 | * @description Error call stack 35 | */ 36 | stack?: string; 37 | 38 | /** 39 | * @description Error status alias 40 | */ 41 | httpStatusCode?: number; 42 | 43 | /** 44 | * @description Error status alias 45 | */ 46 | status?: number; 47 | 48 | /** 49 | * @description Error status alias 50 | */ 51 | statusCode?: number; 52 | 53 | /** 54 | * @description Boom error output 55 | */ 56 | output?: { statusCode?: number, payload?: { error: string, message: string } }; 57 | 58 | /** 59 | * @description Boom error descriptor 60 | */ 61 | isBoom?: boolean; 62 | 63 | /** 64 | * @description Joi error descriptor 65 | */ 66 | isJoi?: boolean; 67 | 68 | /** 69 | * @description Validation error message 70 | */ 71 | statusText?: string; 72 | 73 | /** 74 | * @description Error details 75 | */ 76 | errors?: { field, types, messages }[] | string[]; 77 | 78 | /** 79 | * @description Validation error details 80 | */ 81 | details?: ValidationErrorItem[] 82 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/http-error.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Define error output format 3 | */ 4 | export interface IHTTPError { 5 | 6 | /** 7 | * @description HTTP response status code 8 | */ 9 | statusCode: number; 10 | 11 | /** 12 | * @description HTTP response status message 13 | */ 14 | statusText: string; 15 | 16 | /** 17 | * @description HTTP response errors 18 | */ 19 | errors: Array; 20 | 21 | 22 | /** 23 | * 24 | */ 25 | message?: string; 26 | 27 | /** 28 | * 29 | */ 30 | stack?: string; 31 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import { ICache } from './cache.interface'; 2 | import { IError } from './error.interface'; 3 | import { IHTTPError } from './http-error.interface'; 4 | import { IMedia } from './media.interface'; 5 | import { IMediaQueryString } from './media-query-string.interface'; 6 | import { IMediaRequest } from './media-request.interface'; 7 | import { IModel } from './model.interface'; 8 | import { IOauthResponse } from './oauth-response.interface'; 9 | import { IQueryString } from './query-string.interface'; 10 | import { IRegistrable } from './registrable.interface'; 11 | import { IRequest } from './request.interface'; 12 | import { IResponse } from './response.interface'; 13 | import { IRoute } from './route.interface'; 14 | import { IStorage } from './storage.interface'; 15 | import { ITokenOptions } from './token-options.interface'; 16 | import { IUpload } from './upload.interface'; 17 | import { IUploadMulterOptions } from './upload-multer-options.interface'; 18 | import { IUploadOptions } from './upload-options.interface'; 19 | import { IUserQueryString } from './user-query-string.interface'; 20 | import { IUserRequest } from './user-request.interface'; 21 | 22 | export { 23 | ICache, 24 | IError, 25 | IHTTPError, 26 | IMedia, 27 | IMediaQueryString, 28 | IMediaRequest, 29 | IModel, 30 | IOauthResponse, 31 | IQueryString, 32 | IRegistrable, 33 | IRequest, 34 | IResponse, 35 | IRoute, 36 | IStorage, 37 | ITokenOptions, 38 | IUpload, 39 | IUploadMulterOptions, 40 | IUploadOptions, 41 | IUserQueryString, 42 | IUserRequest 43 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/media-query-string.interface.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '@types'; 2 | import { IQueryString } from '@interfaces'; 3 | 4 | export interface IMediaQueryString extends IQueryString { 5 | path?: string; 6 | fieldname?: string; 7 | filename?: string; 8 | size?: number; 9 | mimetype?: string; 10 | owner?: string; 11 | type?: MediaType; 12 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/media-request.interface.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@models/user.model'; 2 | import { IMedia, IRequest } from '@interfaces'; 3 | 4 | /** 5 | * @description 6 | */ 7 | export interface IMediaRequest extends IRequest { 8 | body: { files?: IMedia[], file?: IMedia } 9 | file?: IMedia; 10 | files?: IMedia[]; 11 | user?: User; 12 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/media.interface.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@models/user.model'; 2 | import { DeepPartial } from 'typeorm'; 3 | import { MimeType } from '@types'; 4 | 5 | export interface IMedia { 6 | path: string; 7 | filename: string; 8 | size: number; 9 | destination: string; 10 | encoding?: string; 11 | mimetype: MimeType; 12 | originalname?: string; 13 | fieldname?: string; 14 | owner?: number | User | DeepPartial; 15 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/model.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IModel { 2 | id: number; 3 | createdAt: Date; 4 | updatedAt: Date; 5 | deletedAt: Date; 6 | whitelist: string[]; 7 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/oauth-response.interface.ts: -------------------------------------------------------------------------------- 1 | import { OAuthProvider } from '@types'; 2 | 3 | export interface IOauthResponse { 4 | 5 | /** 6 | * 7 | */ 8 | id: number; 9 | 10 | /** 11 | * 12 | */ 13 | displayName: string, 14 | 15 | /** 16 | * 17 | */ 18 | name?: { familyName: string, givenName: string }, 19 | 20 | /** 21 | * 22 | */ 23 | emails: { value: string, verified?: boolean }[], 24 | 25 | /** 26 | * 27 | */ 28 | photos: { value: string }[], 29 | 30 | /** 31 | * 32 | */ 33 | provider: { name: OAuthProvider, _raw: string, _json: Record } 34 | 35 | /** 36 | * 37 | */ 38 | username?: string; 39 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/query-string.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IQueryString { 2 | page?: string|number; 3 | perPage?: string|number; 4 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/registrable.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IRegistrable { 2 | 3 | /** 4 | * 5 | */ 6 | username: string; 7 | 8 | /** 9 | * 10 | */ 11 | email: string; 12 | 13 | /** 14 | * 15 | */ 16 | password: string; 17 | 18 | /** 19 | * 20 | */ 21 | picture?: string; 22 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/request.interface.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | /** 4 | * @description 5 | */ 6 | export interface IRequest extends Request { 7 | user?: any; 8 | query: Record, 9 | params: Record; 10 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/response.interface.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import { IModel } from '@interfaces'; 3 | export interface IResponse extends Response { 4 | locals: { 5 | data?: Record | Record[] | IModel | IModel[], 6 | meta?: { 7 | total: number, 8 | pagination?: { 9 | current?: number, 10 | next?: number, 11 | prev?: number 12 | } 13 | } 14 | }; 15 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/route.interface.ts: -------------------------------------------------------------------------------- 1 | import { Router } from '@classes'; 2 | 3 | /** 4 | * Define route definition members 5 | */ 6 | export interface IRoute { 7 | 8 | /** 9 | * @description URI segment 10 | */ 11 | segment: string; 12 | 13 | /** 14 | * @description Router definition or Router concrete instance 15 | */ 16 | // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents 17 | provider: Router|any; 18 | 19 | /** 20 | * @description Indicates if the route response must be serialized 21 | */ 22 | serializable: boolean; 23 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/storage.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IStorage { 2 | getFilename: () => string; 3 | getDestination: () => string; 4 | } 5 | -------------------------------------------------------------------------------- /src/api/core/types/interfaces/token-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { RefreshToken } from '@models/refresh-token.model'; 2 | 3 | export interface ITokenOptions { 4 | password?: string; 5 | email: string; 6 | apikey?: string; 7 | refreshToken: RefreshToken 8 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/upload-multer-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IUploadMulterOptions { 2 | storage: any; 3 | limits: { fileSize: number }; 4 | fileFilter: (req, file, next) => void 5 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/upload-options.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Define allowed properties for upload options configuration 3 | */ 4 | export interface IUploadOptions { 5 | 6 | /** 7 | * @description Destination directory path 8 | */ 9 | destination?: string; 10 | 11 | /** 12 | * @description Max filesize allowed for the current upload 13 | */ 14 | filesize?: number; 15 | 16 | /** 17 | * @description Authorized mime-types 18 | */ 19 | wildcards?: string[]; 20 | 21 | /** 22 | * @description Max concurrents files for the same upload process 23 | */ 24 | maxFiles?: number; 25 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/upload.interface.ts: -------------------------------------------------------------------------------- 1 | import { IStorage } from '@interfaces'; 2 | 3 | /** 4 | * @description 5 | */ 6 | export interface IUpload { 7 | diskStorage: ( { destination, filename } ) => IStorage; 8 | // eslint-disable-next-line id-blacklist 9 | any: () => ( req, res, next ) => void; 10 | } 11 | -------------------------------------------------------------------------------- /src/api/core/types/interfaces/user-query-string.interface.ts: -------------------------------------------------------------------------------- 1 | import { IQueryString } from '@interfaces'; 2 | import { Status } from '@types'; 3 | 4 | export interface IUserQueryString extends IQueryString { 5 | status?: Status; 6 | username?: string; 7 | email?: string; 8 | role?: string; 9 | website?: string; 10 | } -------------------------------------------------------------------------------- /src/api/core/types/interfaces/user-request.interface.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@models/user.model'; 2 | import { IRequest, IMedia, IUserQueryString } from '@interfaces'; 3 | /** 4 | * @description 5 | */ 6 | export interface IUserRequest extends IRequest { 7 | user?: User|Record; 8 | logIn: (user: User, done: (err: any) => void) => void, 9 | files?: IMedia[], 10 | body: { 11 | token?: string, 12 | password?: string, 13 | passwordConfirmation?: string, 14 | passwordToRevoke?: string, 15 | isUpdatePassword: boolean 16 | } 17 | query: { 18 | email?: string, 19 | page?: string, 20 | perPage?: string 21 | } 22 | } -------------------------------------------------------------------------------- /src/api/core/types/schemas/email.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const email = (): AnySchema => { 5 | return Joi.string().email(); 6 | }; 7 | 8 | export { email } -------------------------------------------------------------------------------- /src/api/core/types/schemas/fieldname.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | import { list } from '@utils/enum.util'; 5 | import { FIELDNAME } from '@enums'; 6 | 7 | const fieldname = (): AnySchema => { 8 | return Joi.any().valid(...list(FIELDNAME)); 9 | }; 10 | 11 | export { fieldname } -------------------------------------------------------------------------------- /src/api/core/types/schemas/file.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | import { MIME_TYPE_LIST } from '@enums'; 5 | import { filename, path, mimetype } from '@schemas'; 6 | import { Fieldname } from '@types'; 7 | 8 | const file = (field_name: Fieldname): AnySchema => { 9 | return Joi.object().keys({ 10 | id: Joi.number().optional(), 11 | fieldname: Joi.string().valid(field_name).required(), 12 | filename: filename().required(), 13 | path: path().required(), 14 | mimetype: mimetype(MIME_TYPE_LIST as string[]).required(), 15 | size: Joi.number().required(), 16 | owner: Joi.number().required(), 17 | createdAt: Joi.date().optional().allow(null), 18 | updatedAt: Joi.date().optional().allow(null), 19 | deletedAt: Joi.date().optional().allow(null) 20 | }); 21 | }; 22 | 23 | export { file } -------------------------------------------------------------------------------- /src/api/core/types/schemas/filename.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const filename = (extension: boolean = true): AnySchema => { 5 | return extension ? Joi.string().regex(/^[a-z-A-Z-0-9\-\_]{1,123}\.[a-z-0-9]{1,5}$/i) : Joi.string().regex(/^[a-z-A-Z-0-9\-\_\w]{1,123}$/i); 6 | }; 7 | 8 | export { filename } -------------------------------------------------------------------------------- /src/api/core/types/schemas/id.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const id = (): AnySchema => { 5 | return Joi.string().regex(/^[0-9]{1,4}$/).required(); 6 | }; 7 | 8 | export { id } -------------------------------------------------------------------------------- /src/api/core/types/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { email } from './email.schema'; 2 | import { fieldname } from './fieldname.schema'; 3 | import { file } from './file.schema'; 4 | import { filename } from './filename.schema'; 5 | import { id } from './id.schema'; 6 | import { mimetype } from './mimetype.schema'; 7 | import { pagination } from './pagination.schema'; 8 | import { password } from './password.schema'; 9 | import { path } from './path.schema'; 10 | import { username } from './username.schema'; 11 | 12 | export { email, fieldname, file, filename, id, mimetype, pagination, password, path, username } -------------------------------------------------------------------------------- /src/api/core/types/schemas/mimetype.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const mimetype = (mimetypes: string[]): AnySchema => { 5 | return Joi.any().valid(...mimetypes); 6 | }; 7 | 8 | export { mimetype } -------------------------------------------------------------------------------- /src/api/core/types/schemas/pagination.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const pagination = (filter: string): AnySchema => { 5 | const filters = { 6 | page: Joi.number().min(1), 7 | perPage: Joi.number().min(1).max(100) 8 | }; 9 | return filters[filter] as AnySchema; 10 | }; 11 | 12 | export { pagination } -------------------------------------------------------------------------------- /src/api/core/types/schemas/password.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const password = (type: string): AnySchema => { 5 | const types = [ 6 | { 7 | type: 'user', 8 | schema: Joi.string().min(8).max(16) 9 | }, 10 | { 11 | type: 'smtp', 12 | schema: Joi.string().regex(/^[a-z-0-9\-]{2,12}\.[a-z]{2,16}\.[a-z]{2,8}$/i) 13 | } 14 | ]; 15 | return types.filter( h => h.type === type ).slice().shift().schema; 16 | }; 17 | 18 | export { password } -------------------------------------------------------------------------------- /src/api/core/types/schemas/path.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const path = (): AnySchema => { 5 | return Joi.string().regex(/^[a-z-A-Z-0-9\-\_\/]{1,}\.[a-z-0-9]{1,5}$/i); 6 | }; 7 | 8 | export { path } -------------------------------------------------------------------------------- /src/api/core/types/schemas/username.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | import { AnySchema } from 'joi'; 3 | 4 | const username = (): AnySchema => { 5 | return Joi.string().max(32); 6 | }; 7 | 8 | export { username } -------------------------------------------------------------------------------- /src/api/core/types/types/archive-mime-type.type.ts: -------------------------------------------------------------------------------- 1 | export type ArchiveMimeType = 'application/x-7z-compressed' | 'application/x-rar-compressed' | 'application/x-tar' | 'application/zip'; -------------------------------------------------------------------------------- /src/api/core/types/types/audio-mime-type.type.ts: -------------------------------------------------------------------------------- 1 | export type AudioMimeType = 'audio/mpeg' | 'audio/mp3' | 'audio/mid' | 'audio/mp4' | 'audio/x-aiff' | 'audio/ogg' | 'audio/vorbis' | 'audio/vnd.wav'; -------------------------------------------------------------------------------- /src/api/core/types/types/content-type.type.ts: -------------------------------------------------------------------------------- 1 | export type ContentType = 'application/json' | 'multipart/form-data'; -------------------------------------------------------------------------------- /src/api/core/types/types/database-engine.type.ts: -------------------------------------------------------------------------------- 1 | export type DatabaseEngine = 'mysql' | 'mariadb' | 'postgres' | 'cockroachdb' | 'sqlite' |'mssql' | 'sap' | 'oracle' | 'nativescript' | 'react-native' | 'sqljs' | 'mongodb' | 'aurora-data-api' | 'aurora-data-api-pg' | 'expo' | 'better-sqlite3'; -------------------------------------------------------------------------------- /src/api/core/types/types/document-mime-type.type.ts: -------------------------------------------------------------------------------- 1 | export type DocumentMimeType = 'application/vnd.ms-excel' | 'application/vnd.ms-powerpoint' | 'application/msword' | 'application/pdf' | 'application/vnd.oasis.opendocument.presentation' | 'application/vnd.oasis.opendocument.spreadsheet' | 'application/vnd.oasis.opendocument.text' | 'text/csv'; -------------------------------------------------------------------------------- /src/api/core/types/types/environment-cluster.type.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseEngine, MomentUnit } from '@types'; 2 | 3 | type EnvAccessToken = { DURATION: number, SECRET: string, UNIT: MomentUnit }; 4 | type EnvOauth = { KEY: string, IS_ACTIVE: boolean, ID: string, SECRET: string, CALLBACK_URL: string }; 5 | type EnvMemoryCache = { IS_ACTIVE: boolean, DURATION: number }; 6 | type EnvSSL = { IS_ACTIVE: boolean, CERT: string, KEY: string }; 7 | type EnvTypeorm = { DB: string, NAME: string, TYPE: DatabaseEngine, HOST: string, PORT: number, PWD: string, USER: string, SYNC: boolean, LOG: boolean, CACHE: boolean, ENTITIES: string, MIGRATIONS: string, SUBSCRIBERS: string }; 8 | type EnvLog = { PATH: string, TOKEN: string }; 9 | type EnvUpload = { MAX_FILE_SIZE: number, MAX_FILES: number, PATH: string, WILDCARDS: string[] }; 10 | type EnvImageScaling = { IS_ACTIVE: boolean, PATH_MASTER: string, PATH_SCALE: string, SIZES: { XS: number, SM: number, MD: number, LG: number, XL: number } }; 11 | type EnvRefreshToken = { DURATION: number, UNIT: MomentUnit }; 12 | 13 | export { EnvAccessToken, EnvOauth, EnvMemoryCache, EnvSSL, EnvTypeorm, EnvLog, EnvUpload, EnvImageScaling, EnvRefreshToken } -------------------------------------------------------------------------------- /src/api/core/types/types/fieldname.type.ts: -------------------------------------------------------------------------------- 1 | export type Fieldname = 'avatar' | 'screenshot' | 'banner' | 'invoice' | 'teaser' | 'back-up' | 'song'; -------------------------------------------------------------------------------- /src/api/core/types/types/http-method.type.ts: -------------------------------------------------------------------------------- 1 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; -------------------------------------------------------------------------------- /src/api/core/types/types/image-mime-type.type.ts: -------------------------------------------------------------------------------- 1 | export type ImageMimeType = 'image/bmp' | 'image/gif' | 'image/jpg' | 'image/jpeg' | 'image/png'; -------------------------------------------------------------------------------- /src/api/core/types/types/index.ts: -------------------------------------------------------------------------------- 1 | import { ArchiveMimeType } from './archive-mime-type.type'; 2 | import { AudioMimeType } from './audio-mime-type.type'; 3 | import { ContentType } from './content-type.type'; 4 | import { DatabaseEngine } from './database-engine.type'; 5 | import { DocumentMimeType } from './document-mime-type.type'; 6 | import { Fieldname } from './fieldname.type'; 7 | import { HttpMethod } from './http-method.type'; 8 | import { ImageMimeType } from './image-mime-type.type'; 9 | import { MediaType } from './media-type.type'; 10 | import { MomentUnit } from './moment-unit.type'; 11 | import { OAuthProvider } from './oauth-provider.type'; 12 | import { Role } from './role.type'; 13 | import { Status } from './status.type'; 14 | import { VideoMimeType } from './video-mime-type.type'; 15 | import { EnvOauth, EnvAccessToken, EnvMemoryCache, EnvSSL, EnvTypeorm, EnvLog, EnvUpload, EnvImageScaling, EnvRefreshToken } from './environment-cluster.type'; 16 | import { MimeType } from './mime-type.type'; 17 | 18 | export { 19 | ArchiveMimeType, 20 | AudioMimeType, 21 | ContentType, 22 | DatabaseEngine, 23 | DocumentMimeType, 24 | Fieldname, 25 | HttpMethod, 26 | ImageMimeType, 27 | MediaType, 28 | MimeType, 29 | MomentUnit, 30 | OAuthProvider, 31 | Role, 32 | Status, 33 | VideoMimeType, 34 | EnvAccessToken, 35 | EnvOauth, 36 | EnvMemoryCache, 37 | EnvSSL, 38 | EnvTypeorm, 39 | EnvLog, 40 | EnvUpload, 41 | EnvImageScaling, 42 | EnvRefreshToken 43 | } -------------------------------------------------------------------------------- /src/api/core/types/types/media-type.type.ts: -------------------------------------------------------------------------------- 1 | export type MediaType = 'archive' | 'audio' | 'document' | 'image' | 'video'; -------------------------------------------------------------------------------- /src/api/core/types/types/mime-type.type.ts: -------------------------------------------------------------------------------- 1 | import { AudioMimeType, ArchiveMimeType, DocumentMimeType, ImageMimeType, VideoMimeType } from '@types' 2 | 3 | /** 4 | * @description Shortcut for all mime-types 5 | */ 6 | export type MimeType = AudioMimeType | ArchiveMimeType | DocumentMimeType | ImageMimeType | VideoMimeType; -------------------------------------------------------------------------------- /src/api/core/types/types/moment-unit.type.ts: -------------------------------------------------------------------------------- 1 | export type MomentUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years'; -------------------------------------------------------------------------------- /src/api/core/types/types/oauth-provider.type.ts: -------------------------------------------------------------------------------- 1 | export type OAuthProvider = 'facebook' | 'google' | 'linkedin' | 'github'; -------------------------------------------------------------------------------- /src/api/core/types/types/role.type.ts: -------------------------------------------------------------------------------- 1 | export type Role = 'admin' | 'user' | 'ghost'; -------------------------------------------------------------------------------- /src/api/core/types/types/status.type.ts: -------------------------------------------------------------------------------- 1 | export type Status = 'REGISTERED' | 'SUBMITTED' | 'CONFIRMED' | 'VALIDATED' | 'QUARANTINED' | 'BANNED' | 'REVIEWED'; -------------------------------------------------------------------------------- /src/api/core/types/types/video-mime-type.type.ts: -------------------------------------------------------------------------------- 1 | export type VideoMimeType = 'video/mp4' | 'application/x-mpegURL' | 'video/3gpp' | 'video/quicktime' | 'video/x-msvideo' | 'video/x-ms-wmv'; -------------------------------------------------------------------------------- /src/api/core/utils/date.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Get age from birthdate 3 | * @param dateString 4 | */ 5 | const getAge = ( dateString: string ): number => { 6 | const today = new Date(); 7 | const birthDate = new Date( dateString ); 8 | let age = today.getFullYear() - birthDate.getFullYear(); 9 | const m = today.getMonth() - birthDate.getMonth(); 10 | if ( m < 0 || ( m === 0 && today.getDate() < birthDate.getDate() ) ) { 11 | age--; 12 | } 13 | return age; 14 | } 15 | 16 | export { getAge } -------------------------------------------------------------------------------- /src/api/core/utils/enum.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description List enum values 3 | * @param enm Enum to list 4 | */ 5 | const list = ( enm: Record ): string[] => { 6 | const values = [] as string[]; 7 | for(const key in enm) { 8 | values.push(enm[key] as string); 9 | } 10 | return values; 11 | }; 12 | 13 | export { list }; -------------------------------------------------------------------------------- /src/api/core/utils/error.util.ts: -------------------------------------------------------------------------------- 1 | import { IError } from '@interfaces'; 2 | 3 | /** 4 | * @description Get error status code 5 | * @param error Error object 6 | * @returns HTTP status code 7 | */ 8 | const getErrorStatusCode = (error: IError): number => { 9 | if(typeof(error.statusCode) !== 'undefined') return error.statusCode; 10 | if(typeof(error.status) !== 'undefined') return error.status; 11 | if(typeof(error?.output?.statusCode) !== 'undefined') return error.output.statusCode; 12 | return 500; 13 | }; 14 | 15 | export { getErrorStatusCode }; -------------------------------------------------------------------------------- /src/api/core/utils/http.util.ts: -------------------------------------------------------------------------------- 1 | import { CREATED, OK, NO_CONTENT, NOT_FOUND } from 'http-status'; 2 | 3 | /** 4 | * @description Get the HTTP status code to output for current request 5 | * 6 | * @param method 7 | * @param hasContent 8 | */ 9 | const getStatusCode = (method: string, hasContent: boolean): number => { 10 | switch (method) { 11 | case 'GET': 12 | return OK; 13 | case 'POST': 14 | return hasContent ? CREATED : NO_CONTENT; 15 | case 'PUT': 16 | case 'PATCH': 17 | return hasContent ? OK : NO_CONTENT; 18 | case 'DELETE': 19 | return NO_CONTENT 20 | } 21 | } 22 | 23 | export { getStatusCode } -------------------------------------------------------------------------------- /src/api/core/utils/object.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description 3 | * 4 | * @param val 5 | */ 6 | const isObject = (val: unknown): boolean => typeof val === 'object' && val !== null; 7 | 8 | export { isObject } -------------------------------------------------------------------------------- /src/api/core/utils/pagination.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param page 4 | * @param perPage 5 | * @param total 6 | * 7 | * @returns 8 | */ 9 | const paginate = (page: number = 1, perPage: number = 25, total: number): { current: number, prev: number, next: number } => { 10 | const sumOfPages = Math.ceil(total / perPage); 11 | return { 12 | current: page || 1, 13 | prev: page > 1 ? page - 1 : null, 14 | next: page < sumOfPages ? page + 1 : null 15 | }; 16 | } 17 | 18 | export { paginate } -------------------------------------------------------------------------------- /src/api/core/utils/string.util.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | import { createDecipheriv, createCipheriv,randomBytes } from 'crypto'; 3 | import { DOCUMENT_MIME_TYPE, ARCHIVE_MIME_TYPE, IMAGE_MIME_TYPE, AUDIO_MIME_TYPE, VIDEO_MIME_TYPE } from '@enums'; 4 | import { MediaType } from '@types'; 5 | 6 | import { list } from '@utils/enum.util'; 7 | 8 | const chars = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']; 9 | const numbers = ['0','1','2','3','4','5','6','7','8','9']; 10 | const symbols = ['@','#','&','$','.']; 11 | 12 | const algorithm = 'aes-256-cbc'; 13 | const key = randomBytes(32); 14 | const iv = randomBytes(16); 15 | 16 | /** 17 | * @decription Shuffle an array | string as array of chars 18 | * 19 | * @param a 20 | */ 21 | const shuffle = (a: string[]): string => { 22 | for (let i = a.length - 1; i > 0; i--) { 23 | const j = Math.floor(Math.random() * (i + 1)); 24 | [a[i], a[j]] = [a[j], a[i]]; 25 | } 26 | return a.join(''); 27 | }; 28 | 29 | /** 30 | * @description Hash a string 31 | * 32 | * @param str Base string to hash 33 | * @param length Number of chars to return 34 | */ 35 | const hash = (str: string, length: number): string => { 36 | const array = str.split('').concat(chars).concat(numbers).concat(symbols); 37 | return shuffle(array).substr(0, length); 38 | }; 39 | 40 | /** 41 | * @description Encode binary file in base64 42 | * @param path 43 | */ 44 | const base64Encode = (path: string): string => { 45 | const stream = readFileSync(path); 46 | return stream.toString('base64'); 47 | }; 48 | 49 | /** 50 | * @description Decode base64 encoded stream and write binary file 51 | * @param path 52 | */ 53 | const base64Decode = (stream: Buffer, path: string): void => { 54 | const size = stream.toString('ascii').length; 55 | writeFileSync(path, Buffer.alloc(size, stream, 'ascii')); 56 | }; 57 | 58 | /** 59 | * @description Decrypt text 60 | * 61 | * @param cipherText 62 | */ 63 | const decrypt = (cipherText: string): string => { 64 | const decipher = createDecipheriv(algorithm, key, iv) 65 | return decipher.update(cipherText, 'hex', 'utf8') + decipher.final('utf8') 66 | }; 67 | 68 | /** 69 | * @description Encrypt text 70 | * 71 | * @param text 72 | */ 73 | const encrypt = (text: string): string => { 74 | const cipher = createCipheriv(algorithm, key, iv) 75 | return cipher.update(text, 'utf8', 'hex') + cipher.final('hex') 76 | } 77 | 78 | /** 79 | * @description Get filename without extension 80 | * @param name Filename to parse 81 | */ 82 | const foldername = (name: string): string => { 83 | return name.lastIndexOf('.') !== -1 ? name.substring(0, name.lastIndexOf('.')) : name; 84 | }; 85 | 86 | /** 87 | * @description Get file extension with or without . 88 | * @param name Filename to parse 89 | * @param include Get extension with . if true, without . else 90 | */ 91 | const extension = (name: string, include = false): string => { 92 | return name.lastIndexOf('.') !== -1 ? include === true ? name.substring(name.lastIndexOf('.')) : name.substring(name.lastIndexOf('.') + 1) : name; 93 | }; 94 | 95 | /** 96 | * @description Determine media type from mime type 97 | * @param mimetype 98 | */ 99 | const getTypeOfMedia = (mimetype: string): string => { 100 | if ( DOCUMENT_MIME_TYPE[mimetype] ) { 101 | return 'document' 102 | } 103 | 104 | if ( ARCHIVE_MIME_TYPE[mimetype] ) { 105 | return 'archive'; 106 | } 107 | 108 | if ( IMAGE_MIME_TYPE[mimetype] ) { 109 | return 'image'; 110 | } 111 | 112 | if ( AUDIO_MIME_TYPE[mimetype] ) { 113 | return 'audio'; 114 | } 115 | 116 | if ( VIDEO_MIME_TYPE[mimetype] ) { 117 | return 'video'; 118 | } 119 | }; 120 | 121 | /** 122 | * 123 | * @param type 124 | */ 125 | const getMimeTypesOfType = (type: MediaType): string[] => { 126 | switch(type) { 127 | case 'archive': 128 | return list(ARCHIVE_MIME_TYPE); 129 | case 'audio': 130 | return list(AUDIO_MIME_TYPE); 131 | case 'document': 132 | return list(DOCUMENT_MIME_TYPE); 133 | case 'image': 134 | return list(IMAGE_MIME_TYPE); 135 | case 'video': 136 | return list(VIDEO_MIME_TYPE); 137 | } 138 | } 139 | 140 | export { base64Encode, base64Decode, decrypt, encrypt, extension, getTypeOfMedia, getMimeTypesOfType, foldername, hash, shuffle }; -------------------------------------------------------------------------------- /src/api/core/validations/auth.validation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | 5 | import * as Joi from 'joi'; 6 | 7 | import { email, password, username } from '@schemas'; 8 | 9 | // POST api/v1/auth/register 10 | const register = { 11 | body: Joi.object({ 12 | username: username(), 13 | email: email().required(), 14 | password: password('user').required() 15 | }) 16 | }; 17 | 18 | // POST api/v1/auth/login 19 | const login = { 20 | body: Joi.object({ 21 | email: Joi.when('context.apikey', { 22 | is: null, 23 | then: email().required(), 24 | otherwise: Joi.optional() 25 | }), 26 | password: Joi.when('context.apikey', { 27 | is: null, 28 | then: password('user').required(), 29 | otherwise: Joi.optional() 30 | }), 31 | apikey: Joi.when('context.password', { 32 | is: null, 33 | then: Joi.string().length(64).required(), 34 | otherwise: Joi.optional() 35 | }), 36 | refreshToken: Joi.string() 37 | }) 38 | }; 39 | 40 | // POST api/v1/auth/refresh 41 | const refresh = { 42 | body: Joi.object({ 43 | token: Joi.object().keys({ 44 | refreshToken: Joi.string().required(), 45 | }).required() 46 | }) 47 | }; 48 | 49 | // GEET api/v1/auth/:service/callback 50 | const oauthCb = { 51 | query: Joi.object({ 52 | code: Joi.string().required(), 53 | }) 54 | }; 55 | 56 | // PATCH api/v1/auth/confirm 57 | const confirm = { 58 | body: Joi.object({ 59 | token: Joi.string().min(64).required() 60 | }) 61 | }; 62 | 63 | // GET api/v1/auth/request-password 64 | const requestPassword = { 65 | query: Joi.object({ 66 | email: Joi.string().email().required() 67 | }) 68 | }; 69 | 70 | 71 | export { register, login, refresh, oauthCb, confirm, requestPassword }; -------------------------------------------------------------------------------- /src/api/core/validations/media.validation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | 5 | import * as Joi from 'joi'; 6 | 7 | import { MIME_TYPE_LIST, MEDIA_TYPE } from '@enums'; 8 | import { id, pagination, fieldname, filename, path, mimetype } from '@schemas'; 9 | import { list } from '@utils/enum.util'; 10 | 11 | // GET /v1/medias 12 | const listMedias = { 13 | query: Joi.object({ 14 | page: pagination('page'), 15 | perPage: pagination('perPage'), 16 | fieldname: fieldname(), 17 | filename: filename(false), 18 | path: path(), 19 | mimetype: mimetype(MIME_TYPE_LIST as string[]), 20 | size: Joi.number(), 21 | type: Joi.any().valid(...list(MEDIA_TYPE)), 22 | owner: Joi.number() 23 | }) 24 | }; 25 | 26 | // POST /v1/medias 27 | const insertMedia = { 28 | body: Joi.object({ 29 | files: Joi.array().items( 30 | Joi.object().keys({ 31 | fieldname: fieldname().required(), 32 | filename: filename().required(), 33 | path: path().required(), 34 | mimetype: mimetype(MIME_TYPE_LIST as string[]).required(), 35 | size: Joi.number().required(), 36 | owner: Joi.number().required() 37 | }) 38 | ) 39 | }) 40 | }; 41 | 42 | // GET /v1/medias/:mediaId 43 | const getMedia = { 44 | params: Joi.object({ 45 | mediaId: id() 46 | }) 47 | }; 48 | 49 | // PUT /v1/medias/:mediaId 50 | const replaceMedia = { 51 | params: Joi.object({ 52 | mediaId: id() 53 | }), 54 | body: Joi.object({ 55 | file: { 56 | fieldname: fieldname().required(), 57 | filename: filename().required(), 58 | path: path().required(), 59 | mimetype: mimetype(MIME_TYPE_LIST as string[]).required(), 60 | size: Joi.number().required(), 61 | owner: Joi.number().required() 62 | } 63 | }) 64 | }; 65 | 66 | // PATCH /v1/medias/:mediaId 67 | const updateMedia = { 68 | params: Joi.object({ 69 | mediaId: id() 70 | }), 71 | body: Joi.object({ 72 | file: { 73 | fieldname: fieldname(), 74 | filename: filename(), 75 | path: path(), 76 | mimetype: mimetype(MIME_TYPE_LIST as string[]), 77 | size: Joi.number(), 78 | owner: Joi.number() 79 | } 80 | }) 81 | }; 82 | 83 | // DELETE /v1/medias/:mediaId 84 | const removeMedia = { 85 | params: Joi.object({ 86 | mediaId: id() 87 | }) 88 | }; 89 | 90 | export { listMedias, insertMedia, getMedia, replaceMedia, updateMedia, removeMedia }; -------------------------------------------------------------------------------- /src/api/core/validations/user.validation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | 5 | import * as Joi from 'joi'; 6 | import { ROLE, FIELDNAME, STATUS } from '@enums'; 7 | import { list } from '@utils/enum.util'; 8 | 9 | import { email, id, pagination, username, password, file } from '@schemas'; 10 | 11 | // GET api/v1/users 12 | const listUsers = { 13 | query: Joi.object({ 14 | page: pagination('page'), 15 | perPage: pagination('perPage'), 16 | username: username(), 17 | email: email(), 18 | role: Joi.any().valid(...list(ROLE)), 19 | status: Joi.any().valid(...list(STATUS)), 20 | website: Joi.string().uri() 21 | }) 22 | }; 23 | 24 | // GET api/v1/users/userId 25 | const getUser = { 26 | params: Joi.object({ 27 | userId: id() 28 | }) 29 | }; 30 | 31 | // POST api/v1/users 32 | const createUser = { 33 | body: Joi.object({ 34 | username: username().required(), 35 | email: email().required(), 36 | password: password('user').required(), 37 | status: Joi.any().valid(...list(STATUS)).optional(), 38 | avatar: file( FIELDNAME.avatar ).allow(null), 39 | role: Joi.any().valid(...list(ROLE)) 40 | }) 41 | }; 42 | 43 | // PUT api/v1/users/:userId 44 | const replaceUser = { 45 | params: Joi.object({ 46 | userId: id() 47 | }), 48 | body: Joi.object({ 49 | username: username().required(), 50 | email: email().required(), 51 | password: password('user').required(), 52 | status: Joi.any().valid(...list(STATUS)).required(), 53 | avatar: file( FIELDNAME.avatar ).allow(null), 54 | role: Joi.any().valid(...list(ROLE)).required() 55 | }) 56 | }; 57 | 58 | // PATCH api/v1/users/:userId 59 | const updateUser = { 60 | params: Joi.object({ 61 | userId: id(), 62 | }), 63 | body: Joi.object({ 64 | username: username(), 65 | email: email(), 66 | isUpdatePassword: Joi.boolean().optional(), 67 | password: password('user'), 68 | passwordConfirmation: Joi.when('password', { 69 | is: password('user').required(), 70 | then: Joi.any().equal( Joi.ref('password') ).required().label('Confirm password').messages({ 'any.only': '{{#label}} does not match' }), 71 | otherwise: Joi.optional() 72 | }), 73 | passwordToRevoke: Joi.when('isUpdatePassword', { 74 | is: Joi.any().equal(true).required(), 75 | then: password('user').required(), 76 | otherwise: Joi.optional() 77 | }), 78 | status: Joi.any().valid(...list(STATUS)).optional(), 79 | avatar: file( FIELDNAME.avatar ).allow(null), 80 | role: Joi.any().valid(...list(ROLE)) 81 | }) 82 | }; 83 | 84 | // DELETE api/v1/users/:userId 85 | const removeUser = { 86 | params: Joi.object({ 87 | userId: id() 88 | }) 89 | }; 90 | 91 | export { listUsers, getUser, createUser, replaceUser, updateUser, removeUser }; -------------------------------------------------------------------------------- /src/api/shared/services/business.service.ts: -------------------------------------------------------------------------------- 1 | import { BusinessError } from '@errors'; 2 | import { HttpMethod } from '@types'; 3 | 4 | import { User } from '@models/user.model'; 5 | import { Media } from '@models/media.model'; 6 | 7 | import { IUserRequest } from '@interfaces/user-request.interface'; 8 | import { IMediaRequest } from '@interfaces/media-request.interface'; 9 | 10 | import { BusinessRule } from '@shared/types/business-rule.type'; 11 | 12 | /** 13 | * @class BusinessService 14 | * 15 | * @summary Mother class of business services 16 | */ 17 | export abstract class BusinessService { 18 | 19 | readonly BUSINESS_RULES: BusinessRule[]; 20 | 21 | constructor() {} 22 | 23 | /** 24 | * @description Valid a set of business rules 25 | * 26 | * @param entity 27 | * @param req 28 | * 29 | * @throws BusinessError 30 | */ 31 | valid(entity: User|Media, req: IUserRequest|IMediaRequest): boolean { 32 | return this.BUSINESS_RULES 33 | .filter(rule => rule.methods.includes(req.method as HttpMethod)) 34 | .reduce((acc, current) => { 35 | if (!current.check(req.user as User, entity, req)) { 36 | throw new BusinessError( { name: 'BusinessError', statusCode: current.statusCode, message: current.description } ); 37 | } 38 | return acc && true; 39 | }, true); 40 | } 41 | } -------------------------------------------------------------------------------- /src/api/shared/types/business-rule.type.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from '@types'; 2 | 3 | import { User } from '@models/user.model'; 4 | import { Media } from '@models/media.model'; 5 | 6 | import { IUserRequest } from '@interfaces/user-request.interface'; 7 | import { IMediaRequest } from '@interfaces/media-request.interface'; 8 | 9 | /** 10 | * @description Generic structure of a business rule 11 | */ 12 | export type BusinessRule = { 13 | key: string, 14 | description: string, 15 | statusCode: number, 16 | methods: HttpMethod[], 17 | check(user: User, entity: User|Media, payload: IUserRequest|IMediaRequest): boolean 18 | }; 19 | -------------------------------------------------------------------------------- /src/templates/business.service.txt: -------------------------------------------------------------------------------- 1 | import { ROLE, STATUS } from '@enums'; 2 | 3 | import { User } from '@models/user.model'; 4 | 5 | import { {{PASCAL_CASE}} } from '@resources/{{LOWER_CASE}}/{{LOWER_CASE}}.model'; 6 | import { I{{PASCAL_CASE}}Request } from '@resources/{{LOWER_CASE}}/{{LOWER_CASE}}-request.interface'; 7 | 8 | import { BusinessRule } from '@shared/types/business-rule.type'; 9 | import { BusinessService } from '@shared/services/business.service'; 10 | 11 | /** 12 | * @description 13 | */ 14 | class {{PASCAL_CASE}}BusinessService extends BusinessService { 15 | 16 | /** 17 | * @description 18 | */ 19 | private static instance: {{PASCAL_CASE}}BusinessService; 20 | 21 | /** 22 | * @description 23 | */ 24 | readonly BUSINESS_RULES: BusinessRule[] = [ 25 | { 26 | key: '{{UPPER_CASE}}_CAN_BE_SUBMITTED_BY_CONFIRMED_USER_ONLY', 27 | description: 'A {{LOWER_CASE}} can be submitted by a confirmed user only.', 28 | statusCode: 403, 29 | methods: [ 30 | 'POST' 31 | ], 32 | check: (user: User, entity: {{PASCAL_CASE}}, payload: I{{PASCAL_CASE}}Request): boolean => { 33 | if (user.status !== STATUS.CONFIRMED) { 34 | return false; 35 | } 36 | return true; 37 | } 38 | } 39 | ]; 40 | 41 | constructor() { 42 | super(); 43 | } 44 | 45 | /** 46 | * @description 47 | */ 48 | static get(): {{PASCAL_CASE}}BusinessService { 49 | if (!{{PASCAL_CASE}}BusinessService.instance) { 50 | {{PASCAL_CASE}}BusinessService.instance = new {{PASCAL_CASE}}BusinessService(); 51 | } 52 | return {{PASCAL_CASE}}BusinessService.instance; 53 | } 54 | } 55 | 56 | export { {{PASCAL_CASE}}BusinessService } -------------------------------------------------------------------------------- /src/templates/controller.txt: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | import { ApplicationDataSource } from '@config/database.config'; 4 | import { IRequest, IResponse } from '@interfaces'; 5 | import { Safe } from '@decorators/safe.decorator'; 6 | import { paginate } from '@utils/pagination.util'; 7 | 8 | import { {{PASCAL_CASE}}Repository } from '{{REPOSITORY}}'; 9 | import { {{PASCAL_CASE}} } from '{{MODEL}}'; 10 | 11 | /** 12 | * Manage incoming requests for api/{version}/{{LOWER_CASE_PLURAL}} 13 | */ 14 | class {{PASCAL_CASE}}Controller { 15 | 16 | /** 17 | * @description 18 | */ 19 | private static instance: {{PASCAL_CASE}}Controller; 20 | 21 | private constructor() {} 22 | 23 | /** 24 | * @description 25 | */ 26 | static get(): {{PASCAL_CASE}}Controller { 27 | if (!{{PASCAL_CASE}}Controller.instance) { 28 | {{PASCAL_CASE}}Controller.instance = new {{PASCAL_CASE}}Controller(); 29 | } 30 | return {{PASCAL_CASE}}Controller.instance; 31 | } 32 | 33 | /** 34 | * @description Retrieve one {{CAMEL_CASE}} according to :{{CAMEL_CASE}}Id 35 | * 36 | * @param req Express request object derived from http.incomingMessage 37 | * @param res Express response object 38 | * 39 | * @public 40 | */ 41 | @Safe() 42 | async get(req: IRequest, res: IResponse): Promise { 43 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 44 | const {{CAMEL_CASE}} = await repository.findOneOrFail({ where: { id: req.params.{{CAMEL_CASE}}Id } }); 45 | res.locals.data = {{CAMEL_CASE}}; 46 | } 47 | 48 | /** 49 | * @description Retrieve a list of {{CAMEL_CASE_PLURAL}}, according to some parameters 50 | * 51 | * @param req Express request object derived from http.incomingMessage 52 | * @param res Express response object 53 | */ 54 | @Safe() 55 | async list (req: IRequest, res: IResponse): Promise { 56 | const response = await {{PASCAL_CASE}}Repository.list(req.query); 57 | res.locals.data = response.result; 58 | res.locals.meta = { 59 | total: response.total, 60 | pagination: paginate( parseInt(req.query.page, 10), parseInt(req.query.perPage, 10), response.total ) 61 | } 62 | } 63 | 64 | /** 65 | * @description Create a new {{CAMEL_CASE}} 66 | * 67 | * @param req Express request object derived from http.incomingMessage 68 | * @param res Express response object 69 | * 70 | * @public 71 | */ 72 | @Safe() 73 | async create(req: IRequest, res: IResponse): Promise { 74 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 75 | const {{CAMEL_CASE}} = new {{PASCAL_CASE}}(req.body); 76 | const saved = await repository.save({{CAMEL_CASE}}); 77 | res.locals.data = saved; 78 | } 79 | 80 | /** 81 | * @description Update one {{CAMEL_CASE}} according to :{{CAMEL_CASE}}Id 82 | * 83 | * @param req Express request object derived from http.incomingMessage 84 | * @param res Express response object 85 | * 86 | * @public 87 | */ 88 | @Safe() 89 | async update(req: IRequest, res: IResponse): Promise { 90 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 91 | const {{CAMEL_CASE}} = await repository.findOneOrFail({ where: { id: req.params.{{CAMEL_CASE}}Id } }); 92 | if ({{CAMEL_CASE}}) { 93 | await repository.update(req.params.{{CAMEL_CASE}}Id, req.body); 94 | } 95 | res.locals.data = {{CAMEL_CASE}} ? req.body as {{PASCAL_CASE}} : undefined; 96 | } 97 | 98 | /** 99 | * @description Delete one {{CAMEL_CASE}} according to :{{CAMEL_CASE}}Id 100 | * 101 | * @param req Express request object derived from http.incomingMessage 102 | * @param res Express response object 103 | * 104 | * @public 105 | */ 106 | @Safe() 107 | async remove (req: IRequest, res: IResponse): Promise { 108 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 109 | const {{CAMEL_CASE}} = await repository.findOneOrFail({ where: { id: req.params.{{CAMEL_CASE}}Id } }); 110 | void repository.remove({{CAMEL_CASE}}); 111 | } 112 | } 113 | 114 | const {{CAMEL_CASE}}Controller = {{PASCAL_CASE}}Controller.get(); 115 | 116 | export { {{CAMEL_CASE}}Controller as {{PASCAL_CASE}}Controller } -------------------------------------------------------------------------------- /src/templates/data-layer.service.txt: -------------------------------------------------------------------------------- 1 | import { ApplicationDataSource } from '@config/database.config'; 2 | import { {{PASCAL_CASE}}Repository } from '@resources/{{LOWER_CASE}}/{{LOWER_CASE}}.repository'; 3 | import { {{PASCAL_CASE}} } from '@resources/{{LOWER_CASE}}/{{LOWER_CASE}}.model'; 4 | import { I{{PASCAL_CASE}}QueryString } from '@resources/{{LOWER_CASE}}/{{LOWER_CASE}}-query-string.interface'; 5 | import { I{{PASCAL_CASE}}Request } from './{{LOWER_CASE}}-request.interface'; 6 | import { paginate } from '@utils/pagination.util'; 7 | 8 | /** 9 | * @description 10 | */ 11 | class {{PASCAL_CASE}}DataLayerService { 12 | 13 | /** 14 | * @description 15 | */ 16 | private static instance: {{PASCAL_CASE}}DataLayerService; 17 | 18 | private constructor() {} 19 | 20 | /** 21 | * @description 22 | */ 23 | static get(): {{PASCAL_CASE}}DataLayerService { 24 | if (!{{PASCAL_CASE}}DataLayerService.instance) { 25 | {{PASCAL_CASE}}DataLayerService.instance = new {{PASCAL_CASE}}DataLayerService(); 26 | } 27 | return {{PASCAL_CASE}}DataLayerService.instance; 28 | } 29 | 30 | /** 31 | * @description Retrieve one {{LOWER_CASE}} according to :{{LOWER_CASE}}Id 32 | * 33 | * @param {{LOWER_CASE}}Id 34 | * 35 | * @public 36 | */ 37 | async get({{LOWER_CASE}}Id: string): Promise<{{PASCAL_CASE}}> { 38 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 39 | const {{LOWER_CASE}} = await repository.findOneOrFail({{LOWER_CASE}}Id, { relations: ] } ); 40 | return {{LOWER_CASE}}; 41 | } 42 | 43 | /** 44 | * @description Retrieve a list of {{LOWER_CASE_PLURAL}}, according to query parameters 45 | * 46 | * @param query 47 | */ 48 | async list (query: I{{PASCAL_CASE}}QueryString): Promise<{{PASCAL_CASE}}[]> { 49 | const response = await {{PASCAL_CASE}}Repository.list(query); 50 | res.locals.data = response.result; 51 | res.locals.meta = { 52 | total: response.total, 53 | pagination: paginate( parseInt(req.query.page, 10), parseInt(req.query.perPage, 10), response.total ) 54 | } 55 | return { 56 | data: res.locals.data, 57 | meta: res.locals.meta 58 | } 59 | } 60 | 61 | /** 62 | * @description Create a new {{LOWER_CASE}} 63 | * 64 | * @param payload 65 | * 66 | * @public 67 | */ 68 | async create( { body }: I{{PASCAL_CASE}}Request): Promise<{{PASCAL_CASE}}> { 69 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 70 | const {{LOWER_CASE}} = new {{PASCAL_CASE}}(body); 71 | const saved = await repository.save({{LOWER_CASE}}); 72 | return saved; 73 | } 74 | 75 | /** 76 | * @description Update one {{LOWER_CASE}} according to :{{LOWER_CASE}}Id 77 | * 78 | * @param {{LOWER_CASE}}Id 79 | * @param payload 80 | * 81 | * @public 82 | */ 83 | async update({{LOWER_CASE}}: {{PASCAL_CASE}}, { body }: I{{PASCAL_CASE}}Request): Promise<{{PASCAL_CASE}}> { 84 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 85 | repository.merge({{LOWER_CASE}}, body); 86 | const saved = await repository.save({{LOWER_CASE}}); 87 | return saved; 88 | } 89 | 90 | /** 91 | * @description Delete one {{LOWER_CASE}} according to :{{LOWER_CASE}}Id 92 | * 93 | * @param {{LOWER_CASE}}Id 94 | * 95 | * @public 96 | */ 97 | async remove ({{LOWER_CASE}}: {{PASCAL_CASE}}): Promise { 98 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 99 | void repository.remove({{LOWER_CASE}}); 100 | } 101 | 102 | } 103 | 104 | export { {{PASCAL_CASE}}DataLayerService } -------------------------------------------------------------------------------- /src/templates/fixture.txt: -------------------------------------------------------------------------------- 1 | const chance = require('chance').Chance(); 2 | 3 | exports.entity = { 4 | 5 | } -------------------------------------------------------------------------------- /src/templates/model.txt: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import * as Moment from 'moment-timezone'; 4 | 5 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; 6 | import { IModel } from '@interfaces'; 7 | 8 | @Entity() 9 | export class {{PASCAL_CASE}} implements IModel { 10 | 11 | @PrimaryGeneratedColumn() 12 | id: number; 13 | 14 | @Column({ 15 | type: Date, 16 | default: Moment( new Date() ).format('YYYY-MM-DD HH:ss') 17 | }) 18 | createdAt; 19 | 20 | @Column({ 21 | type: Date, 22 | default: null 23 | }) 24 | updatedAt; 25 | 26 | @Column({ 27 | type: Date, 28 | default: null 29 | }) 30 | deletedAt; 31 | 32 | /** 33 | * @param payload Object data to assign 34 | */ 35 | constructor(payload: Record) { 36 | Object.assign(this, payload); 37 | } 38 | 39 | /** 40 | * @description Allowed fields 41 | */ 42 | get whitelist(): string[] { 43 | return []; 44 | } 45 | } -------------------------------------------------------------------------------- /src/templates/query-string.interface.txt: -------------------------------------------------------------------------------- 1 | import { IQueryString } from '@interfaces'; 2 | 3 | export interface I{{PASCAL_CASE}}QueryString extends IQueryString {} -------------------------------------------------------------------------------- /src/templates/repository.txt: -------------------------------------------------------------------------------- 1 | import { omitBy, isNil } from 'lodash'; 2 | import { notFound } from'@hapi/boom'; 3 | 4 | import { ApplicationDataSource } from '@config/database.config'; 5 | import { {{PASCAL_CASE}} } from '{{MODEL}}'; 6 | 7 | export const {{PASCAL_CASE}}Repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}).extend({ 8 | 9 | constructor() { 10 | super(); 11 | } 12 | 13 | /** 14 | * @description Get one {{LOWER_CASE}} 15 | * 16 | * @param id - The id of {{LOWER_CASE}} 17 | * 18 | */ 19 | async one(id: number): Promise<{{PASCAL_CASE}}> { 20 | 21 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 22 | const options: { id: number } = omitBy({ id }, isNil) as { id: number }; 23 | 24 | const {{CAMEL_CASE}} = await repository.findOne({ 25 | where: options 26 | }); 27 | 28 | if (!{{CAMEL_CASE}}) { 29 | throw notFound('{{PASCAL_CASE}} not found'); 30 | } 31 | 32 | return {{CAMEL_CASE}}; 33 | } 34 | 35 | /** 36 | * Get a list of {{LOWER_CASE}}s according to current query parameters 37 | * 38 | * @public 39 | */ 40 | async list({ page = 1, perPage = 30 }: { page: number, perPage: number }): Promise<{{PASCAL_CASE}}[]> { 41 | 42 | const repository = ApplicationDataSource.getRepository({{PASCAL_CASE}}); 43 | const options = {}; /** @todo omitBy({}, isNil) **/ 44 | 45 | const [ result, total ] = await repository.find({ 46 | where: options, 47 | skip: ( page - 1 ) * perPage, 48 | take: perPage 49 | }); 50 | 51 | return { result, total }; 52 | } 53 | }) -------------------------------------------------------------------------------- /src/templates/request.interface.txt: -------------------------------------------------------------------------------- 1 | import { IRequest, IMedia } from '@interfaces'; 2 | 3 | /** 4 | * @description 5 | */ 6 | export interface I{{PASCAL_CASE}}Request extends IRequest { 7 | body: {} 8 | } -------------------------------------------------------------------------------- /src/templates/route.txt: -------------------------------------------------------------------------------- 1 | import { {{PASCAL_CASE}}Controller } from '{{CONTROLLER}}'; 2 | import { Router } from '@classes/router.class'; 3 | import { Guard } from '@middlewares/guard.middleware'; 4 | import { Validator } from '@middlewares/validator.middleware'; 5 | import { ROLE } from '@enums'; 6 | import { list{{PASCAL_CASE_PLURAL}}, insert{{PASCAL_CASE}}, get{{PASCAL_CASE}}, replace{{PASCAL_CASE}}, update{{PASCAL_CASE}}, remove{{PASCAL_CASE}} } from '{{VALIDATION}}'; 7 | 8 | export class {{PASCAL_CASE}}Router extends Router { 9 | 10 | constructor(){ 11 | super(); 12 | } 13 | 14 | /** 15 | * @description Plug routes definitions 16 | */ 17 | define(): void { 18 | 19 | this.router.route('/') 20 | 21 | /** 22 | * @api {get} api/v1/{{HYPHEN_PLURAL}} List {{CAMEL_CASE_PLURAL}} 23 | * @apiDescription Get a list of {{HYPHEN_PLURAL}} 24 | * @apiVersion 1.0.0 25 | * @apiName List{{PASCAL_CASE}} 26 | * @apiGroup {{PASCAL_CASE}} 27 | * @apiPermission ROLE.admin 28 | * 29 | * @apiUse BaseHeader 30 | * 31 | * @apiParam {Number{1-}} [page=1] List page 32 | * @apiParam {Number{1-100}} [perPage=1] {{PASCAL_CASE}}'s per page 33 | * 34 | * TODO: 35 | */ 36 | .get(Guard.authorize([{{PERMISSIONS}}]), Validator.check(list{{PASCAL_CASE_PLURAL}}), {{PASCAL_CASE}}Controller.list) 37 | 38 | /** 39 | * @api {post} api/v1/{{HYPHEN_PLURAL}} Create {{CAMEL_CASE_PLURAL}} 40 | * @apiDescription Create one or many new {{CAMEL_CASE_PLURAL}} 41 | * @apiVersion 1.0.0 42 | * @apiName Create{{PASCAL_CASE}} 43 | * @apiGroup {{PASCAL_CASE}} 44 | * @apiPermission user 45 | * 46 | * @apiUse BaseHeader 47 | * 48 | * TODO: 49 | */ 50 | .post(Guard.authorize([{{PERMISSIONS}}]), Validator.check(insert{{PASCAL_CASE}}), {{PASCAL_CASE}}Controller.create); 51 | 52 | this.router.route('/:{{CAMEL_CASE}}Id') 53 | 54 | /** 55 | * @api {get} api/v1/{{HYPHEN_PLURAL}}/:id Get one {{CAMEL_CASE}} 56 | * @apiDescription Get {{CAMEL_CASE}} 57 | * @apiVersion 1.0.0 58 | * @apiName Get{{PASCAL_CASE}} 59 | * @apiGroup {{PASCAL_CASE}} 60 | * @apiPermission user 61 | * 62 | * @apiUse BaseHeader 63 | * 64 | * TODO: 65 | */ 66 | .get(Guard.authorize([{{PERMISSIONS}}]), Validator.check(get{{PASCAL_CASE}}), {{PASCAL_CASE}}Controller.get) 67 | 68 | /** 69 | * @api {put} api/v1/{{HYPHEN_PLURAL}}/:id Replace {{CAMEL_CASE}} 70 | * @apiDescription Replace the whole {{CAMEL_CASE}} with a new one 71 | * @apiVersion 1.0.0 72 | * @apiName Replace{{PASCAL_CASE}} 73 | * @apiGroup {{PASCAL_CASE}} 74 | * @apiPermission user 75 | * 76 | * @apiUse BaseHeader 77 | * 78 | * TODO: 79 | */ 80 | .put(Guard.authorize([{{PERMISSIONS}}]), Validator.check(replace{{PASCAL_CASE}}), {{PASCAL_CASE}}Controller.update) 81 | 82 | /** 83 | * @api {patch} api/v1/{{HYPHEN_PLURAL}}/:id Update {{CAMEL_CASE}} 84 | * @apiDescription Update some fields of a {{CAMEL_CASE}} 85 | * @apiVersion 1.0.0 86 | * @apiName Update{{PASCAL_CASE}} 87 | * @apiGroup {{PASCAL_CASE}} 88 | * @apiPermission user 89 | * 90 | * @apiUse BaseHeader 91 | * 92 | * TODO: 93 | */ 94 | .patch(Guard.authorize([{{PERMISSIONS}}]), Validator.check(update{{PASCAL_CASE}}), {{PASCAL_CASE}}Controller.update) 95 | 96 | /** 97 | * @api {patch} api/v1/{{HYPHEN_PLURAL}}/:id Delete {{CAMEL_CASE}} 98 | * @apiDescription Delete a {{CAMEL_CASE}} 99 | * @apiVersion 1.0.0 100 | * @apiName Delete{{PASCAL_CASE}} 101 | * @apiGroup {{PASCAL_CASE}} 102 | * @apiPermission user 103 | * 104 | * @apiUse BaseHeader 105 | * 106 | * @apiError (Bad request 400) ValidationError Some parameters may contain invalid values 107 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data 108 | * @apiError (Forbidden 403) Forbidden Only ROLE.admins can access the data 109 | * @apiError (Not Found 404) NotFound {{PASCAL_CASE}} does not exist 110 | * 111 | * TODO: 112 | */ 113 | .delete(Guard.authorize([{{PERMISSIONS}}]), Validator.check(remove{{PASCAL_CASE}}), {{PASCAL_CASE}}Controller.remove); 114 | 115 | } 116 | } -------------------------------------------------------------------------------- /src/templates/subscriber.txt: -------------------------------------------------------------------------------- 1 | require('module-alias/register'); 2 | 3 | import * as Dayjs from 'dayjs'; 4 | 5 | import { EventSubscriber, EntitySubscriberInterface, InsertEvent, UpdateEvent, RemoveEvent } from 'typeorm'; 6 | import { {{PASCAL_CASE}} } from '@resources/{{LOWER_CASE}}/{{LOWER_CASE}}.model'; 7 | 8 | /** 9 | * 10 | */ 11 | @EventSubscriber() 12 | export class {{PASCAL_CASE}}Subscriber implements EntitySubscriberInterface<{{PASCAL_CASE}}> { 13 | 14 | /** 15 | * @description Indicates that this subscriber only listen to {{PASCAL_CASE}} events. 16 | */ 17 | listenTo(): any { 18 | return {{PASCAL_CASE}}; 19 | } 20 | 21 | /** 22 | * @description Called before {{PASCAL_CASE}} insertion. 23 | */ 24 | beforeInsert(event: InsertEvent<{{PASCAL_CASE}}>): void { 25 | event.entity.createdAt = Dayjs( new Date() ).toDate(); 26 | } 27 | 28 | /** 29 | * @description Called after {{PASCAL_CASE}} insertion. 30 | */ 31 | afterInsert(event: InsertEvent<{{PASCAL_CASE}}>): void {} 32 | 33 | /** 34 | * @description Called before {{PASCAL_CASE}} update. 35 | */ 36 | beforeUpdate(event: UpdateEvent<{{PASCAL_CASE}}>): void { 37 | event.entity.updatedAt = Dayjs( new Date() ).toDate(); 38 | } 39 | 40 | /** 41 | * @description Called after {{PASCAL_CASE}} update. 42 | */ 43 | afterUpdate(event: UpdateEvent<{{PASCAL_CASE}}>): void {} 44 | 45 | /** 46 | * @description Called after {{PASCAL_CASE}} deletion. 47 | */ 48 | afterRemove(event: RemoveEvent<{{PASCAL_CASE}}>): void {} 49 | 50 | } -------------------------------------------------------------------------------- /src/templates/test.txt: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { expect } = require('chai'); 3 | const { clone } = require('lodash'); 4 | 5 | let { server } = require(process.cwd() + '/dist/api/app.bootstrap'); 6 | 7 | const { user, {{CAMEL_CASE}} } = require(process.cwd() + '/test/utils/fixtures'); 8 | const { doRequest, doQueryRequest } = require(process.cwd() + '/test/utils'); 9 | 10 | describe("{{PASCAL_CASE}} routes", function () { 11 | 12 | let agent, token, unauthorizedToken, _{{CAMEL_CASE}}; 13 | 14 | before(function (done) { 15 | 16 | agent = request(server); 17 | 18 | doRequest(agent, 'post', '/api/v1/auth/register', null, null, user.entity('admin', 'e2q2mak7'), function(err, res) { 19 | token = res.body.token.accessToken; 20 | doRequest(agent, 'post', '/api/v1/auth/register', null, null, user.entity('user', 'e2q2mak7'), function(err, res) { 21 | unauthorizedToken = res.body.token.accessToken; 22 | done(); 23 | }); 24 | }); 25 | 26 | }); 27 | 28 | after(function () { 29 | server.close(); 30 | delete server; 31 | }); 32 | 33 | describe('POST /api/v1/{{HYPHEN_PLURAL}}', () => { 34 | 35 | it('201 - succeed', function (done) { 36 | const params = clone({{CAMEL_CASE}}); 37 | doRequest(agent, 'post', '/api/v1/{{HYPHEN_PLURAL}}', null, null, params, function(err, res) { 38 | expect(res.statusCode).to.eqls(201); 39 | _{{CAMEL_CASE}} = res.body; 40 | done(); 41 | }); 42 | }); 43 | 44 | }); 45 | 46 | describe('GET /api/v1/{{HYPHEN_PLURAL}}', () => { 47 | 48 | it('200 - ok', function (done) { 49 | doQueryRequest(agent, '/api/v1/{{HYPHEN_PLURAL}}', null, null, {}, function(err, res) { 50 | expect(res.statusCode).to.eqls(200); 51 | done(); 52 | }); 53 | }); 54 | 55 | }); 56 | 57 | describe('GET /api/v1/{{HYPHEN_PLURAL}}/:id', () => { 58 | 59 | it('200 - ok', function (done) { 60 | doQueryRequest(agent, `/api/v1/{{HYPHEN_PLURAL}}/`, _{{CAMEL_CASE}}.id, null, {}, function(err, res) { 61 | expect(res.statusCode).to.eqls(200); 62 | done(); 63 | }); 64 | }); 65 | 66 | }); 67 | 68 | describe('PUT /api/v1/{{HYPHEN_PLURAL}}/:id', () => { 69 | 70 | it('404 - not found', function (done) { 71 | const params = clone({{CAMEL_CASE}}); 72 | doRequest(agent, 'put', '/api/v1/{{HYPHEN_PLURAL}}/', 2569, token, params, function(err, res) { 73 | expect(res.statusCode).to.eqls(404); 74 | done(); 75 | }); 76 | }); 77 | 78 | it('200 - ok', function (done) { 79 | const params = clone({{CAMEL_CASE}}); 80 | doRequest(agent, 'put', '/api/v1/{{HYPHEN_PLURAL}}/', _{{CAMEL_CASE}}.id, token, params, function(err, res) { 81 | expect(res.statusCode).to.eqls(200); 82 | done(); 83 | }); 84 | }); 85 | 86 | }); 87 | 88 | describe('PATCH /api/v1/{{HYPHEN_PLURAL}}/:id', () => { 89 | 90 | it('404 - not found', function (done) { 91 | const params = clone({{CAMEL_CASE}}); 92 | doRequest(agent, 'patch', '/api/v1/{{HYPHEN_PLURAL}}/', 2569, token, params, function(err, res) { 93 | expect(res.statusCode).to.eqls(404); 94 | done(); 95 | }); 96 | }); 97 | 98 | it('200 - ok', function (done) { 99 | const params = clone({{CAMEL_CASE}}); 100 | doRequest(agent, 'patch', '/api/v1/{{HYPHEN_PLURAL}}/', _{{CAMEL_CASE}}.id, null, params, function(err, res) { 101 | expect(res.statusCode).to.eqls(200); 102 | done(); 103 | }); 104 | }); 105 | 106 | }); 107 | 108 | describe('DELETE /api/v1/{{HYPHEN_PLURAL}}/:id', () => { 109 | 110 | it('404 - not found', function (done) { 111 | agent 112 | .delete('/api/v1/{{HYPHEN_PLURAL}}/' + 2754) 113 | .set('Authorization', 'Bearer ' + token) 114 | .set('Origin', process.env.ORIGIN) 115 | .set('Content-Type', process.env.CONTENT_TYPE) 116 | .expect(404, done); 117 | }); 118 | 119 | it('204', function (done) { 120 | agent 121 | .delete('/api/v1/{{HYPHEN_PLURAL}}/' + _{{CAMEL_CASE}}.id) 122 | .set('Authorization', 'Bearer ' + token) 123 | .set('Origin', process.env.ORIGIN) 124 | .set('Content-Type', process.env.CONTENT_TYPE) 125 | .expect(204, done); 126 | }); 127 | 128 | }); 129 | 130 | }); -------------------------------------------------------------------------------- /src/templates/validation.txt: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | 5 | import * as Joi from 'joi'; 6 | 7 | // GET /v1/{{HYPHEN_PLURAL}} 8 | const list{{PASCAL_CASE_PLURAL}} = { 9 | query: Joi.object({ 10 | filter: Joi.number().min(0).max(1), 11 | page: Joi.number().min(1), 12 | perPage: Joi.number().min(1).max(100), 13 | }) 14 | }; 15 | 16 | // GET /v1/{{HYPHEN_PLURAL}}/:{{CAMEL_CASE}}Id 17 | const get{{PASCAL_CASE}} = { 18 | params: Joi.object({ 19 | {{CAMEL_CASE}}Id: Joi.string().regex(/^[0-9]{1,4}$/).required() 20 | }) 21 | }; 22 | 23 | // POST /v1/{{HYPHEN_PLURAL}} 24 | const insert{{PASCAL_CASE}} = { 25 | body: Joi.object({}) 26 | }; 27 | 28 | // PUT /v1/{{HYPHEN_PLURAL}}/:{{CAMEL_CASE}}Id 29 | const replace{{PASCAL_CASE}} = { 30 | body: Joi.object({}), 31 | params: Joi.object({ 32 | {{CAMEL_CASE}}Id: Joi.string().regex(/^[0-9]{1,4}$/).required(), 33 | }) 34 | }; 35 | 36 | // PATCH /v1/{{HYPHEN_PLURAL}}/:{{CAMEL_CASE}}Id 37 | const update{{PASCAL_CASE}} = { 38 | body: Joi.object({}), 39 | params: Joi.object({ 40 | {{CAMEL_CASE}}Id: Joi.string().regex(/^[0-9]{1,4}$/).required(), 41 | }) 42 | }; 43 | 44 | // DELETE /v1/{{HYPHEN_PLURAL}}/:{{CAMEL_CASE}}Id 45 | const remove{{PASCAL_CASE}} = { 46 | body: Joi.object({}), 47 | params: Joi.object({ 48 | {{CAMEL_CASE}}Id: Joi.string().regex(/^[0-9]{1,4}$/).required(), 49 | }) 50 | }; 51 | 52 | export { list{{PASCAL_CASE_PLURAL}}, get{{PASCAL_CASE}}, insert{{PASCAL_CASE}}, replace{{PASCAL_CASE}}, update{{PASCAL_CASE}}, remove{{PASCAL_CASE}} }; -------------------------------------------------------------------------------- /test/e2e/00-api.e2e.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | process.env.ENVIRONMENT = 'test'; 3 | process.env.ORIGIN = 'http://localhost:4200'; 4 | process.env.CONTENT_TYPE = 'application/json'; 5 | 6 | // --- API server 7 | 8 | var { server } = require(process.cwd() + '/dist/api/app.bootstrap'); 9 | 10 | describe('E2E API tests', () => { 11 | 12 | after(function () { 13 | server.close(); 14 | server = undefined; 15 | delete server; 16 | }); 17 | 18 | 19 | require('./01-api-routes.e2e.test'); 20 | require('./02-auth-routes.e2e.test'); 21 | require('./03-user-routes.e2e.test'); 22 | require('./04-media-routes.e2e.test'); 23 | 24 | }); -------------------------------------------------------------------------------- /test/e2e/01-api-routes.e2e.test.js: -------------------------------------------------------------------------------- 1 | let request = require('supertest'); 2 | 3 | let { server } = require(process.cwd() + '/dist/api/app.bootstrap'); 4 | 5 | describe('Routes resolving', () => { 6 | 7 | describe('/status', () => { 8 | 9 | it('200 - OK', (done) => { 10 | request(server) 11 | .get('/api/v1/status') 12 | .set('Content-Type', process.env.CONTENT_TYPE) 13 | .set('Origin', process.env.ORIGIN) 14 | .expect(200, done); 15 | }); 16 | 17 | }); 18 | 19 | describe('/report-violation', () => { 20 | 21 | it('204 - OK', (done) => { 22 | request(server) 23 | .post('/api/v1/report-violation') 24 | .set('Content-Type', process.env.CONTENT_TYPE) 25 | .set('Origin', process.env.ORIGIN) 26 | .send({ data: 'report-violation' }) 27 | .expect(204, done); 28 | }); 29 | 30 | }); 31 | 32 | describe('/*', () => { 33 | 34 | it('404 - anything', (done) => { 35 | request(server) 36 | .get('/api/v1/foo/bar') 37 | .set('Content-Type', process.env.CONTENT_TYPE) 38 | .set('Accept', process.env.CONTENT_TYPE) 39 | .set('Origin', process.env.ORIGIN) 40 | .expect(404, done); 41 | }); 42 | 43 | it('406 - content-type header not present', (done) => { 44 | request(server) 45 | .get('/api/v1/status') 46 | .set('Accept', process.env.CONTENT_TYPE) 47 | .set('Origin', process.env.ORIGIN) 48 | .expect(406, done); 49 | }); 50 | 51 | it('406 - content-type header not allowed', (done) => { 52 | request(server) 53 | .get('/api/v1/status') 54 | .set('Accept', process.env.CONTENT_TYPE) 55 | .set('Origin', process.env.ORIGIN) 56 | .set('Content-Type', 'application/graphql') 57 | .expect(406, done); 58 | }); 59 | 60 | it('406 - origin header not present', (done) => { 61 | request(server) 62 | .get('/api/v1/status') 63 | .set('Accept', process.env.CONTENT_TYPE) 64 | .set('Content-Type', process.env.CONTENT_TYPE) 65 | .expect(406, done); 66 | }); 67 | 68 | it('406 - domain not allowed by CORS', function (done) { 69 | request(server) 70 | .get('/api/v1/status') 71 | .set('Accept', process.env.CONTENT_TYPE) 72 | .set('Content-Type', process.env.CONTENT_TYPE) 73 | .set('Origin', 'http://www.test.com') 74 | .expect(406, done); 75 | }); 76 | 77 | }); 78 | 79 | }); -------------------------------------------------------------------------------- /test/units/00-application.unit.test.js: -------------------------------------------------------------------------------- 1 | let { server } = require(process.cwd() + '/dist/api/app.bootstrap'); 2 | 3 | describe('Units tests', () => { 4 | 5 | before( () => {} ); 6 | 7 | after( () => { 8 | server.close(); 9 | delete server; 10 | }); 11 | 12 | require('./01-express-app.unit.test'); 13 | require('./02-config.unit.test'); 14 | require('./03-utils.unit.test'); 15 | require('./04-services.unit.test'); 16 | require('./05-middlewares.unit.test'); 17 | require('./06-factories.unit.test'); 18 | require('./07-errors.unit.test'); 19 | 20 | }); -------------------------------------------------------------------------------- /test/units/01-express-app.unit.test.js: -------------------------------------------------------------------------------- 1 | const { application } = require(process.cwd() + '/dist/api/app.bootstrap'); 2 | 3 | const pkgInfo = require(process.cwd() + '/package.json'); 4 | const expect = require('chai').expect; 5 | 6 | describe('Express application', () => { 7 | 8 | it('Express instance type is function', () => { 9 | expect(typeof(application)).to.equal('function'); 10 | }); 11 | 12 | it('Express server version is 4.21.2', () => { 13 | expect(pkgInfo.dependencies.express).to.equal('4.21.2'); 14 | }); 15 | 16 | }); -------------------------------------------------------------------------------- /test/units/05-middlewares.unit.test.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect; 2 | const sinon = require('sinon'); 3 | 4 | const { CacheConfiguration } = require(process.cwd() + '/dist/api/config/cache.config'); 5 | const { CacheService } = require(process.cwd() + '/dist/api/core/services/cache.service'); 6 | const { Cache } = require(process.cwd() + '/dist/api/core/middlewares/cache.middleware'); 7 | const { Uploader } = require(process.cwd() + '/dist/api/core/middlewares/uploader.middleware'); 8 | 9 | describe('Middlewares', () => { 10 | 11 | describe('Cache', () => { 12 | 13 | const res = { 14 | status: (code) => {}, 15 | json: (data) => {} 16 | }; 17 | 18 | let stubEngine, stubIsActive, stubResJSON; 19 | 20 | beforeEach( () => { 21 | stubEngine = sinon.stub(CacheService.engine, 'get'); 22 | stubResJSON = sinon.stub(res, 'json'); 23 | stubIsActive = sinon.stub(CacheConfiguration.options, 'IS_ACTIVE'); 24 | }); 25 | 26 | afterEach( () => { 27 | stubEngine.restore(); 28 | stubResJSON.restore(); 29 | stubIsActive.restore(); 30 | }); 31 | 32 | it('should next when not activated', async () => { 33 | stubIsActive.value(false); 34 | stubEngine.callsFake((key) => {}); 35 | await Cache.read({method: 'GET'}, {}, () => {}); 36 | expect(stubEngine.called).to.be.false; 37 | }); 38 | 39 | 40 | it('should next when method is not GET', async () => { 41 | stubIsActive.value(true) 42 | stubEngine.callsFake((key) => {}); 43 | await Cache.read({method: 'POST'}, {}, () => {}); 44 | expect(stubEngine.called).to.be.false; 45 | }); 46 | 47 | it('should try to retrieve cached data', async () => { 48 | stubIsActive.value(true) 49 | stubEngine.callsFake((key) => {}); 50 | await Cache.read({method: 'GET'}, {}, () => {}); 51 | expect(stubEngine.called).to.be.true; 52 | }); 53 | 54 | it('should output the cached data', async () => { 55 | stubIsActive.value(true) 56 | stubEngine.callsFake((key) => { 57 | return { body: 'Hello World' }; 58 | }); 59 | stubResJSON.callsFake((data) => {}); 60 | await Cache.read({method: 'GET'}, res, () => {}); 61 | expect(stubEngine.called).to.be.true; 62 | expect(stubResJSON.called).to.be.true; 63 | }); 64 | 65 | it('should next if not available cached data', async () => { 66 | stubIsActive.value(true) 67 | stubEngine.callsFake((key) => null); 68 | stubResJSON.callsFake((data) => {}); 69 | await Cache.read({method: 'GET'}, res, () => {}); 70 | expect(stubEngine.called).to.be.true; 71 | expect(stubResJSON.called).to.be.false; 72 | }); 73 | 74 | }); 75 | 76 | describe('Uploader', () => { 77 | 78 | it('should mix args options', (done) => { 79 | Uploader.upload( { maxFiles: 2, filesize: 5000 } )( null, null, (e) => { 80 | expect(Uploader.options.maxFiles).to.be.eqls(2); 81 | expect(Uploader.options.filesize).to.be.eqls(5000); 82 | done(); 83 | }) 84 | }); 85 | 86 | }); 87 | 88 | }); -------------------------------------------------------------------------------- /test/units/06-factories.unit.test.js: -------------------------------------------------------------------------------- 1 | const { ErrorFactory } = require(process.cwd() + '/dist/api/core/factories/error.factory'); 2 | const { ServerError } = require(process.cwd() + '/dist/api/core/types/errors/server.error'); 3 | 4 | const expect = require('chai').expect; 5 | 6 | describe('Factories', () => { 7 | 8 | describe('ErrorFactory', () => { 9 | 10 | it('should get Server Error', () => { 11 | expect(ErrorFactory.get(new Error('bad'))).to.be.instanceOf(ServerError); 12 | }); 13 | 14 | it('should get badImplementation Error', () => { 15 | const customError = function(message) { 16 | this.message = message; 17 | }; 18 | 19 | customError.prototype.name = 'CustomError'; 20 | 21 | const error = ErrorFactory.get(new customError('message')); 22 | 23 | expect(error.statusCode).to.be.eqls(500); 24 | expect(error.statusText).to.be.eqls('Internal Server Error'); 25 | }); 26 | 27 | }); 28 | 29 | }); -------------------------------------------------------------------------------- /test/units/07-errors.unit.test.js: -------------------------------------------------------------------------------- 1 | const { MySQLError } = require(process.cwd() + '/dist/api/core/types/errors/mysql.error'); 2 | 3 | const expect = require('chai').expect; 4 | 5 | describe('Errors', () => { 6 | 7 | describe('MySQLError', () => { 8 | 9 | [ 10 | { errno: 1052, status: 409 }, 11 | { errno: 1054, status: 409 }, 12 | { errno: 1062, status: 409 }, 13 | { errno: 1452, status: 409 }, 14 | { errno: 1364, status: 422 }, 15 | { errno: 1406, status: 422 }, 16 | { errno: 007, status: 422 } 17 | ].forEach(err => { 18 | 19 | it(`should get ${err.status} status`, () => { 20 | expect(new MySQLError(err).statusCode).to.be.eqls(err.status); 21 | }); 22 | 23 | }); 24 | 25 | }); 26 | 27 | }); -------------------------------------------------------------------------------- /test/utils/fixtures/entities/index.js: -------------------------------------------------------------------------------- 1 | exports.user = require('./user.fixture'); 2 | exports.media = require('./media.fixture'); 3 | exports.token = require('./token.fixture'); -------------------------------------------------------------------------------- /test/utils/fixtures/entities/media.fixture.js: -------------------------------------------------------------------------------- 1 | const { UPLOAD } = require(process.cwd() + '/dist/api/config/environment.config'); 2 | 3 | exports.image = (owner) => { 4 | return { 5 | fieldname: 'banner', 6 | filename: 'javascript.jpg', 7 | path: UPLOAD.PATH + '/images/master-copy/banner/javascript.jpg', 8 | mimetype: 'image/jpeg', 9 | size: '', 10 | owner: owner.id 11 | } 12 | }; 13 | 14 | exports.document = (owner) => { 15 | return { 16 | fieldname: 'document', 17 | filename: 'invoice.pdf', 18 | path: UPLOAD.PATH + '/documents/invoice.jpg', 19 | mimetype: 'application/pdf', 20 | size: '', 21 | owner: owner.id 22 | } 23 | }; 24 | 25 | exports.archive = (owner) => { 26 | return { 27 | fieldname: 'archive', 28 | filename: 'documents.rar', 29 | path: UPLOAD.PATH + '/archives/documents.rar', 30 | mimetype: 'archive/rar', 31 | size: '', 32 | owner: owner.id 33 | } 34 | }; -------------------------------------------------------------------------------- /test/utils/fixtures/entities/token.fixture.js: -------------------------------------------------------------------------------- 1 | const oauthFacebook = { 2 | id: '10226107961312549', 3 | username: undefined, 4 | displayName: undefined, 5 | name: { familyName: 'Doe', givenName: 'John', middleName: undefined }, 6 | gender: undefined, 7 | profileUrl: undefined, 8 | photos: [ 9 | { 10 | value: 'https://platform-lookaside.fbsbx.com/platform/profilepic/?asid=10226107961312549&height=50&width=50&ext=1618134835&hash=AeQG7JwQDHxpvYzf5Rk' 11 | } 12 | ], 13 | provider: 'facebook', 14 | _raw: '{"id":"10226107961312549","last_name":"Doe","first_name":"John","picture":{"data":{"height":49,"is_silhouette":false,"url":"https:\\/\\/platform-lookaside.fbsbx.com\\/platform\\/profilepic\\/?asid=10226107961312549&height=50&width=50&ext=1618134835&hash=AeQG7JwQDHxpvYzf5Rk","width":49}}}', 15 | _json: { 16 | id: '10226107961312549', 17 | last_name: 'Doe', 18 | first_name: 'John', 19 | picture: { data: [Object] } 20 | } 21 | } 22 | 23 | exports.oauthFacebook = oauthFacebook; 24 | 25 | const oauthGoogle = { 26 | id: '100381987564055936818', 27 | displayName: 'Steve Lebleu', 28 | name: { familyName: 'Lebleu', givenName: 'Steve' }, 29 | emails: [ { value: 'steve.lebleu1979@gmail.com', verified: true } ], 30 | photos: [ 31 | { 32 | value: 'https://lh6.googleusercontent.com/-QmuEXYjq2Ag/AAAAAAAAAAI/AAAAAAAAFT4/AMZuucmaVXn8Gopeny7NpT9A5uM5lJH6yQ/s96-c/photo.jpg' 33 | } 34 | ], 35 | provider: 'google', 36 | _raw: '{\n' + 37 | ' "sub": "100381987564055936818",\n' + 38 | ' "name": "Steve Lebleu",\n' + 39 | ' "given_name": "Steve",\n' + 40 | ' "family_name": "Lebleu",\n' + 41 | ' "picture": "https://lh6.googleusercontent.com/-QmuEXYjq2Ag/AAAAAAAAAAI/AAAAAAAAFT4/AMZuucmaVXn8Gopeny7NpT9A5uM5lJH6yQ/s96-c/photo.jpg",\n' + 42 | ' "email": "steve.lebleu1979@gmail.com",\n' + 43 | ' "email_verified": true,\n' + 44 | ' "locale": "fr"\n' + 45 | '}', 46 | _json: { 47 | sub: '100381987564055936818', 48 | name: 'Steve Lebleu', 49 | given_name: 'Steve', 50 | family_name: 'Lebleu', 51 | picture: 'https://lh6.googleusercontent.com/-QmuEXYjq2Ag/AAAAAAAAAAI/AAAAAAAAFT4/AMZuucmaVXn8Gopeny7NpT9A5uM5lJH6yQ/s96-c/photo.jpg', 52 | email: 'steve.lebleu1979@gmail.com', 53 | email_verified: true, 54 | locale: 'fr' 55 | } 56 | } 57 | 58 | exports.oauthGoogle = oauthGoogle; 59 | 60 | exports.accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjE5NzU4ODAsImlhdCI6MTYxNDcxODI4MCwic3ViIjoyfQ.qQTiFLLRIXuRLfMxQXfwul_UjIrWV5-x6CG2UIovpSA'; -------------------------------------------------------------------------------- /test/utils/fixtures/entities/user.fixture.js: -------------------------------------------------------------------------------- 1 | const chance = require('chance').Chance(); 2 | const pool = require(process.cwd() + '/test/utils').pools; 3 | 4 | const chars = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']; 5 | 6 | const shuffle = (a) => { 7 | for (let i = a.length - 1; i > 0; i--) { 8 | const j = Math.floor(Math.random() * (i + 1)); 9 | [a[i], a[j]] = [a[j], a[i]]; 10 | } 11 | return a.join(''); 12 | }; 13 | 14 | exports.entity = (pwd, apikey) => { 15 | return { 16 | avatar: null, 17 | status: 'REGISTERED', 18 | username: chance.string({ length: 16, pool: pool.username }), 19 | email: shuffle(chars).slice(0,10) + chance.email({ domain: 'example.com'} ), 20 | password: pwd || chance.hash({ length: 8 }), 21 | role: 'user' 22 | }; 23 | }; 24 | 25 | exports.register = (pwd, email) => { 26 | return { 27 | username: chance.string({ length: 16, pool: pool.username }), 28 | email: email || shuffle(chars).slice(0,10) + chance.email({ domain: 'example.com'} ), 29 | password: pwd || chance.hash({ length: 8 }) 30 | }; 31 | }; -------------------------------------------------------------------------------- /test/utils/fixtures/files/Responsive_Webdesign.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/Responsive_Webdesign.pdf -------------------------------------------------------------------------------- /test/utils/fixtures/files/Vue-Handbook.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/Vue-Handbook.pdf -------------------------------------------------------------------------------- /test/utils/fixtures/files/documents.rar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/documents.rar -------------------------------------------------------------------------------- /test/utils/fixtures/files/electric-bulb-2.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/electric-bulb-2.mp4 -------------------------------------------------------------------------------- /test/utils/fixtures/files/electric-bulb.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/electric-bulb.mp4 -------------------------------------------------------------------------------- /test/utils/fixtures/files/javascript.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/javascript.jpg -------------------------------------------------------------------------------- /test/utils/fixtures/files/kill-bill-vol-1-the-whistle-song.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/kill-bill-vol-1-the-whistle-song.mp3 -------------------------------------------------------------------------------- /test/utils/fixtures/files/syllabus_web.doc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/syllabus_web.doc -------------------------------------------------------------------------------- /test/utils/fixtures/files/tags.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steve-lebleu/typeplate/a5b106f546a8a9b30e32481da590a9add4e7b304/test/utils/fixtures/files/tags.tif -------------------------------------------------------------------------------- /test/utils/fixtures/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./entities') -------------------------------------------------------------------------------- /test/utils/index.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const Util = require('util'); 3 | 4 | exports.routes = { 5 | users: [ 6 | { 7 | method: 'GET', 8 | path: '', 9 | query: {}, 10 | params: {}, 11 | tests: [ 12 | { 13 | statusCode: 400 14 | }, 15 | { 16 | statusCode: 403 17 | }, 18 | { 19 | statusCode: 200 20 | } 21 | ] 22 | } 23 | ], 24 | medias: {} 25 | }; 26 | 27 | exports.pools = { 28 | password: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 29 | username: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 30 | }; 31 | 32 | exports.statusCodes = { 33 | "200": 200, 34 | "201": 201, 35 | "202": 202, 36 | "400": 400, 37 | "401": 401, 38 | "403": 403, 39 | "404": 404, 40 | "406": 406, 41 | "409": 409, 42 | "417": 417, 43 | "500": 500 44 | }; 45 | 46 | const _doRequest = (agent, method, route, id, token, payload, callback) => { 47 | const path = id !== null ? `${route}${id}` : route; 48 | return agent[method](path) 49 | .set('Authorization', 'Bearer ' + token) 50 | .set('Accept', process.env.CONTENT_TYPE) 51 | .set('Content-Type', process.env.CONTENT_TYPE) 52 | .set('Origin', process.env.ORIGIN) 53 | .send(payload) 54 | .end(function(err, res) { 55 | callback(err, res); 56 | }); 57 | }; 58 | 59 | exports.doRequest = _doRequest; 60 | 61 | const _doQueryRequest = (agent, route, id, token, payload, callback) => { 62 | const path = id !== null ? `${route}${id}` : route; 63 | return agent.get(path) 64 | .set('Authorization', 'Bearer ' + token) 65 | .set('Accept', process.env.CONTENT_TYPE) 66 | .set('Content-Type', process.env.CONTENT_TYPE) 67 | .set('Origin', process.env.ORIGIN) 68 | .query(payload) 69 | .end(function(err, res) { 70 | callback(err, res); 71 | }); 72 | }; 73 | 74 | exports.doQueryRequest = _doQueryRequest; 75 | 76 | const _doFormRequest = (agent, method, route, id, token, payload, callback) => { 77 | const path = id !== null ? `${route}${id}` : route; 78 | return agent[method](path) 79 | .set('Authorization', 'Bearer ' + token) 80 | .set('Accept', process.env.CONTENT_TYPE) 81 | .set('Content-Type', 'multipart/form-data') 82 | .set('Origin', process.env.ORIGIN) 83 | .attach(payload.name, payload.path) 84 | .end(function(err, res) { 85 | callback(err, res); 86 | }); 87 | }; 88 | 89 | exports.doFormRequest = _doFormRequest; 90 | 91 | const _expectations = (res, field, err) => { 92 | expect(res.body.statusCode).to.eqls(400); 93 | expect(res.body.errors).to.be.an('array').length.gt(0); 94 | expect(res.body.errors).satisfy(function(value) { 95 | return value.filter( error => error.field === field && error.types.includes(err)).length >= 1; 96 | }); 97 | }; 98 | 99 | exports.expectations = _expectations; 100 | 101 | exports.dataOk = (res, entity, method) => { 102 | const models = [ 103 | { 104 | name: 'user', 105 | expect: () => { 106 | // expect(res.body).to.have.all.keys('id', 'username', 'email', 'role', 'createdAt', 'updatedAt'); 107 | expect(res.body).to.have.not.keys(['password', 'apikey']); 108 | expect(res.body.id).to.be.a('number'); 109 | expect(res.body.username).to.be.a('string'); 110 | expect(res.body.email).to.be.a('string'); 111 | expect(res.body.email).to.match(/^[a-z-0-9\w\W\s]{2,}@[a-z]{2,}\.[a-z]{2,8}$/); 112 | expect(res.body.role).to.be.oneOf(['admin', 'user']) 113 | expect(res.body.createdAt).to.be.a('string'); 114 | expect(res.body.createdAt).satisfy(function(value) { 115 | return Util.types.isDate(new Date(value)); 116 | }); 117 | if(method === 'update') { 118 | expect(res.body.updatedAt).satisfy(function(value) { 119 | return Util.types.isDate(new Date(value)); 120 | }); 121 | } 122 | } 123 | }, 124 | { 125 | name: 'media', 126 | expect: () => { 127 | expect(res.body).to.have.all.keys(['id', 'fieldname', 'filename', 'mimetype', 'size', 'createdAt', 'updatedAt', 'owner']); 128 | expect(res.body.id).to.be.a('number'); 129 | expect(res.body.fieldname).to.be.a('string'); 130 | expect(res.body.filename).to.be.a('string'); 131 | expect(res.body.mimetype).to.be.a('string'); 132 | expect(res.body.size).to.be.a('number'); 133 | expect(res.body.createdAt).to.be.a('string'); 134 | expect(res.body.createdAt).satisfy(function(value) { 135 | return Util.types.isDate(new Date(value)); 136 | }); 137 | if(method === 'update') { 138 | expect(res.body.updatedAt).satisfy(function(value) { 139 | return Util.types.isDate(new Date(value)); 140 | }); 141 | } 142 | } 143 | } 144 | ]; 145 | return models.filter( model => model.name === entity ).shift().expect(); 146 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "removeComments": true, 5 | "outDir": "./dist/api", 6 | "sourceMap": true, 7 | "baseUrl": "./src", 8 | "paths": { 9 | "@core": ["api/core/*"], 10 | "@classes": ["api/core/types/classes"], 11 | "@classes/*": ["api/core/types/classes/*"], 12 | "@config/*": ["api/config/*"], 13 | "@controllers/*": ["api/core/controllers/*"], 14 | "@decorators/*": ["api/core/types/decorators/*"], 15 | "@enums": ["api/core/types/enums"], 16 | "@enums/*": ["api/core/types/enums/*"], 17 | "@errors": ["api/core/types/errors"], 18 | "@errors/*": ["api/core/types/errors/*"], 19 | "@events": ["api/core/types/events"], 20 | "@events/*": ["api/core/types/events/*"], 21 | "@factories/*": ["api/core/factories/*"], 22 | "@interfaces": ["api/core/types/interfaces"], 23 | "@interfaces/*": ["api/core/types/interfaces/*"], 24 | "@middlewares/*": ["api/core/middlewares/*"], 25 | "@models/*": ["api/core/models/*"], 26 | "@repositories/*": ["api/core/repositories/*"], 27 | "@resources/*": ["api/resources/*"], 28 | "@routes/*": ["api/core/routes/v1/*"], 29 | "@schemas": ["api/core/types/schemas"], 30 | "@schemas/*": ["api/core/types/schemas/*"], 31 | "@services/*": ["api/core/services/*"], 32 | "@shared/*": ["api/shared/*"], 33 | "@types": ["api/core/types/types"], 34 | "@types/*": ["api/core/types/types/*"], 35 | "@utils/*": ["api/core/utils/*"], 36 | "@validations/*": ["api/core/validations/*"] 37 | }, 38 | "lib": ["dom", "ESNext"], 39 | "target": "ESNext", 40 | "module": "CommonJS", 41 | "allowSyntheticDefaultImports": true, 42 | "emitDecoratorMetadata": true, 43 | "experimentalDecorators": true, 44 | "skipLibCheck": true 45 | }, 46 | "exclude" : [ 47 | "**/**/node_modules", 48 | "node_modules" 49 | ] 50 | } --------------------------------------------------------------------------------