diff --git a/.gitignore b/.gitignore index 18cc2d8..03b5a8c 100644 --- a/.gitignore +++ b/.gitignore @@ -82,4 +82,7 @@ reports/ /**/env/test.env # Husky configuration -.husky/ \ No newline at end of file +.husky/ + +# Roadmap +ROADMAP.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e505e5..d9c78a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -## [v1.1.4](https://github.com/konfer-be/ts-express-typeorm-boilerplate/compare/v1.1.3...v1.1.4) +## [v1.2.0](https://github.com/konfer-be/ts-express-typeorm-boilerplate/compare/v1.1.4...v1.2.0) + +### Commits + +- Testable and tested Environment [`e4f8ee5`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/e4f8ee5107e68b0d0b73c547b7da3267be4b1bc9) +- Facebook oAuth [`5e48eba`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/5e48eba62e533ab5f7d3b5ca6fce950268c54e0c) +- Google oAuth [`6edca6d`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/6edca6d72cc2c7211090e539a393350485e12aa1) +- UT services and utils [`f9f60ff`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/f9f60ff56b2a66e3408ad67e013e01e33dab01f2) +- Features description [`db08ce1`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/db08ce13c4bfb656f332bb166d5e95e1da8666ba) +- Update changelog [`13967d9`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/13967d9bc569cc5a1b0416fd5c48535e964581c4) +- Fix rescale paths [`ffbeecf`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/ffbeecf86c0855f44c28045efe3ad5b9fd7dc462) +- Implements Sinon.js for testing [`ace1417`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/ace14176816436218343621c902e9954b36efee8) +- Fix custom types path [`fea5621`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/fea562182a8ceb9f99c2132ff76643679f6925c3) + +## [v1.1.4](https://github.com/konfer-be/ts-express-typeorm-boilerplate/compare/v1.1.3...v1.1.4) - 2021-03-10 ### Commits - Middlewares as function expressions [`4c32368`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/4c323684fc413348530ac1668a6607d528880cc5) - Split/move relevant code as service [`0afd5c2`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/0afd5c2e589063b7533022bd03f00bd77f19d2e5) - JWT & oauth as auth services [`3fc0597`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/3fc059707cab002e3992a3101c744f4e7511acee) +- Update changelog [`1de524d`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/1de524d1ed0da4927da10208ad34844fa467a514) +- Sinon.js [`e386b8f`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/e386b8fb80d4c3dbbad06d88460bf3a5a0f552f9) +- Bump to version 1.1.4 [`940373b`](https://github.com/konfer-be/ts-express-typeorm-boilerplate/commit/940373b8ad7df3d919b3a4158dddd48ad57f1eb8) ## [v1.1.3](https://github.com/konfer-be/ts-express-typeorm-boilerplate/compare/v1.1.2...v1.1.3) - 2021-03-10 diff --git a/README.md b/README.md index 00a684c..bf78362 100644 --- a/README.md +++ b/README.md @@ -15,135 +15,139 @@ [![MIT Licence](https://badges.frapsoft.com/os/mit/mit.svg?v=103)](https://opensource.org/licenses/mit-license.php) ![Discord](https://img.shields.io/discord/817108781291929641) -Small but badass RESTful API boilerplate builded with [Express.js](http://expressjs.com/en/4x/api.html), [Typescript](https://github.com/Microsoft/TypeScript) [TypeORM](https://github.com/typeorm/typeorm) and [Mocha](https://mochajs.org/). 🀘 +Small but badass & ready to use RESTful API boilerplate builded with [Express.js](http://expressjs.com/en/4x/api.html), [Typescript](https://github.com/Microsoft/TypeScript) [TypeORM](https://github.com/typeorm/typeorm) and [Mocha](https://mochajs.org/). 🀘 + +## > Features + +* **Clear code architecture** with classic layers such controllers, services, repositories, models, middlewares, subscribers, utils, ... +* **Object Relational Mapping** with [Typeorm](https://typeorm.io/#/). +* **SSL secure connection** with native [HTTPS node module](https://nodejs.org/docs/latest-v14.x/api/https.html). +* **Cross Oigin Resource Sharing** with [CORS](https://expressjs.com/en/resources/middleware/cors.html). +* **Securized HTTP headers** with [Helmet](https://helmetjs.github.io/). +* **HTTP header pollution** preventing with [Hpp](https://www.npmjs.com/package/hpp). +* **API request rate limit** with [Express rate limit](https://www.npmjs.com/package/express-rate-limit). +* **HTTP friendly errors** based on a custom pipe with [boom](https://github.com/hapijs/boom) and [http-status](https://www.npmjs.com/package/http-status). +* **Logs management** with [Morgan](https://github.com/expressjs/morgan) and [Winston](https://github.com/winstonjs/winston). +* **HTTP request cache** with [memory-cache](https://www.npmjs.com/package/memory-cache). +* **Database query cache** with [typeorm caching](https://github.com/typeorm/typeorm/blob/master/docs/caching.md). +* **JWT authentication process** with [passport.js](http://www.passportjs.org/). +* **oAuth authentication process** with [passport.js](http://www.passportjs.org/). +* **Route validation** with [Joi](https://github.com/hapijs/joi). +* **Customizable file upload** with [Multer](https://www.npmjs.com/package/multer). +* **Customizable image resizing** designed for front-end requirements with [Jimp](https://www.npmjs.com/package/jimp). +* **Automatic changelog completion** with [auto-changelog](https://www.npmjs.com/package/auto-changelog). +* **Easy API testing** with included unit and e2e test sets builded with [Mocha](https://mochajs.org/), [Chai](https://www.chaijs.com/), [Sinon](https://sinonjs.org/) and [Supertest](https://github.com/visionmedia/supertest). +* **Easy generation of documentation** with [api-doc](https://apidocjs.com/) and [typedoc](https://typedoc.org/). + +## > Table of contents -Thanks to [Daniel F. Sousa](https://github.com/danielfsousa) for the inspiration with [Express REST 2017 boilerplate](https://github.com/danielfsousa/express-rest-es2017-boilerplate). 🍺🍺🍺 - -## Table of contents - -* [Features](#features) * [Getting started](#getting-started) * [Documentation](#documentation) * [Tests](#tests) * [Continuous integration](#continuous-integration) * [Deployment](#deployment) -* [Roadmap](#roadmap) - -## Features - -### ORM - -Object Relational Mapping with [Typeorm](https://typeorm.io/#/). - -### Security - -SSL secure connection support with native [HTTPS node module](https://nodejs.org/docs/latest-v14.x/api/https.html). - -Classic security features with [CORS](https://expressjs.com/en/resources/middleware/cors.html), [Helmet](https://helmetjs.github.io/), [Hpp](https://www.npmjs.com/package/hpp) and [Express rate limit](https://www.npmjs.com/package/express-rate-limit). - -### HTTP friendly errors - -Customized error handling for clean and consistent HTTP friendly errors with [boom](https://github.com/hapijs/boom) and [http-status](https://www.npmjs.com/package/http-status). - -### Logging - -Simple logs management with [Morgan](https://github.com/expressjs/morgan) and [Winston](https://github.com/winstonjs/winston). - -### Caching - -Simple cache system with [memory-cache](https://www.npmjs.com/package/memory-cache) or [typeorm caching](https://github.com/typeorm/typeorm/blob/master/docs/caching.md). - -### Authentication +* [Licence](#licence) -Full authentication process with [passport.js](http://www.passportjs.org/) (Bearer, oauth Facebook, oauth Google). +## > Getting started -### Validation +### Prerequisites -Route validation with [Joi](https://github.com/hapijs/joi). +Before start, following technologies are required: -### File upload +* Git engine +* Node.js >= 14.16.0 +* NPM or yarn +* A database engine with dedicated user and database -Configurable [Medias](https://github.com/konfer-be/ts-express-typeorm-boilerplate/blob/master/src/api/models/media.model.ts) and file upload with [Multer](https://www.npmjs.com/package/multer). +When you're ready with that, starting your project is a matter of minutes. -### Image resizing +### Step 1: install -Automatic and configurable image resizing designed for front-end requirements with [Jimp](https://www.npmjs.com/package/jimp). - -### Changelog management - -Automatic completion of the changelog with [auto-changelog](https://www.npmjs.com/package/auto-changelog). - -### Testing - -Unit and e2e tests with [Mocha](https://mochajs.org/), [Chai](https://www.chaijs.com/) and [Supertest](https://github.com/visionmedia/supertest). - -### Documentation +```bash +$ git clone https://github.com/konfer-be/ts-express-typeorm-boilerplate.git path-to/your-project-name/ +``` -Easy generation of documentation with [api-doc](https://apidocjs.com/) and [typedoc](https://typedoc.org/). +### Step 2: go to your project -## Getting started +```bash +$ cd path-to/your-project-name/ +``` -### Install +### Step 3: build ```bash -$ git clone https://github.com/konfer-be/ts-express-typeorm-boilerplate.git your-project-name/ +$ npm run kickstart ``` -### Build +### Step 4: setup git ```bash -$ npm run kickstart +$ rm -rf ./.git && git init && git add . --all && git commit -m "First commit" ``` -### Setup +### Step 5: setup package.json + +Open the *./package.json* file and edit *version*, *author*, *name*, *description*, *homepage*, *repository* and *bugs* fields with your own values. -#### Environments +### Step 6: setup environment variables -First, fill required env variables in *./dist/env/development.env* and *./dist/env/test.env* files. Mandatory fields are uncommented in the files. See env variables list above for more informations. +Open *./dist/env/development.env* and *./dist/env/test.env* files and fill the required environment values. Mandatory fields are uncommented in the files. See env variables list above for more informations. | Key | Description | Type | Default | Required | | ------------ | ----------- | ----------- | ----------- | ---------- | | API_VERSION | Current version of your API | string | v1 | false | **AUTHORIZED** | Allowed client hosts | string | / | true -| CACHE_IS_ACTIVE | Cache activated | boolean | 0 | false -| CACHE_TYPE | Cache type | enum | MEMORY | false -| CACHE_LIFETIME | Cache lifetime duration (ms) | number | 5000 | false | CONTENT_TYPE | Supported Content-Type | string | application/json | false | **DOMAIN** | API domain | string | localhost | true -| HTTPS_IS_ACTIVE | SSL support activated | boolean | 0 | false -| HTTPS_CERT | SSL certificate path | string | / | false -| HTTPS_KEY | Private key path | string | / | false +| FACEBOOK_CONSUMER_ID | Facebook app ID | string | / | false +| FACEBOOK_CONSUMER_SECRET | Facebook app secret | string | / | false +| GITHUB_CONSUMER_ID | Github app ID | string | / | false +| GITHUB_CONSUMER_SECRET | Github app secret | string | / | false +| GOOGLE_CONSUMER_ID | Google app ID | string | / | false +| GOOGLE_CONSUMER_SECRET | Google app secret | string | / | false | JWT_EXPIRATION_MINUTES | JWT lifetime (minutes) | number | 120960 | false | **JWT_SECRET** | JWT secret passphrase | string | / | true -| LOGS_MORGAN_TOKEN | Morgan logs format | string | dev | false +| LINKEDIN_CONSUMER_ID | Linkedin app ID | string | / | false +| LINKEDIN_CONSUMER_SECRET | Linkedin app secret | string | / | false | LOGS_PATH | Logs directory path | string | logs | false +| LOGS_TOKEN | Morgan logs format | string | dev | false +| MEMORY_CACHE | Memory cache activated | boolean | 0 | false +| MEMORY_CACHE_DURATION | Cache lifetime duration (ms) | number | 5000 | false | **PORT** | Listened application port | number | / | true | REFRESH_TOKEN_DURATION | Refresh token duration | number | 30 | false | REFRESH_TOKEN_UNIT | Refresh token duration unit | string | days | false +| RESIZE_IS_ACTIVE | Images resizing activated | boolean | 1 | false | RESIZE_PATH_MASTER | Images directory name | string | master-copy | false | RESIZE_PATH_SCALE | Resized images path | string | rescale | path -| RESIZE_SIZE_XS | Extra-small size value (px) | number | 260 | false -| RESIZE_SIZE_SM | Small size value (px) | number | 320 | false -| RESIZE_SIZE_MD | Medium size value (px) | number | 768 | false | RESIZE_SIZE_LG | Large size value (px) | number | 1024 | false +| RESIZE_SIZE_MD | Medium size value (px) | number | 768 | false +| RESIZE_SIZE_SM | Small size value (px) | number | 320 | false | RESIZE_SIZE_XL | Extra-large size value (px) | number | 1366 | false -| **TYPEORM_TYPE** | Database engine | string | / | true -| TYPEORM_NAME | Databse connection identifier | string | default | false -| **TYPEORM_HOST** | Database server host | string | / | true +| RESIZE_SIZE_XS | Extra-small size value (px) | number | 260 | false +| SSL_CERT | SSL certificate path | string | / | false +| SSL_KEY | SSL key path | string | / | false | **TYPEORM_DB** | Database name | string | / | true -| **TYPEORM_USER** | Database user | string | / | true -| **TYPEORM_PWD** | Database password | string | / | true +| TYPEORM_CACHE | Database cache activated | boolean | 0 | false +| TYPEORM_CACHE_DURATION | Database cache duration | number | 5000 | false +| **TYPEORM_HOST** | Database server host | string | / | true +| TYPEORM_LOG | Queries logs activated | boolean | 0 | false +| TYPEORM_NAME | Databse connection identifier | string | default | false | **TYPEORM_PORT** | Database server port | number | / | true +| **TYPEORM_PWD** | Database password | string | / | true | TYPEORM_SYNC | Schema synchronization activated | boolean | 0 | false -| TYPEORM_LOG | Queries logs activated | boolean | 0 | false -| UPLOAD_PATH | Destination path for uploads | string | public | false +| **TYPEORM_TYPE** | Database engine | string | / | true +| **TYPEORM_USER** | Database user | string | / | true | UPLOAD_MAX_FILE_SIZE | Max file size (bytes) | number | 1000000 | false | UPLOAD_MAX_FILES | Max number of files per request | number | 5 | false +| UPLOAD_PATH | Destination path for uploads | string | public | false | UPLOAD_WILDCARDS | Accepted file types for upload | string | * | false +| URL | HTTP root path of your API | string | http://localhost:8101 | false -#### Typescript +### Step 7: setup your Typescript environment + +If you don't wish specify particular Typescript settings, skip this step. -Typescript configuration is provided in *./tsconfig.json* file: +Otherwise, Typescript configuration is provided in *./tsconfig.json* file: ```javascript { @@ -158,19 +162,16 @@ Typescript configuration is provided in *./tsconfig.json* file: "@decorators/*": ["api/decorators/*"], "@enums/*": ["api/types/enums/*"], "@errors/*": ["api/types/errors/*"], - "@events/*": ["api/events/*"], "@factories/*": ["api/factories/*"], "@interfaces/*": ["api/types/interfaces/*"], "@middlewares/*": ["api/middlewares/*"], "@models/*": ["api/models/*"], "@repositories/*": ["api/repositories/*"], "@routes/*": ["api/routes/v1/*"], - "@serializers/*": ["api/serializers/*"], "@servers/*": ["servers/*"], "@services/*": ["api/services/*"], "@utils/*": ["api/utils/*"], "@validations/*": ["api/validations/*"], - "@whitelists/*": ["api/serializers/whitelists/*"] }, "lib": ["dom", "es5", "es6", "es7"], "target": "es5", @@ -186,13 +187,13 @@ Typescript configuration is provided in *./tsconfig.json* file: } ``` -If you don't wish specify particular Typescript settings, skip this step. - More info about [tsconfig.json](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html). -#### TypeORM +### Step 8: setup Typeorm cli + +If you don't wish to use Typeorm with CLI, skip this step. -If you will use Typeorm as CLI, you must update *ormconfig.json* file, and fill it with the same parameters as in your environment file. +Otherwise, update *./ormconfig.json* file, and fill it with the same parameters as in your environment file. ```javascript { @@ -222,25 +223,21 @@ If you will use Typeorm as CLI, you must update *ormconfig.json* file, and fill } ``` -If you don't wish to use Typeorm with CLI, skip this step. - More info about [ormconfig file](http://typeorm.io/#/using-ormconfig) and [typeorm cli](https://typeorm.io/#/using-cli/installing-cli). -### Compile +### Step 9: compile ```bash $ tsc ``` -### Run - -Enjoy with: +### Step 10: run & enjoy ```bash $ nodemon ``` -## Documentation +## > Documentation ```bash $ npm run doc:apidoc @@ -264,7 +261,7 @@ $ npm run doc Generate api and code documentation websites into *./docs/*. -## Tests +## > Tests ```bash $ npm run test --env test @@ -274,11 +271,11 @@ HTML coverage report is generated by [Istanbul](https://github.com/gotwarlost/is Bonus with *./insomnia.workspace.json* if you wish run manual e2e tests without create the config. -## Continuous integration +## > Continuous integration Basic Travis-CI configuration is provided in *./.travis.yml* file. -## Deployment +## > Deployment Project implements a basic [PM2](https://github.com/Unitech/PM2/) configuration to allow easy deployment. @@ -288,11 +285,9 @@ First, install PM2 globaly : $ npm i pm2 -g ``` -Note that PM2 should also be installed on other server environments, and that your SSH public key must be granted by the destination server. - ### Configuration -Configure the *ecosystem.config.js* file with your environments informations. +Configure the *./ecosystem.config.js* file with your environments informations. ```javascript deploy : { @@ -320,6 +315,8 @@ More info about PM2 [ecosystem.config.js](https://pm2.io/doc/en/runtime/referenc ### Deploy +Pm 2 must be installed on the target server and your SSH public key granted. + ```bash # Setup deployment at remote location $ pm2 deploy production setup @@ -334,29 +331,8 @@ $ pm2 deploy production revert 1 $ pm2 deploy production exec "pm2 reload all" ``` -More info about [PM2 deploy](https://pm2.io/doc/en/runtime/guide/easy-deploy-with-ssh/). - -More info about [PM2](http://pm2.keymetrics.io/docs/usage/quick-start/). - -## Roadmap - -- [ ] Services - - [ ] Permissions - - [ ] Business - - [ ] Data layer -- [ ] Modular, scalable & consistent architecture -- [ ] Unit testing - - [ ] 95% coverage - - [x] Refactoring UT, split e2e testing - - [x] Pretty fixtures - - [x] Dest path lcov (~~docs~~) -- [ ] ESLint compliance -- [ ] PM2 deployment and configuration -- [ ] API monitoring -- [ ] Wiki -- [ ] Generating (Typeorm cli and kem) -- [ ] Clean doc generation. Set Api doc as demo -- [ ] Oauth twitter, github, linkedin -- [ ] Email sending -- [ ] Graphql support -- [ ] CI providers (circle-ci at least) \ No newline at end of file +More info about [PM2](http://pm2.keymetrics.io/docs/usage/quick-start/) and [PM2 deploy](https://pm2.io/doc/en/runtime/guide/easy-deploy-with-ssh/). + +## > License + +[MIT](/LICENSE) \ No newline at end of file diff --git a/TODO.md b/TODO.md index 3a96ea4..e166669 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,15 @@ ### FIXMEs | Filename | line # | FIXME |:------|:------:|:------ -| ./src/api/config/environment.config.ts | 17 | encrypt confidential data on env variables (ie typeorm) -| ./src/api/config/passport.config.ts | 68 | promise error is not managed +| ./src/api/config/environment.config.ts | 14 | encrypt confidential data on env variables (ie typeorm) | ./src/api/models/media.model.ts | 10 | Media fieldname management. Seems to be always 'media'. Check and add e2e tests +| ./src/api/repositories/user.repository.ts | 106 | user should always retrieved from her email address. If not, possible collision on username value +| ./src/api/services/auth.service.ts | 52 | promise error is not managed +| ./src/api/services/auth.service.ts | 78 | promise error is not managed +| ./src/api/utils/string.util.ts | 38 | not working ### TODOs | Filename | line # | TODO |:------|:------:|:------ | ./src/api/decorators/safe.decorator.ts | 28 | fallback in catch -| ./src/api/middlewares/sanitizer.middleware.ts | 25 | safe decorator ? +| ./src/api/middlewares/sanitizer.middleware.ts | 18 | safe decorator on this middleware and each other diff --git a/package-lock.json b/package-lock.json index 93483d5..6177b48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1509,6 +1509,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -6135,6 +6140,11 @@ } } }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE=" + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -6481,6 +6491,47 @@ "pause": "0.0.1" } }, + "passport-facebook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz", + "integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, + "passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, + "passport-google-oauth": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth/-/passport-google-oauth-2.0.0.tgz", + "integrity": "sha512-JKxZpBx6wBQXX1/a1s7VmdBgwOugohH+IxCy84aPTZNq/iIPX6u7Mqov1zY7MKRz3niFPol0KJz8zPLBoHKtYA==", + "requires": { + "passport-google-oauth1": "1.x.x", + "passport-google-oauth20": "2.x.x" + } + }, + "passport-google-oauth1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth1/-/passport-google-oauth1-1.0.0.tgz", + "integrity": "sha1-r3SoA99R7GRvZqRNgigr5vEI4Mw=", + "requires": { + "passport-oauth1": "1.x.x" + } + }, + "passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, "passport-http-bearer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz", @@ -6498,6 +6549,36 @@ "passport-strategy": "^1.0.0" } }, + "passport-linkedin-oauth2": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-linkedin-oauth2/-/passport-linkedin-oauth2-2.0.0.tgz", + "integrity": "sha512-PnSeq2HzFQ/y1/p2RTF/kG2zhJ7kwGVg4xO3E+JNxz2aI0pFJGAqC503FVpUksYbhQdNhL6QYlK9qrEXD7ZYCg==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, + "passport-oauth1": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/passport-oauth1/-/passport-oauth1-1.1.0.tgz", + "integrity": "sha1-p96YiiEfnPRoc3cTDqdN8ycwyRg=", + "requires": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "utils-merge": "1.x.x" + } + }, + "passport-oauth2": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz", + "integrity": "sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==", + "requires": { + "base64url": "3.x.x", + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -8091,6 +8172,11 @@ "dev": true, "optional": true }, + "uid2": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", + "integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I=" + }, "undefsafe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz", diff --git a/package.json b/package.json index 800e4e3..6bc0981 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.1.4", + "version": "1.2.0", "engines": { "node": ">=14.16" }, @@ -49,12 +49,13 @@ "test:unit": "./node_modules/.bin/mocha ./test/units/00-application.unit.test.js --exit --reporter spec --timeout 10000 --env test", "test:e2e": "./node_modules/.bin/mocha ./test/e2e/00-api.e2e.test.js --exit --reporter spec --timeout 10000 --env test", "todo": "leasot -x --reporter markdown './src/**/*.ts' > TODO.md && git add TODO.md", - "version": "auto-changelog -p && git add CHANGELOG.md && git commit -m \"Update changelog\"" + "version": "npm run todo && git add TODO.md && git add package.json && auto-changelog -p && git add CHANGELOG.md && git commit -m \"Update changelog\" --no-verify" }, "_moduleAliases": { "@bases": "dist/api/types/classes", "@config": "dist/api/config", "@controllers": "dist/api/controllers", + "@customtypes": "dist/api/types/types", "@decorators": "dist/api/decorators", "@enums": "dist/api/types/enums", "@errors": "dist/api/types/errors", @@ -95,8 +96,12 @@ "multer": "^1.4.2", "mysql": "^2.18.1", "passport": "^0.4.1", + "passport-facebook": "^3.0.0", + "passport-github2": "^0.1.12", + "passport-google-oauth": "^2.0.0", "passport-http-bearer": "^1.0.1", "passport-jwt": "^4.0.0", + "passport-linkedin-oauth2": "^2.0.0", "pluralize": "^8.0.0", "reflect-metadata": "^0.1.13", "typeorm": "^0.2.31", diff --git a/src/api/app.bootstrap.ts b/src/api/app.bootstrap.ts index 7656b16..a669445 100644 --- a/src/api/app.bootstrap.ts +++ b/src/api/app.bootstrap.ts @@ -2,23 +2,24 @@ require('module-alias/register'); import * as Express from 'express'; -import { env, port} from '@config/environment.config'; +import { ENV, PORT, TYPEORM } from '@config/environment.config'; import { TypeormConfiguration } from '@config/typeorm.config'; import { ServerConfiguration } from '@config/server.config'; import { ExpressConfiguration } from '@config/app.config'; import { Logger } from '@services/logger.service'; -TypeormConfiguration.connect() +TypeormConfiguration.connect(TYPEORM) .catch( (e: Error) => { - throw new Error(e.message); + process.stdout.write(e.message); + process.exit(1); }); const application = new ExpressConfiguration( Express() ).get(); const HTTPServer = ServerConfiguration.server(application); -const server = HTTPServer.listen(port, () => { - Logger.log('info', `HTTP(S) server is now running on port ${port} (${env})`); +const server = HTTPServer.listen(PORT, () => { + Logger.log('info', `HTTP(S) server is now running on port ${PORT} (${ENV})`); }); export { application, server }; \ No newline at end of file diff --git a/src/api/config/app.config.ts b/src/api/config/app.config.ts index c64f21f..29f58a8 100644 --- a/src/api/config/app.config.ts +++ b/src/api/config/app.config.ts @@ -8,12 +8,12 @@ import * as Morgan from 'morgan'; import * as Helmet from 'helmet'; import { createWriteStream } from 'fs'; -import { initialize as PassportInitialize, use as PassportUse } from 'passport'; +import { initialize as Passport } from 'passport'; import { notAcceptable } from '@hapi/boom'; import { ENVIRONMENT } from '@enums/environment.enum'; -import { domain, logs, authorized, version, env, contentType, upload } from '@config/environment.config'; +import { API_VERSION, AUTHORIZED, CONTENT_TYPE, DOMAIN, LOGS, ENV, UPLOAD } from '@config/environment.config'; import { PassportConfiguration } from '@config/passport.config'; import { Logger } from '@services/logger.service'; @@ -36,13 +36,14 @@ export class ExpressConfiguration { */ private instance: Express.Application; + /** * @description Middlewares options */ private options = { cors: { origin: (origin, callback: ( error: Error, status?: boolean ) => void) => { - if (authorized.indexOf(origin) !== -1) { + if (AUTHORIZED.indexOf(origin) !== -1) { callback(null, true); } else { callback( notAcceptable('Domain not allowed by CORS') ); @@ -53,7 +54,7 @@ export class ExpressConfiguration { }, helmet: { contentSecurityPolicy: { - defaultSrc: ['\'self\'', `'${domain}'`], + defaultSrc: ['\'self\'', `'${DOMAIN}'`], scriptSrc: ['\'self\'', '\'unsafe-inline\''], sandbox: ['allow-forms', 'allow-scripts'], reportUri: '/report-violation', @@ -65,7 +66,7 @@ export class ExpressConfiguration { noSniff: true, referrerPolicy: { policy: 'no-referrer' } }, - stream: ( env === ENVIRONMENT.production ? createWriteStream(`${logs.path}/access.log`, { flags: 'a+' }) : Logger.stream ) as ReadableStream, + stream: (ENV === ENVIRONMENT.production ? createWriteStream(`${LOGS.PATH}/access.log`, { flags: 'a+' }) : Logger.stream ) as ReadableStream, rate: { windowMs: 60 * 60 * 1000, // 1 hour max: 2500, @@ -80,7 +81,7 @@ export class ExpressConfiguration { /** * First, before all : check headers validity */ - this.instance.use( Kors(contentType) ); + this.instance.use( Kors(CONTENT_TYPE) ); /** * Expose body on req.body @@ -88,7 +89,7 @@ export class ExpressConfiguration { * @see https://www.npmjs.com/package/body-parser */ this.instance.use( BodyParser.urlencoded({ extended : false }) ); - this.instance.use( BodyParser.json({ type: contentType }) ); + this.instance.use( BodyParser.json({ type: CONTENT_TYPE }) ); /** * Prevent request parameter pollution @@ -121,15 +122,16 @@ export class ExpressConfiguration { this.instance.use( Cors( this.options.cors ) ); /** - * Passport configuration + * Passport initialize * * @see http://www.passportjs.org/ */ - this.instance.use( PassportInitialize() ); + this.instance.use( Passport() ); - PassportUse('jwt', PassportConfiguration.factory('jwt')); - PassportUse('facebook', PassportConfiguration.factory('facebook')); - PassportUse('google', PassportConfiguration.factory('google')); + /** + * Plug active oAuth provider + */ + PassportConfiguration.plug() /** * Configure API Rate limit @@ -144,26 +146,27 @@ export class ExpressConfiguration { * * @see https://github.com/expressjs/morgan */ - this.instance.use( Morgan(logs.token, { stream: this.options.stream } ) ); + this.instance.use( Morgan(LOGS.TOKEN, { stream: this.options.stream } ) ); /** * Define CDN static resources location */ - this.instance.use('/cdn', RateLimit(this.options.rate), Express.static(`${__dirname}/../../${upload.destination}`)); + this.instance.use('/cdn', RateLimit(this.options.rate), Express.static(`${__dirname}/../../${UPLOAD.PATH}`)); /** * Set global middlewares on Express Application * * - RateLimit + * - Memory cache * - Router(s) * - Resolver */ - this.instance.use(`/api/${version}`, RateLimit(this.options.rate), Kache, ProxyRouter.map(), Sanitizer, Resolver); + this.instance.use(`/api/${API_VERSION}`, RateLimit(this.options.rate), Kache, ProxyRouter.map(), Sanitizer, Resolver); /** * Errors handlers */ - if( [ENVIRONMENT.development].includes(env as ENVIRONMENT) ) { + if( [ENVIRONMENT.development].includes(ENV as ENVIRONMENT) ) { this.instance.use( Catcher.notification ); } diff --git a/src/api/config/environment.config.ts b/src/api/config/environment.config.ts index 5aba80d..a188172 100644 --- a/src/api/config/environment.config.ts +++ b/src/api/config/environment.config.ts @@ -1,4 +1,3 @@ -import { CACHE } from '@enums/cache.enum'; import { DATABASE, DATABASE_ENGINE } from '@enums/database-engine.enum'; import { MOMENT_UNIT } from '@enums/moment-unity.enum'; import { ENVIRONMENT } from '@enums/environment.enum'; @@ -7,8 +6,6 @@ import { list } from '@utils/enum.util'; import { existsSync } from 'fs'; /** - * Configure dotenv with variables.env file before app, to allow process.env accessibility in - * app.js * * @dependency dotenv * @@ -16,321 +13,868 @@ import { existsSync } from 'fs'; * * FIXME: encrypt confidential data on env variables (ie typeorm) */ -class EnvironmentConfiguration { +export class Environment { + + /** + * @description Current root dir + */ + base: string; + + /** + * @description Cluster with aggregated data + */ + cluster: Record; /** * @description Current environment (default development) */ - static environment: string = ENVIRONMENT.development; + environment: string = ENVIRONMENT.development; /** - * @description Current root dir + * @description Errors staged on current environment + */ + errors: string[] = []; + + /** + * @description + */ + variables: Record; + + constructor() {} + + /** + * @description Env variables exhaustive key list + */ + get keys(): string[] { + return [ + 'API_VERSION', + 'AUTHORIZED', + 'CONTENT_TYPE', + 'DOMAIN', + 'FACEBOOK_CONSUMER_ID', + 'FACEBOOK_CONSUMER_SECRET', + 'GITHUB_CONSUMER_ID', + 'GITHUB_CONSUMER_SECRET', + 'GOOGLE_CONSUMER_ID', + 'GOOGLE_CONSUMER_SECRET', + 'JWT_EXPIRATION_MINUTES', + 'JWT_SECRET', + 'LINKEDIN_CONSUMER_ID', + 'LINKEDIN_CONSUMER_SECRET', + 'LOGS_PATH', + 'LOGS_TOKEN', + 'MEMORY_CACHE', + 'MEMORY_CACHE_DURATION', + 'PORT', + 'REFRESH_TOKEN_DURATION', + 'REFRESH_TOKEN_UNIT', + 'RESIZE_IS_ACTIVE', + 'RESIZE_PATH_MASTER', + 'RESIZE_PATH_SCALE', + 'RESIZE_SIZE_LG', + 'RESIZE_SIZE_MD', + 'RESIZE_SIZE_SM', + 'RESIZE_SIZE_XL', + 'RESIZE_SIZE_XS', + 'SSL_CERT', + 'SSL_KEY', + 'TYPEORM_DB', + 'TYPEORM_CACHE', + 'TYPEORM_CACHE_DURATION', + 'TYPEORM_HOST', + 'TYPEORM_LOG', + 'TYPEORM_NAME', + 'TYPEORM_PORT', + 'TYPEORM_PWD', + 'TYPEORM_SYNC', + 'TYPEORM_TYPE', + 'TYPEORM_USER', + 'UPLOAD_MAX_FILE_SIZE', + 'UPLOAD_MAX_FILES', + 'UPLOAD_PATH', + 'UPLOAD_WILDCARDS', + 'URL' + ] + } + + /** + * @description Embeded validation rules for env variables */ - static base: string; + get rules(): any { + + return { + + /** + * @description Current api version + * + * @default v1 + */ + API_VERSION: (value: string): string => { + return value ? value.trim().toLowerCase() : 'v1'; + }, + + /** + * @description Authorized remote(s) host(s) + * + * @default null + */ + AUTHORIZED: (value: string): string => { + const regex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}(:[0-9]{1,5})|\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; + if (!value) { + this.errors.push('AUTHORIZED not found: please fill a single host as string or multiple hosts separated by coma (ie: http://my-domain.com or http://my-domain-1.com,http://my-domain-2.com, ...'); + } + if ( value && value.lastIndexOf(',') === -1 && !regex.test(value)) { + this.errors.push('AUTHORIZED bad value: please fill a single host as string or multiple hosts separated by coma (ie: http://my-domain.com or http://my-domain-1.com,http://my-domain-2.com, ...'); + } + if ( value && value.lastIndexOf(',') !== -1 && value.split(',').some(v => !regex.test(v) )) { + this.errors.push('AUTHORIZED bad value: please fill a single host as string or multiple hosts separated by coma (ie: http://my-domain.com or http://my-domain-1.com,http://my-domain-2.com, ...'); + } + return value ? value.trim().toLowerCase() : null; + }, + + /** + * @description Content-Type + * + * @default application/json + */ + CONTENT_TYPE: (value: string) => { + if (value && !CONTENT_MIME_TYPE[value]) { + this.errors.push(`CONTENT_TYPE bad value: please fill a supported Content-Type must be one of ${list(CONTENT_MIME_TYPE).join(',')}`); + } + return value || CONTENT_MIME_TYPE['application/json']; + }, + + /** + * @description Domain of the application in current environment + * + * @default localhost + */ + DOMAIN: (value: string): string => { + return value ? value.trim().toLowerCase() : 'localhost'; + }, + + /** + * @description Current runtime environment + */ + ENVIRONMENT: (value: string): string => { + return value; + }, + + /** + * @description Facebook application id + * + * @default null + */ + FACEBOOK_CONSUMER_ID: (value: string): string => { + if (value && /[0-9]{15}/.test(value) === false) { + this.errors.push('FACEBOOK_CONSUMER_ID bad value: check your Facebook app settings to fill a correct value.'); + } + return value || null; + }, + + /** + * @description Facebook application secret + * + * @default null + */ + FACEBOOK_CONSUMER_SECRET: (value: string): string => { + if (value && /[0-9-abcdef]{32}/.test(value) === false) { + this.errors.push('FACEBOOK_CONSUMER_SECRET bad value: check your Facebook app settings to fill a correct value.') + } + return value || null; + }, + + /** + * @description Github application id + * + * @default null + */ + GITHUB_CONSUMER_ID: (value: string): string => { + if (value && /[0-9-a-z-A-Z]{20}/.test(value) === false) { + this.errors.push('GITHUB_CONSUMER_ID bad value: check your Github app settings to fill a correct value.'); + } + return value || null; + }, + + /** + * @description Github application secret + * + * @default null + */ + GITHUB_CONSUMER_SECRET: (value: string): string => { + if (value && /[0-9-A-Z-a-z-_]{40}/.test(value) === false) { + this.errors.push('GITHUB_CONSUMER_SECRET bad value: check your Github app and fill a correct value in your .env file.') + } + return value || null; + }, + + /** + * @description Google application id + * + * @default null + */ + GOOGLE_CONSUMER_ID: (value: string): string => { + if (value && /[0-9]{12}-[0-9-a-z]{32}.apps.googleusercontent.com/.test(value) === false) { + this.errors.push('GOOGLE_CONSUMER_ID bad value: check your Google app settings to fill a correct value.'); + } + return value || null; + }, + + /** + * @description Google application secret + * + * @default null + */ + GOOGLE_CONSUMER_SECRET: (value: string): string => { + if (value && /[0-9-A-Z-a-z-_]{24}/.test(value) === false) { + this.errors.push('GOOGLE_CONSUMER_SECRET bad value: check your Google app and fill a correct value in your .env file.') + } + return value || null; + }, + + /** + * @description JWT exiration duration in minutes + * + * @default 120960 + */ + JWT_EXPIRATION_MINUTES: (value: string): number => { + if (value && isNaN(parseInt(value, 10))) { + this.errors.push('JWT_EXPIRATION_MINUTES bad value: please fill a duration expressed as a number'); + } + return parseInt(value, 10) || 120960; + }, + + /** + * @description JWT secret token + * + * @default null + */ + JWT_SECRET: (value: string): string => { + if (!value) { + this.errors.push('JWT_SECRET not found: please fill a jwt secret value in your .env file to strengthen the encryption.'); + } + if (value && value.toString().length < 32) { + this.errors.push('JWT_SECRET bad value: please fill a jwt secret which have a length >= 32.'); + } + return value ? value.toString() : null; + }, + + /** + * @description Linkedin application id + * + * @default null + */ + LINKEDIN_CONSUMER_ID: (value: string): string => { + if (value && /[0-9-a-z-A-Z]{20}/.test(value) === false) { + this.errors.push('LINKEDIN_CONSUMER_ID bad value: check your Linkedin app settings to fill a correct value.'); + } + return value || null; + }, + + /** + * @description Linkedin application secret + * + * @default null + */ + LINKEDIN_CONSUMER_SECRET: (value: string): string => { + if (value && /[0-9-A-Z-a-z-_]{40}/.test(value) === false) { + this.errors.push('LINKEDIN_CONSUMER_SECRET bad value: check your Linkedin app and fill a correct value in your .env file.') + } + return value || null; + }, + + /** + * @description Logs token configuration used by Morgan for output pattern + * + * @default dev + */ + LOGS_TOKEN: (value: string): string => { + return this.environment === 'production' ? 'combined' : value || 'dev' + }, + + /** + * @description Logs path root directory + * + * @default logs + */ + LOGS_PATH: (value: string): string => { + return `${process.cwd()}/${this.base}/${value || 'logs'}` + }, + + /** + * @description Memory cache activated + * + * @default false + */ + MEMORY_CACHE: (value: string): boolean => { + return !!parseInt(value, 10) || false + }, + + /** + * @description Memory cache lifetime duration + * + * @default 5000 + */ + MEMORY_CACHE_DURATION: (value: string): number => { + return parseInt(value, 10) || 5000 + }, + + /** + * @description Listened port. Default 8101 + * + * @default 8101 + */ + PORT: (value: string): number => { + if (value && ( isNaN(parseInt(value,10)) || parseInt(value,10) > 65535)) { + this.errors.push('PORT bad value: please fill a valid TCP port number'); + } + return parseInt(value,10) || 8101; + }, + + /** + * @description Refresh token duration + * + * @default 30 + */ + REFRESH_TOKEN_DURATION: (value: string): number => { + if (value && isNaN(parseInt(value, 10))) { + this.errors.push('REFRESH_TOKEN_DURATION bad value: duration must be a number expressed in minutes.'); + } + return parseInt(value, 10) || 30 + }, + + /** + * @description Refresh token unit of duration (hours|days|weeks|months) + * + * @default 30 + */ + REFRESH_TOKEN_UNIT: (value: string): MOMENT_UNIT => { + if(value && !['hours', 'days', 'weeks', 'months'].includes(value) ) { + this.errors.push('REFRESH_TOKEN_UNIT bad value: unit must be one of hours, days, weeks, months.'); + } + return (value || 'days') as MOMENT_UNIT + }, + + /** + * @description Image resizing activated + * + * @default true + */ + RESIZE_IS_ACTIVE: (value: string): boolean => { + return !!parseInt(value, 10) || true + }, + + /** + * @description Directory name for original copy (required) + * + * @default master-copy + */ + RESIZE_PATH_MASTER: (value: string): string => { + return value || 'master-copy' + }, + + /** + * @description Directory name for resizes + * + * @default rescale + */ + RESIZE_PATH_SCALE: (value: string): string => { + return value || 'rescale' + }, + + /** + * @description + * + * @default 1024 + */ + RESIZE_SIZE_LG: (value: string): number => { + return parseInt(value, 10) || 1024 + }, + + /** + * @description + * + * @default 768 + */ + RESIZE_SIZE_MD: (value: string): number => { + return parseInt(value, 10) || 768 + }, + + /** + * @description + * + * @default 320 + */ + RESIZE_SIZE_SM: (value: string): number => { + return parseInt(value, 10) || 320 + }, + + /** + * @description + * + * @default 1366 + */ + RESIZE_SIZE_XL: (value: string): number => { + return parseInt(value, 10) || 1366 + }, + + /** + * @description + * + * @default 280 + */ + RESIZE_SIZE_XS: (value: string): number => { + return parseInt(value, 10) || 280 + }, + + /** + * @description SSL certificate location + * + * @default null + */ + SSL_CERT: (value: string): string => { + if (value && !existsSync(value)) { + this.errors.push('SSL_CERT bad value or SSL certificate not found. Please check path and/or file access rights.') + } + return value || null + }, + + /** + * @description SSL key location + * + * @default null + */ + SSL_KEY: (value: string): string => { + if (value && !existsSync(value)) { + this.errors.push('SSL_KEY bad value or SSL key not found. Please check path and/or file access rights.') + } + return value || null + }, + + /** + * @description + * + * @default null + */ + TYPEORM_DB: (value: string): string => { + if(!value) { + this.errors.push('TYPEORM_DB not found: please define the targeted database.'); + } + if (value && /^[0-9a-zA-Z_]{3,}$/.test(value) === false) { + this.errors.push('TYPEORM_DB bad value: please check the name of your database according [0-9a-zA-Z_].'); + } + return value || null + }, + + /** + * @description + * + * @default false + */ + TYPEORM_CACHE: (value: string): boolean => { + if(value && isNaN(parseInt(value, 10))) { + this.errors.push('TYPEORM_CACHE bad value: please use 0 or 1 to define activation of the db cache'); + } + return !!parseInt(value, 10) || false + }, + + /** + * @description + * + * @default 5000 + */ + TYPEORM_CACHE_DURATION: (value: string): number => { + if(value && isNaN(parseInt(value,10))) { + this.errors.push('TYPEORM_CACHE_DURATION bad value: please fill it with a duration expressed in ms.'); + } + return parseInt(value,10) || 5000 + }, + + /** + * @description + * + * @default localhost + */ + TYPEORM_HOST: (value: string): string => { + if(!value) { + this.errors.push('TYPEORM_HOST not found: please define the database server host.'); + } + if(value && value !== 'localhost' && /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/.test(value) === false) { + this.errors.push('TYPEORM_HOST bad value: please fill it with a valid database server host.'); + } + return value || 'localhost' + }, + + /** + * @description + * + * @default false + */ + TYPEORM_LOG: (value: string): boolean => { + return !!parseInt(value, 10) || false + }, + + /** + * @description + * + * @default default + */ + TYPEORM_NAME: (value: string): string => { + return value || 'default' + }, + + /** + * @description + * + * @default 3306 + */ + TYPEORM_PORT: (value: string): number => { + if(!value) { + this.errors.push('TYPEORM_PORT not found: please define the database server port.'); + } + return parseInt(value,10) || 3306; + }, + + /** + * @description + * + * @default null + */ + TYPEORM_PWD: (value: string): string => { + if(!value && ![ENVIRONMENT.test, ENVIRONMENT.development].includes(this.environment as ENVIRONMENT)) { + this.errors.push('TYPEORM_PWD not found: please define the database user password.'); + } + return value || null + }, + + /** + * @description + * + * @default false + */ + TYPEORM_SYNC: (value: string): boolean => { + return this.environment === ENVIRONMENT.production ? false : !!parseInt(value, 10) || false + }, + + /** + * @description + * + * @default mysql + */ + TYPEORM_TYPE: (value: string): DATABASE => { + if(!value) { + this.errors.push('TYPEORM_TYPE not found: please define the database engine type.'); + } + if(value && !DATABASE_ENGINE[value]) { + this.errors.push(`TYPEORM_TYPE bad value: database engine must be one of following: ${list(DATABASE_ENGINE).join(',')}.`); + } + return (value || 'mysql') as DATABASE + }, + + /** + * @description + * + * @default null + */ + TYPEORM_USER: (value: string): string => { + if(!value) { + this.errors.push('TYPEORM_USER not found: please define one user for your database.'); + } + return value || null + }, + + /** + * @description Max upload file size + * + * @default 1000000 + */ + UPLOAD_MAX_FILE_SIZE: (value: string): number => { + if(value && isNaN(parseInt(value, 10))) { + this.errors.push('UPLOAD_MAX_FILE_SIZE bad value: please fill it with an integer.'); + } + return parseInt(value, 10) || 1000000 + }, + + /** + * @description Max number of uploaded files by request + * + * @default 5 + */ + UPLOAD_MAX_FILES: (value: string): number => { + if(value && isNaN(parseInt(value, 10))) { + this.errors.push('UPLOAD_MAX_FILES bad value: please fill it with an integer.'); + } + return parseInt(value, 10) || 5 + }, + + /** + * @description Upload directory path + * + * @default public + */ + UPLOAD_PATH: (value: string): string => { + return `${process.cwd()}/${this.base}/${value || 'public'}` + }, + + /** + * @description Accepted mime-type + * + * @default AUDIO|ARCHIVE|DOCUMENT|IMAGE|VIDEO + */ + UPLOAD_WILDCARDS: (value: string): string[] => { + + const mimes = { AUDIO: AUDIO_MIME_TYPE, ARCHIVE: ARCHIVE_MIME_TYPE, DOCUMENT: DOCUMENT_MIME_TYPE, IMAGE: IMAGE_MIME_TYPE, VIDEO: VIDEO_MIME_TYPE }; + const input = value ? value.toString().split(',') : Object.keys(mimes); + const keys = Object.keys(mimes).map(k => k.toLowerCase()); + + if (value && value.toString().split(',').some(key => !keys.includes(key)) ) { + this.errors.push(`UPLOAD_WILDCARDS bad value: please fill it with an accepted value (${keys.join(',')}) with coma separation`); + } + + return input + .filter(key => mimes[key]) + .map(key => mimes[key] as Record ) + .reduce((acc,current) => { + return [...acc as string[], ...list(current)] as string[]; + }, []) as string[]; + }, + + /** + * @description API main URL + * + * @default http://localhost:8101 + */ + URL: (value: string): string => { + if (value && /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/.test(value)) { + this.errors.push('URL bad value. Please fill a local or remote URL'); + } + return value || 'http://localhost:8101' + } + + } + } /** * @description Set env according to args, and load .env file */ - static load() { + loads(nodeVersion: string): Environment { - const [major, minor] = process.versions.node.split('.').map( parseFloat ) + const [major, minor] = nodeVersion.split('.').map( parseFloat ) if(major < 14 || major === 14 && minor < 16) { - process.stdout.write('--- The node version of the server is too low for modern node programming'); - process.exit(1); + this.exit('The node version of the server is too low. Please consider at least v14.16.0.'); } if (process.argv && process.argv.indexOf('--env') !== -1 ) { - EnvironmentConfiguration.environment = ENVIRONMENT[process.argv[process.argv.indexOf('--env') + 1]] as string || ENVIRONMENT.development; + this.environment = ENVIRONMENT[process.argv[process.argv.indexOf('--env') + 1]] as string || ENVIRONMENT.development; } - switch (EnvironmentConfiguration.environment) { + switch (this.environment) { case ENVIRONMENT.development: - EnvironmentConfiguration.base = 'dist'; + this.base = 'dist'; break; case ENVIRONMENT.staging: - EnvironmentConfiguration.base = ''; + this.base = ''; break; case ENVIRONMENT.production: - EnvironmentConfiguration.base = ''; + this.base = ''; break; case ENVIRONMENT.test: - EnvironmentConfiguration.base = 'dist'; + this.base = 'dist'; break; } - const path = `${process.cwd()}/${EnvironmentConfiguration.base}/env/${EnvironmentConfiguration.environment}.env`; + const path = `${process.cwd()}/${this.base}/env/${this.environment}.env`; if (!existsSync(path)) { - process.stdout.write(`.env file not found on ${path}`); - process.exit(1); + this.exit(`Environment file not found at ${path}`); } const dtv: { config: (options) => void, parse: () => void } = require('dotenv') as { config: () => void, parse: () => void }; - dtv.config( { path} ); - } -} - -EnvironmentConfiguration.load(); - -/** - * @description Authorized remote(s) host(s) - */ -const authorized = ((value: string) => { - if (!value) { - throw new Error('AUTHORIZED not found. Please fill this value in your .env file to indicate allowed hosts.'); - } - return value; -})(process.env.AUTHORIZED); - -/** - * @description Cache configuration - */ - const cacheParams = Object.keys(process.env) - .filter(key => key.lastIndexOf('CACHE') !== -1) - .reduce( (acc, current) => { - acc[current] = process.env[current]; - return acc; - }, {}); - - const cache = ((args: Record) => { - if (args.CACHE_TYPE && !CACHE[args.CACHE_TYPE as string]) { - throw new Error(`CACHE_TYPE bad value. Supported Content-Type must be one of ${list(CACHE).join(',')}`); - } - return { - isActive: !!parseInt(args.CACHE_IS_ACTIVE as string, 10) || false, - type: args.CACHE_TYPE || CACHE.MEMORY, - lifetime: parseInt(args.CACHE_LIFETIME as string, 10) || 5000 - }; -})(cacheParams); - -/** - * @description Supported Content-Type as application/json | application/vnd.api+json | multipart/form-data - */ - const contentType = ((value: string) => { - if (value && !CONTENT_MIME_TYPE[value]) { - throw new Error(`CONTENT_TYPE bad value. Supported Content-Type must be one of ${list(CONTENT_MIME_TYPE).join(',')}`); - } - return value || CONTENT_MIME_TYPE['application/json']; -})(process.env.CONTENT_TYPE); - -/** - * @description API domain - */ - const domain = ((value: string) => { - if (!value) { - throw new Error('DOMAIN not found. Please fill this value in your .env file to indicate domain of your API.'); - } - return value; -})(process.env.DOMAIN); - -/** - * @description Current runtime environment as development | staging | production | test - */ - const env = EnvironmentConfiguration.environment; - -/** - * @description JWT exiration duration in minutes - */ - const jwtExpirationInterval = ((value: string) => { - if (value && isNaN(parseInt(value, 10))) { - throw new Error('JWT_EXPIRATION_MINUTES bad value. Expiration value must be a duration expressed as a number'); - } - return parseInt(value, 10) || 120960; -})(process.env.JWT_EXPIRATION_MINUTES); + dtv.config( { path} ); -/** - * @description JWT secret token - */ - const jwtSecret = ((value: string) => { - if (!value) { - throw new Error('JWT_SECRET not found. Please fill this value in your .env file to strengthen the encryption.'); + return this; } - return value; -})(process.env.JWT_SECRET); - -/** - * @description Logs configuration - * - * - token: morgan output pattern. Default: dev - * - path: root directory for logs. Default: logs - */ -const logs = Object.freeze({ - token: EnvironmentConfiguration.environment === 'production' ? 'combined' : process.env.LOGS_MORGAN_TOKEN || 'dev', - path: `${process.cwd()}/${EnvironmentConfiguration.base}/${process.env.LOGS_PATH || 'logs'}` -}); -/** - * @description Listened port. Default 8001 - */ - const port = ((value: string) => { - if (value && isNaN(parseInt(value,10))) { - throw new Error('PORT bad value. Port value must be a number'); + /** + * @description Extract variables from process.env + * + * @param args Environment variables + */ + extracts(args: Record): Environment { + this.variables = this.keys.reduce( (acc, current) => { + acc[current] = args[current]; + return acc; + }, {}); + return this; } - return value; -})(process.env.PORT); -/** - * @description Image resize configuration - * - * - isActive: enable|disable image resize feature. Default: disabled - * - destinations: destination paths for images - * - master: directory for original copy (required). Default: master-copy - * - scale: directory for resizes. Default: rescale - * - sizes: image sizes definitions. Default: 260, 320, 768, 1024, 1366 - */ - const resize = Object.freeze({ - isActive: !!parseInt(process.env.RESIZE_IS_ACTIVE, 10) || false, - destinations: { - master: process.env.RESIZE_PATH_MASTER || 'master-copy', - scale: process.env.RESIZE_PATH_SCALE || 'rescale' - }, - sizes: { - xs: parseInt(process.env.RESIZE_SIZE_XS, 10) || 280, - sm: parseInt(process.env.RESIZE_SIZE_SM, 10) || 320, - md: parseInt(process.env.RESIZE_SIZE_MD, 10) || 768, - lg: parseInt(process.env.RESIZE_SIZE_LG, 10) || 1024, - xl: parseInt(process.env.RESIZE_SIZE_XL, 10) || 1366 + /** + * @description Parse allowed env variables, validate it and returns safe current or default value + */ + validates(): Environment { + this.keys.forEach( (key: string) => { + this.variables[key] = this.rules[key](this.variables[key]) + }); + return this } -}); -/** - * @description HTTPS configuration - * - * - isActive: enable|disable HTTPS. Default: disabled - * - key: path to private key - * - cert: path to SSL certificate - */ -const ssl = ((isActive: string, key: string, cert: string) => { - const is = !!parseInt(isActive, 10); - if (is && !existsSync(key)) { - throw new Error('HTTPS_KEY bad value or private key not found. Please check your .env file configuration.') - } - if (is && !existsSync(cert)) { - throw new Error('HTTPS_CERT bad value or SSL certificate not found. Please check your .env file configuration.') + /** + * @description Say if current environment is valid or not + */ + isValid(): boolean { + return this.errors.length === 0; } - return Object.freeze({ - isActive: is, - key: is ? key || null : null, - cert: is ? cert || null : null - }); -})(process.env.HTTPS_IS_ACTIVE, process.env.HTTPS_KEY, process.env.HTTPS_CERT); -/** - * @description TypeORM configuration - * - * - type: database server type (mysql, postgresql, ...) - * - name: connection name - * - port: database server port - * - host: database server host address - * - database: database name - * - user: database user - * - pwd: database user password - * - sync: enable|disable typeorm schema synchronization (by default disabled in production) - * - log: enable|disable typeorm logs - * - entities: directory path of models that should be managed by typeorm - */ -const typeormParams = Object.keys(process.env) - .filter(key => key.lastIndexOf('TYPEORM') !== -1) - .reduce( (acc, current) => { - acc[current] = process.env[current]; - return acc; - }, {}); - -const typeorm = ((args: Record, environment: string, cch: Record) => { - if(!args.TYPEORM_TYPE) { - throw new Error('TYPEORM_TYPE not found. Please fill it in your .env file to define the database engine type.'); - } - if(args.TYPEORM_TYPE && !DATABASE_ENGINE[args.TYPEORM_TYPE as string]) { - throw new Error(`TYPEORM_TYPE bad value. Database engine must be one of following: ${list(DATABASE_ENGINE).join(',')}.`); - } - if(!args.TYPEORM_PORT) { - throw new Error('TYPEORM_PORT not found. Please fill it in your .env file to define the database server port.'); - } - if(!args.TYPEORM_HOST) { - throw new Error('TYPEORM_HOST not found. Please fill it in your .env file to define the database server host.'); - } - if(!args.TYPEORM_DB) { - throw new Error('TYPEORM_DB not found. Please fill it in your .env file to define the targeted database.'); - } - if(!args.TYPEORM_USER) { - throw new Error('TYPEORM_USER not found. Please fill it in your .env file to define the user of the database.'); - } - if(!args.TYPEORM_PWD && ![ENVIRONMENT.test, ENVIRONMENT.development].includes(environment as ENVIRONMENT)) { - throw new Error('TYPEORM_PWD not found. Please fill it in your .env file to define the password of the database.'); + /** + * @description Aggregates some data for easy use + */ + aggregates(): Environment { + this.cluster = { + API_VERSION: this.variables.API_VERSION, + AUTHORIZED: this.variables.AUTHORIZED, + CONTENT_TYPE: this.variables.CONTENT_TYPE, + DOMAIN: this.variables.DOMAIN, + ENV: this.environment, + JWT: { + SECRET: this.variables.JWT_SECRET, + EXPIRATION: this.variables.JWT_EXPIRATION_MINUTES + }, + FACEBOOK: { + KEY: 'facebook', + IS_ACTIVE: this.variables.FACEBOOK_CONSUMER_ID !== null && this.variables.FACEBOOK_CONSUMER_SECRET !== null, + ID: this.variables.FACEBOOK_CONSUMER_ID, + SECRET: this.variables.FACEBOOK_CONSUMER_SECRET, + CALLBACK_URL: `${this.variables.URL as string}/api/${this.variables.API_VERSION as string}/auth/facebook/callback` + }, + GITHUB: { + KEY: 'github', + IS_ACTIVE: this.variables.GITHUB_CONSUMER_ID !== null && this.variables.GITHUB_CONSUMER_SECRET !== null, + ID: this.variables.GITHUB_CONSUMER_ID, + SECRET: this.variables.GITHUB_CONSUMER_SECRET, + CALLBACK_URL: `${this.variables.URL as string}/api/${this.variables.API_VERSION as string}/auth/github/callback` + }, + GOOGLE: { + KEY: 'google', + IS_ACTIVE: this.variables.GOOGLE_CONSUMER_ID !== null && this.variables.GOOGLE_CONSUMER_SECRET !== null, + ID: this.variables.GOOGLE_CONSUMER_ID, + SECRET: this.variables.GOOGLE_CONSUMER_SECRET, + CALLBACK_URL: `${this.variables.URL as string}/api/${this.variables.API_VERSION as string}/auth/google/callback` + }, + LINKEDIN: { + KEY: 'linkedin', + IS_ACTIVE: this.variables.LINKEDIN_CONSUMER_ID !== null && this.variables.LINKEDIN_CONSUMER_SECRET !== null, + ID: this.variables.LINKEDIN_CONSUMER_ID, + SECRET: this.variables.LINKEDIN_CONSUMER_SECRET, + CALLBACK_URL: `${this.variables.URL as string}/api/${this.variables.API_VERSION as string}/auth/linkedin/callback` + }, + LOGS: { + PATH: this.variables.LOGS_PATH, + TOKEN: this.variables.LOGS_TOKEN + }, + MEMORY_CACHE: { + IS_ACTIVE: this.variables.MEMORY_CACHE, + DURATION: this.variables.MEMORY_CACHE_DURATION + }, + PORT: this.variables.PORT, + REFRESH_TOKEN: { + DURATION: this.variables.REFRESH_TOKEN_DURATION, + UNIT: this.variables.REFRESH_TOKEN_UNIT + }, + SCALING: { + IS_ACTIVE: this.variables.RESIZE_IS_ACTIVE, + PATH_MASTER: this.variables.RESIZE_PATH_MASTER, + PATH_SCALE: this.variables.RESIZE_PATH_SCALE, + SIZES: { + XS: this.variables.RESIZE_SIZE_XS, + SM: this.variables.RESIZE_SIZE_SM, + MD: this.variables.RESIZE_SIZE_MD, + LG: this.variables.RESIZE_SIZE_LG, + XL: this.variables.RESIZE_SIZE_XL + } + }, + SSL: { + IS_ACTIVE: this.variables.SSL_CERT !== null && this.variables.SSL_KEY !== null, + CERT: this.variables.SSL_CERT, + KEY: this.variables.SSL_KEY + }, + TYPEORM: { + DB: this.variables.TYPEORM_DB, + NAME: this.variables.TYPEORM_NAME, + TYPE: this.variables.TYPEORM_TYPE, + HOST: this.variables.TYPEORM_HOST, + PORT: this.variables.TYPEORM_PORT, + PWD: this.variables.TYPEORM_PWD, + USER: this.variables.TYPEORM_USER, + SYNC: this.variables.TYPEORM_SYNC, + LOG: this.variables.TYPEORM_LOG, + CACHE: !this.variables.MEMORY_CACHE && this.variables.TYPEORM_CACHE, + CACHE_DURATION: !this.variables.MEMORY_CACHE && this.variables.TYPEORM_CACHE ? this.variables.TYPEORM_CACHE_DURATION : 0, + ENTITIES: `${process.cwd()}/${this.base}/api/models/**/*.js`, + MIGRATIONS: `${process.cwd()}/${this.base}/migrations/**/*.js`, + SUBSCRIBERS: `${process.cwd()}/${this.base}/api/subscribers/**/*.subscriber.js` + }, + UPLOAD: { + MAX_FILE_SIZE: this.variables.UPLOAD_MAX_FILE_SIZE, + MAX_FILES: this.variables.UPLOAD_MAX_FILES, + PATH: this.variables.UPLOAD_PATH, + WILDCARDS: this.variables.UPLOAD_WILDCARDS + }, + URL: this.variables.URL + }; + + return this; } - const dbCache = cch.isActive && cch.type === 'DB' ? { cache: { duration: cch.lifetime } } : {}; - - return Object.freeze({...{ - type: (DATABASE_ENGINE[args.TYPEORM_TYPE as string] || 'mysql') as DATABASE, - name: (args.TYPEORM_NAME || 'default'), - port: parseInt(args.TYPEORM_PORT as string, 10), - host: args.TYPEORM_HOST, - database: args.TYPEORM_DB, - user: args.TYPEORM_USER, - pwd: args.TYPEORM_PWD, - sync: environment === ENVIRONMENT.production ? false : !!args.TYPEORM_SYNC, - log: !!args.TYPEORM_LOG, - entities: `${process.cwd()}/${EnvironmentConfiguration.base}/api/models/**/*.js`, - migrations: `${process.cwd()}/${EnvironmentConfiguration.base}/migrations/**/*.js`, - subscribers: `${process.cwd()}/${EnvironmentConfiguration.base}/api/subscribers/**/*.subscriber.js` - }, ...dbCache }); - -})( typeormParams, env, cache); - -/** - * @description Refresh token duration parameters - * - * - duration: duration length. Default: 30 - * - unit: unit of duration (hours|days|weeks|months). Default: days - */ -const refresh = ((duration: string, unit: string) => { - if(duration && isNaN(parseInt(duration, 10)) ) { - throw new Error('REFRESH_TOKEN_LIFETIME bad value. Duration must be a number.'); - } - if(unit && ['hours', 'days', 'weeks', 'months'].includes(unit) ) { - throw new Error('REFRESH_TOKEN_UNIT bad value. Unity must be one of hours, days, weeks, months.'); + /** + * @description Exit of current process with error messages + * + * @param messages + */ + exit(messages: string|string[]): void { + process.stdout.write('\n\x1b[41m[ERROR]\x1b[40m\n\n'); + process.stdout.write([].concat(messages).join('\n')); + process.exit(0); } - return { duration: parseInt(duration, 10) || 30, unit: (unit || 'days') as MOMENT_UNIT } -})(process.env.REFRESH_TOKEN_DURATION, process.env.REFRESH_TOKEN_UNIT); +} -/** - * @description File upload default configuration. Can be setted on the fly when you define upload middleware options - * - * - destination: upload directory path - * - filesize: max filesize - * - wildcards: accepted mime-type - * - maxFiles: max number of files by request - */ -const uploadParams = Object.keys(process.env) - .filter(key => key.lastIndexOf('UPLOAD') !== -1) - .reduce( (acc, current) => { - acc[current] = process.env[current]; - return acc; - }, {}); - -const upload = ((args: Record) => { - const mimes = { AUDIO: AUDIO_MIME_TYPE, ARCHIVE: ARCHIVE_MIME_TYPE, DOCUMENT: DOCUMENT_MIME_TYPE, IMAGE: IMAGE_MIME_TYPE, VIDEO: VIDEO_MIME_TYPE }; - const input = args.UPLOAD_WILDCARDS ? args.UPLOAD_WILDCARDS.toString().split(',') : Object.keys(mimes); - const wildcards = input - .filter(key => mimes[key]) - .map(key => mimes[key] as Record ) - .reduce((acc,current) => { - return [...acc as string[], ...list(current)] as string[]; - }, []); - return { - destination: `${process.cwd()}/${EnvironmentConfiguration.base}/${args.UPLOAD_PATH as string || 'public'}`, - filesize: parseInt(args.UPLOAD_MAX_FILE_SIZE as string, 10) || 1000000, - wildcards, - maxFiles: parseInt(args.UPLOAD_MAX_FILES as string, 10) || 5 - }; -})(uploadParams); +const environment = new Environment().loads(process.versions.node).extracts(process.env).validates().aggregates(); -/** - * @description Current API version - */ -const version = process.env.API_VERSION || 'v1'; +if (!environment.isValid()) { + environment.exit(environment.errors); +} -export { authorized, cache, contentType, domain, env, jwtSecret, jwtExpirationInterval, logs, port, refresh, resize, ssl, typeorm, upload, version }; \ No newline at end of file +export default environment.variables; + +type OauthCluser = { KEY: string, IS_ACTIVE: boolean, ID: string, SECRET: string, CALLBACK_URL: string } +type JwtCluster = { SECRET: string, EXPIRATION: number }; +type MemoryCluster = { IS_ACTIVE: boolean, DURATION: number }; +type SSLCluster = { IS_ACTIVE: boolean, CERT: string, KEY: string }; +type TypeormCluster = { DB: string, NAME: string, TYPE: DATABASE, HOST: string, PORT: number, PWD: string, USER: string, SYNC: boolean, LOG: boolean, CACHE: boolean, ENTITIES: string, MIGRATIONS: string, SUBSCRIBERS: string }; +type LogCluster = { PATH: string, TOKEN: string }; +type UploadCluster = { MAX_FILE_SIZE: number, MAX_FILES: number, PATH: string, WILDCARDS: string[] }; +type ScalingCluster = { IS_ACTIVE: boolean, PATH_MASTER: string, PATH_SCALE: string, SIZES: { XS: number, SM: number, MD: number, LG: number, XL: number } }; +type RefreshTokenCluster = { DURATION: number, UNIT: MOMENT_UNIT }; + +const API_VERSION = environment.cluster.API_VERSION as string; +const AUTHORIZED = environment.cluster.AUTHORIZED as string; +const CONTENT_TYPE = environment.cluster.CONTENT_TYPE as string; +const DOMAIN = environment.cluster.DOMAINE as string; +const ENV = environment.cluster.ENV as string; +const FACEBOOK = environment.cluster.FACEBOOK as OauthCluser; +const GITHUB = environment.cluster.GITHUB as OauthCluser; +const GOOGLE = environment.cluster.GOOGLE as OauthCluser; +const JWT = environment.cluster.JWT as JwtCluster; +const LINKEDIN = environment.cluster.LINKEDIN as OauthCluser; +const LOGS = environment.cluster.LOGS as LogCluster; +const MEMORY_CACHE = environment.cluster.MEMORY_CACHE as MemoryCluster; +const PORT = environment.cluster.PORT as number; +const REFRESH_TOKEN = environment.cluster.REFRESH_TOKEN as RefreshTokenCluster; +const SCALING = environment.cluster.SCALING as ScalingCluster; +const SSL = environment.cluster.SSL as SSLCluster; +const TYPEORM = environment.cluster.TYPEORM as TypeormCluster; +const UPLOAD = environment.cluster.UPLOAD as UploadCluster; +const URL = environment.cluster.URL as string; + +export { API_VERSION, AUTHORIZED, CONTENT_TYPE, DOMAIN, ENV, FACEBOOK, GITHUB, GOOGLE, LINKEDIN, JWT, LOGS, MEMORY_CACHE, PORT, REFRESH_TOKEN, SCALING, SSL, TYPEORM, UPLOAD, URL } \ No newline at end of file diff --git a/src/api/config/index.ts b/src/api/config/index.ts new file mode 100644 index 0000000..44ad01c --- /dev/null +++ b/src/api/config/index.ts @@ -0,0 +1,7 @@ +import * as environment from '@config/environment.config'; +import * as passport from '@config/passport.config'; +import * as server from '@config/server.config'; +import * as typeorm from '@config/typeorm.config'; +import * as winston from '@config/winston.config'; + +export { environment, passport, server, typeorm, winston } \ No newline at end of file diff --git a/src/api/config/passport.config.ts b/src/api/config/passport.config.ts index 1e47044..60913f0 100644 --- a/src/api/config/passport.config.ts +++ b/src/api/config/passport.config.ts @@ -1,11 +1,13 @@ -import { jwtSecret } from '@config/environment.config'; +import { JWT, FACEBOOK, GOOGLE, GITHUB, LINKEDIN } from '@config/environment.config'; -import * as BearerStrategy from 'passport-http-bearer'; +import { use } from 'passport'; +import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; +import { Strategy as FacebookStrategy } from 'passport-facebook'; +import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth'; +import { Strategy as GithubStrategy } from 'passport-github2'; +import { Strategy as LinkedInStrategy } from 'passport-linkedin-oauth2'; -import { Strategy as JwtStrategy } from 'passport-jwt'; -import { ExtractJwt } from 'passport-jwt'; - -import { oAuth, jwt } from '@services/auth.service'; +import { AuthService } from '@services/auth.service'; const ExtractJwtAlias = ExtractJwt as { fromAuthHeaderWithScheme: (type: string) => string }; @@ -19,7 +21,7 @@ export class PassportConfiguration { */ private static options = { jwt: { - secretOrKey: jwtSecret, + secretOrKey: JWT.SECRET, jwtFromRequest: ExtractJwtAlias.fromAuthHeaderWithScheme('Bearer') } }; @@ -31,14 +33,51 @@ export class PassportConfiguration { * * @param strategy Strategy to instanciate */ - static factory (strategy: string): JwtStrategy|BearerStrategy { + static factory (strategy: string): unknown { switch(strategy) { case 'jwt': - return new JwtStrategy( PassportConfiguration.options.jwt, jwt ); + return new JwtStrategy( PassportConfiguration.options.jwt, AuthService.jwt ) as unknown; case 'facebook': - return new BearerStrategy( oAuth('facebook') ); + return new FacebookStrategy({ + clientID: FACEBOOK.ID, + clientSecret: FACEBOOK.SECRET, + callbackURL: FACEBOOK.CALLBACK_URL, + profileFields: ['id', 'link', 'email', 'name', 'picture', 'address'] + }, AuthService.oAuth ) as unknown; case 'google': - return new BearerStrategy( oAuth('google') ); + return new GoogleStrategy({ + clientID: GOOGLE.ID, + clientSecret: GOOGLE.SECRET, + callbackURL: GOOGLE.CALLBACK_URL, + scope: ['profile', 'email'] + }, AuthService.oAuth ) as unknown; + case 'github': + return new GithubStrategy({ + clientID: GITHUB.ID, + clientSecret: GITHUB.SECRET, + callbackURL: GITHUB.CALLBACK_URL, + scope: ['profile', 'email'] + }, AuthService.oAuth ) as unknown; + case 'linkedin': + return new LinkedInStrategy({ + clientID: LINKEDIN.ID, + clientSecret: LINKEDIN.SECRET, + callbackURL: LINKEDIN.CALLBACK_URL, + scope: ['profile', 'email'] + }, AuthService.oAuth ) as unknown; } } + + /** + * @description + */ + static plug(): void { + use(PassportConfiguration.factory('jwt')); + [ FACEBOOK, GITHUB, GOOGLE, LINKEDIN ] + .filter(provider => provider.IS_ACTIVE) + .forEach(provider => { + use(PassportConfiguration.factory(provider.KEY)) + }); + } + } \ No newline at end of file diff --git a/src/api/config/server.config.ts b/src/api/config/server.config.ts index 69ca960..daae21e 100644 --- a/src/api/config/server.config.ts +++ b/src/api/config/server.config.ts @@ -1,25 +1,31 @@ import * as Express from 'express'; -import { ssl, port } from '@config/environment.config'; +import { SSL, PORT } from '@config/environment.config'; import { readFileSync } from 'fs'; import { Server as HttpsServer, createServer } from 'https'; +/** + * @description + */ export class ServerConfiguration { - /** - * - */ - static options = { - credentials: { - key: ssl.isActive ? readFileSync(ssl.key, 'utf8') : null, - cert: ssl.isActive ? readFileSync(ssl.cert, 'utf8') : null, - }, - port - } + /** + * + */ + static options = { + credentials: { + key: SSL.IS_ACTIVE ? readFileSync(SSL.KEY, 'utf8') : null, + cert: SSL.IS_ACTIVE ? readFileSync(SSL.CERT, 'utf8') : null, + }, + port: PORT + } - constructor() {} - - static server(app: Express.Application): Express.Application|HttpsServer { - return ssl.isActive ? createServer(ServerConfiguration.options.credentials, app) : app - } + /** + * @description + * + * @param app Express application + */ + static server(app: Express.Application): Express.Application|HttpsServer { + return SSL.IS_ACTIVE ? createServer(ServerConfiguration.options.credentials, app) : app + } } diff --git a/src/api/config/typeorm.config.ts b/src/api/config/typeorm.config.ts index e2c32f8..c18eb70 100644 --- a/src/api/config/typeorm.config.ts +++ b/src/api/config/typeorm.config.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import { createConnection, Connection } from 'typeorm'; -import { env, typeorm } from '@config/environment.config'; +import { ENV } from '@config/environment.config'; import { Logger } from '@services/logger.service'; /** @@ -17,24 +17,24 @@ export class TypeormConfiguration { * @description Connect to MySQL server * @async */ - static async connect(): Promise { + static async connect(options: Record): Promise { return new Promise( (resolve, reject) => { createConnection({ - type: typeorm.type, - name: typeorm.name, - host: typeorm.host, - port: typeorm.port, - username: typeorm.user, - password: typeorm .pwd, - database: typeorm.database, - entities: [ typeorm.entities ], - subscribers: [ typeorm.subscribers ], - synchronize: typeorm.sync, - logging: typeorm.log, - cache: typeorm.cache + type: options.TYPE, + name: options.NAME, + host: options.HOST, + port: options.PORT, + username: options.USER, + password: options.PWD, + database: options.DB, + entities: [ options.ENTITIES ], + subscribers: [ options.SUBSCRIBERS ], + synchronize: options.SYNC, + logging: options.LOG, + cache: options.CACHE } as any) .then( (connection: Connection) => { - Logger.log('info', `Connection to MySQL server established on port ${typeorm.port} (${env})`); + Logger.log('info', `Connection to MySQL server established on port ${options.port as string} (${ENV})`); resolve(connection); }) .catch( (error: Error) => { diff --git a/src/api/config/winston.config.ts b/src/api/config/winston.config.ts index 0f02074..998e011 100644 --- a/src/api/config/winston.config.ts +++ b/src/api/config/winston.config.ts @@ -1,7 +1,7 @@ import * as Winston from 'winston'; import { format, Logger as WinstonLogger } from 'winston'; -import { env, logs } from '@config/environment.config'; +import { ENV, LOGS } from '@config/environment.config'; /** * This logger implements Winston module for writing custom logs @@ -32,7 +32,7 @@ export class WinstonConfiguration { format.timestamp(), WinstonConfiguration.formater ), - filename: `${logs.path}/error.log`, + filename: `${LOGS.PATH}/error.log`, handleException: true, json: true, maxSize: 5242880, // 5MB @@ -45,7 +45,7 @@ export class WinstonConfiguration { format.timestamp(), WinstonConfiguration.formater ), - filename: `${logs.path}/combined.log`, + filename: `${LOGS.PATH}/combined.log`, handleException: false, json: true, maxSize: 5242880, // 5MB @@ -91,7 +91,7 @@ export class WinstonConfiguration { }); // If we're not in production||test then log to the `console` - if ( !['production', 'test'].includes(env) ) { + if ( !['production', 'test'].includes(ENV) ) { logger.add( new Winston.transports.Console(WinstonConfiguration.options.console) ); } diff --git a/src/api/controllers/auth.controller.ts b/src/api/controllers/auth.controller.ts index 6f03e9c..22194c5 100644 --- a/src/api/controllers/auth.controller.ts +++ b/src/api/controllers/auth.controller.ts @@ -6,8 +6,9 @@ import { IResponse } from '@interfaces/IResponse.interface'; import { User } from '@models/user.model'; import { RefreshToken } from '@models/refresh-token.model'; import { UserRepository } from '@repositories/user.repository'; -import { generateTokenResponse } from '@services/auth.service'; +import { AuthService } from '@services/auth.service'; import { safe } from '@decorators/safe.decorator'; +import { IUserRequest } from '@interfaces/IUserRequest.interface'; /** * Manage incoming requests from api/{version}/auth @@ -27,7 +28,7 @@ export class AuthController { const repository = getRepository(User); const user = new User(req.body); await repository.insert(user); - const token = await generateTokenResponse(user, user.token()); + const token = await AuthService.generateTokenResponse(user, user.token()); res.locals.data = { token, user }; } @@ -41,7 +42,7 @@ export class AuthController { static async login(req: Request, res: IResponse): Promise { const repository = getCustomRepository(UserRepository); const { user, accessToken } = await repository.findAndGenerateToken(req.body); - const token = await generateTokenResponse(user, accessToken); + const token = await AuthService.generateTokenResponse(user, accessToken); res.locals.data = { token, user }; } @@ -52,24 +53,10 @@ export class AuthController { * @param res Express response object */ @safe - static async oAuth (req: Request, res: IResponse): Promise { - const user = req.body as User; + static async oAuth (req: IUserRequest, res: IResponse): Promise { + const user = req.user as User; const accessToken = user.token(); - const token = await generateTokenResponse(user, accessToken); - res.locals.data = { token, user }; - } - - /** - * @description Login with an existing user or creates a new one if valid accessToken token - * - * @param req Express request object derived from http.incomingMessage - * @param res Express response object - */ - @safe - static async authorize (req: Request, res: IResponse): Promise { - const user = req.body as User; - const accessToken = user.token(); - const token = await generateTokenResponse(user, accessToken); + const token = await AuthService.generateTokenResponse(user, accessToken); res.locals.data = { token, user }; } @@ -98,7 +85,7 @@ export class AuthController { // Get owner user of the token const { user, accessToken } = await userRepository.findAndGenerateToken({ email: refreshToken.user.email , refreshToken }); - const response = await generateTokenResponse(user, accessToken); + const response = await AuthService.generateTokenResponse(user, accessToken); res.locals.data = { token: response }; } diff --git a/src/api/controllers/index.ts b/src/api/controllers/index.ts new file mode 100644 index 0000000..f1574f5 --- /dev/null +++ b/src/api/controllers/index.ts @@ -0,0 +1,6 @@ +import { AuthController } from '@controllers/auth.controller'; +import { MainController } from '@controllers/main.controller'; +import { MediaController } from '@controllers/media.controller'; +import { UserController } from '@controllers/user.controller'; + +export { AuthController, MainController, MediaController, UserController } \ No newline at end of file diff --git a/src/api/factories/refresh-token.factory.ts b/src/api/factories/refresh-token.factory.ts index 7227378..b934100 100644 --- a/src/api/factories/refresh-token.factory.ts +++ b/src/api/factories/refresh-token.factory.ts @@ -3,7 +3,7 @@ import * as Moment from 'moment-timezone'; import { randomBytes } from 'crypto'; import { User } from '@models/user.model'; import { RefreshToken } from '@models/refresh-token.model'; -import { refresh } from '@config/environment.config'; +import { REFRESH_TOKEN } from '@config/environment.config'; /** * @description @@ -17,7 +17,7 @@ export class RefreshTokenFactory { */ static get(user: User): RefreshToken { const token = `${user.id}.${randomBytes(40).toString('hex')}`; - const expires = Moment().add(refresh.duration, refresh.unit).toDate(); + const expires = Moment().add(REFRESH_TOKEN.DURATION, REFRESH_TOKEN.UNIT).toDate(); return new RefreshToken( token, user, expires ); } } \ No newline at end of file diff --git a/src/api/middlewares/cache.middleware.ts b/src/api/middlewares/cache.middleware.ts index 3e6556b..f2116af 100644 --- a/src/api/middlewares/cache.middleware.ts +++ b/src/api/middlewares/cache.middleware.ts @@ -1,6 +1,5 @@ import { Request, Response } from 'express'; import { Cache } from '@services/cache.service'; -import { CACHE } from '@enums/cache.enum'; /** * @description Request cache middleware @@ -10,7 +9,7 @@ import { CACHE } from '@enums/cache.enum'; * @param next Middleware function */ const Kache = async (req: Request, res: Response, next: () => void): Promise => { - if (req.method !== 'GET' || !Cache.options.isActive || Cache.options.type !== CACHE.MEMORY) { + if (req.method !== 'GET' || !Cache.options.IS_ACTIVE) { return next(); } const cached = Cache.resolve.get( Cache.key(req) ) as unknown ; diff --git a/src/api/middlewares/guard.middleware.ts b/src/api/middlewares/guard.middleware.ts index 9162f44..fe44d28 100644 --- a/src/api/middlewares/guard.middleware.ts +++ b/src/api/middlewares/guard.middleware.ts @@ -1,12 +1,13 @@ import { authenticate } from 'passport'; import { promisify } from 'es6-promisify'; -import { forbidden, badRequest } from '@hapi/boom'; +import { forbidden, badRequest, notFound } from '@hapi/boom'; import { User } from '@models/user.model'; import { ROLES } from '@enums/role.enum'; import { list } from '@utils/enum.util'; import { IUserRequest } from '@interfaces/IUserRequest.interface'; import { IResponse } from '@interfaces/IResponse.interface'; +import { OAuthProvider } from '@customtypes/oauth-provider.type'; /** * @description Callback function provided to passport.authenticate with JWT strategy @@ -41,6 +42,29 @@ const handleJWT = (req: IUserRequest, res: IResponse, next: (error?: Error) => v return next(); }; +/** + * @description + * + * @param req + * @param res + * @param nex + */ +const handleOauth = (req: IUserRequest, res: IResponse, next: (error?: Error) => void) => async (err: Error, user: User) => { + + if (err) { + return next( badRequest(err?.message) ); + } else if (!user) { + return next( notFound(err?.message) ); + } else if (!list(ROLES).includes(user.role)) { + return next( forbidden('Forbidden area') ); + } + + req.user = user + + next(); +} + + /** * @description Authorize user access according to role(s) in arguments * @@ -60,6 +84,16 @@ const Authorize = (roles = list(ROLES)) => (req: IUserRequest, res: IResponse, n * @dependency passport * @see http://www.passportjs.org/ */ -const Oauth = (service: string) => authenticate(service, { session: false }); +const Oauth = (service: OAuthProvider) => authenticate(service, { session: false }); + +/** + * @description + * + * @param service OAuthProvider + * + * @dependency passport + * @see http://www.passportjs.org/ + */ +const OauthCallback = (service: OAuthProvider) => (req: IUserRequest, res: IResponse, next: (e?: Error) => void): void => authenticate(service, { session: false }, handleOauth(req, res, next) )( req, res, next); -export { Authorize, Oauth } \ No newline at end of file +export { Authorize, Oauth, OauthCallback } \ No newline at end of file diff --git a/src/api/middlewares/resolver.middleware.ts b/src/api/middlewares/resolver.middleware.ts index 22817a9..874beb0 100644 --- a/src/api/middlewares/resolver.middleware.ts +++ b/src/api/middlewares/resolver.middleware.ts @@ -4,7 +4,6 @@ import { IResponse } from '@interfaces/IResponse.interface'; import { expectationFailed } from '@hapi/boom'; import { getStatusCode } from '@utils/http.util'; import { Cache } from '@services/cache.service'; -import { CACHE } from '@enums/cache.enum'; /** * @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 @@ -36,8 +35,8 @@ const Resolver = async (req: Request, res: IResponse, next: (e?: Error) => void) // The end for the rest if ( ( hasContent && ['GET', 'POST', 'PUT', 'PATCH'].includes(req.method) ) || ( hasStatusCodeOnResponse && res.statusCode !== NOT_FOUND ) ) { - if (req.method === 'GET' && Cache.options.isActive && Cache.options.type === CACHE.MEMORY) { - Cache.resolve.put( Cache.key(req), res.locals.data, Cache.options.lifetime ); + if (req.method === 'GET' && Cache.options.IS_ACTIVE) { + Cache.resolve.put( Cache.key(req), res.locals.data, Cache.options.DURATION ); } res.status( status ); res.json(res.locals.data); diff --git a/src/api/middlewares/sanitizer.middleware.ts b/src/api/middlewares/sanitizer.middleware.ts index 84b7af6..9d6ca1c 100644 --- a/src/api/middlewares/sanitizer.middleware.ts +++ b/src/api/middlewares/sanitizer.middleware.ts @@ -1,6 +1,6 @@ import { Request } from 'express'; -import { contentType } from '@config/environment.config'; +import { CONTENT_TYPE } from '@config/environment.config'; import { CONTENT_MIME_TYPE } from '@enums/mime-type.enum'; import { IResponse } from '@interfaces/IResponse.interface'; @@ -21,7 +21,7 @@ const Sanitizer = async (req: Request, res: IResponse, next: () => void): Promis const hasContent = typeof res.locals.data !== 'undefined'; - if (req.method === 'DELETE' || contentType !== CONTENT_MIME_TYPE['application/json'] || !hasContent) { + if (req.method === 'DELETE' || CONTENT_TYPE !== CONTENT_MIME_TYPE['application/json'] || !hasContent) { return next(); } @@ -29,8 +29,17 @@ const Sanitizer = async (req: Request, res: IResponse, next: () => void): Promis res.locals.data = res.locals.data.map( (data: { whitelist?: string[] } ) => data.whitelist ? sanitize(data as IModel) : data ); } else if (res.locals.data.whitelist) { res.locals.data = sanitize(res.locals.data as IModel); + } else if (typeof res.locals.data === 'object') { + const sanitized = Object.keys(res.locals.data).reduce((acc: any,current: string) => { + if (res.locals.data[current].whitelist) { + acc[current] = sanitize(res.locals.data[current]) + } else { + acc[current] = res.locals.data[current]; + } + return acc; + }, {}) as Record; + res.locals.data = sanitized } - next(); } diff --git a/src/api/middlewares/uploader.middleware.ts b/src/api/middlewares/uploader.middleware.ts index bc2446f..ece4087 100644 --- a/src/api/middlewares/uploader.middleware.ts +++ b/src/api/middlewares/uploader.middleware.ts @@ -5,7 +5,7 @@ import * as filenamify from 'filenamify'; import { MulterError } from 'multer'; import { unsupportedMediaType } from '@hapi/boom'; -import { upload, resize } from '@config/environment.config'; +import { UPLOAD, SCALING } from '@config/environment.config'; import { IMediaRequest } from '@interfaces/IMediaRequest.interface'; import { IResponse } from '@interfaces/IResponse.interface'; @@ -28,7 +28,7 @@ export class Uploader { /** * @description Default options */ - private static default: IUploadOptions = upload; + private static default: IUploadOptions = { destination: UPLOAD.PATH, maxFiles: UPLOAD.MAX_FILES, filesize: UPLOAD.MAX_FILE_SIZE, wildcards: UPLOAD.WILDCARDS}; constructor() { } @@ -73,7 +73,7 @@ export class Uploader { .map( ( media: IMedia ) => { const type = Pluralize(fieldname(media.mimetype)) as string; media.owner = req.user.id; - media.url = `${type}/${type === 'image' ? `${resize.destinations.master}/` : ''}${media.filename}` + media.url = `${type}/${type === 'image' ? `${SCALING.PATH_MASTER}/` : ''}${media.filename}` return media; }) || []; next(); @@ -112,7 +112,7 @@ export class Uploader { destination: (req: Request, file: IMedia, next: (e?: Error, v?: any) => void) => { let towards = `${destination}/${Pluralize(fieldname(file.mimetype)) as string}`; if (IMAGE_MIME_TYPE[file.mimetype]) { - towards += `/${resize.destinations.master}`; + towards += `/${SCALING.PATH_MASTER}`; } next(null, towards); }, diff --git a/src/api/models/user.model.ts b/src/api/models/user.model.ts index 4426c4c..d369978 100644 --- a/src/api/models/user.model.ts +++ b/src/api/models/user.model.ts @@ -6,7 +6,7 @@ import * as Bcrypt from 'bcrypt'; import { Entity, PrimaryGeneratedColumn, Column, BeforeUpdate, AfterLoad, BeforeInsert, OneToMany } from 'typeorm'; import { badImplementation } from '@hapi/boom'; -import { jwtSecret, jwtExpirationInterval } from '@config/environment.config'; +import { JWT } from '@config/environment.config'; import { ROLE, ROLES } from '@enums/role.enum'; import { Media } from '@models/media.model'; import { IModel } from '@interfaces/IModel.interface'; @@ -115,11 +115,11 @@ export class User implements IModel { */ token(): string { const payload = { - exp: Moment().add(jwtExpirationInterval, 'minutes').unix(), + exp: Moment().add(JWT.EXPIRATION, 'minutes').unix(), iat: Moment().unix(), sub: this.id }; - return Jwt.encode(payload, jwtSecret); + return Jwt.encode(payload, JWT.SECRET); } /** diff --git a/src/api/repositories/refresh-token.repository.ts b/src/api/repositories/refresh-token.repository.ts index 941f238..8b8bb99 100644 --- a/src/api/repositories/refresh-token.repository.ts +++ b/src/api/repositories/refresh-token.repository.ts @@ -1,5 +1,4 @@ import { Repository, EntityRepository } from 'typeorm'; -import { expectationFailed } from '@hapi/boom'; import { User } from '@models/user.model'; import { RefreshToken } from '@models/refresh-token.model'; import { RefreshTokenFactory } from '@factories/refresh-token.factory'; @@ -17,12 +16,8 @@ export class RefreshTokenRepository extends Repository { * @param user */ generate(user: User): RefreshToken { - try { - const refreshToken = RefreshTokenFactory.get(user); - void this.save(refreshToken); - return refreshToken; - } catch(e) { - throw expectationFailed(e.message); - } + const refreshToken = RefreshTokenFactory.get(user); + void this.save(refreshToken); + return refreshToken; } } diff --git a/src/api/repositories/user.repository.ts b/src/api/repositories/user.repository.ts index 053fc51..47d1206 100644 --- a/src/api/repositories/user.repository.ts +++ b/src/api/repositories/user.repository.ts @@ -2,13 +2,12 @@ import * as Moment from 'moment-timezone'; import { Repository, EntityRepository, getRepository } from 'typeorm'; import { omitBy, isNil } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; import { badRequest, notFound, unauthorized } from '@hapi/boom'; import { User } from '@models/user.model'; import { IUserQueryString } from '@interfaces/IUserQueryString.interface'; -import { IAuthExternalProvider } from '@interfaces/IAuthExternalProvider.interface'; import { ITokenOptions } from '@interfaces/ITokenOptions.interface'; +import { IRegistrable } from '@interfaces/IRegistrable.interface'; @EntityRepository(User) export class UserRepository extends Repository { @@ -103,27 +102,33 @@ export class UserRepository extends Repository { * @description Create / save user for oauth connexion * * @param options + * + * FIXME: user should always retrieved from her email address. If not, possible collision on username value */ - async oAuthLogin(options: IAuthExternalProvider): Promise { + async oAuthLogin(options: IRegistrable): Promise { - const { email, name } = options; + const { email, username, password } = options; const userRepository = getRepository(User); - const user = await userRepository.findOne({ - where: { email }, + let user = await userRepository.findOne({ + where: [ { email }, { username } ], }); if (user) { if (!user.username) { - user.username = name; + user.username = username; + await userRepository.save(user) + } + if (user.email.includes('externalprovider') && !email.includes('externalprovider')) { + user.email = email; + await userRepository.save(user) } - return userRepository.save(user); + return user; } - const password = uuidv4(); - - return userRepository.create({ email, password, username: name }); + user = userRepository.create({ email, password, username }); + return userRepository.save(user); } } diff --git a/src/api/routes/v1/auth.route.ts b/src/api/routes/v1/auth.route.ts index 695d682..31c0cec 100644 --- a/src/api/routes/v1/auth.route.ts +++ b/src/api/routes/v1/auth.route.ts @@ -1,9 +1,8 @@ import { Router } from '@bases/router.class'; -import { Oauth } from '@middlewares/guard.middleware'; import { Validate } from '@middlewares/validator.middleware'; +import { Oauth, OauthCallback } from '@middlewares/guard.middleware'; import { AuthController } from '@controllers/auth.controller'; - -import { register, login, oAuth, refresh } from '@validations/auth.validation'; +import { register, login, refresh, oauthCb } from '@validations/auth.validation'; export class AuthRouter extends Router { @@ -329,7 +328,37 @@ export class AuthRouter extends Router { .post(Validate(refresh), AuthController.refresh); /** - * @api {post} /auth/facebook Facebook oauth + * @api {get} /auth/facebook Facebook oauth + * @apiDescription Login with facebook. Obtains facebook authorization for oAuth + * @apiVersion 1.0.0 + * @apiName FacebookLogin + * @apiGroup Auth + * @apiPermission public + * + * @apiUse BaseHeaderSimple + * + * @apiUse SuccessToken + * + * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values + * @apiError (Unauthorized 401) Unauthorized Incorrect access_token + * + * + * @apiErrorExample {json} Unauthorized example + * { + * "statusCode": 401, + * "statusText": "Unauthorized", + * "errors": [ + * "Invalid access token" + * ] + * } + * + */ + this.router + .route('/facebook') + .get( Oauth('facebook') ); + + /** + * @api {post} /auth/facebook/callback Callback URL Facebook oauth * @apiDescription Login with facebook. Creates a new user if it does not exist. * @apiVersion 1.0.0 * @apiName FacebookLogin @@ -351,34 +380,47 @@ export class AuthRouter extends Router { * "statusText": "Bad request", * "errors": [ * { - * "field": "access_token", + * "field": "code", * "types": [ * "string.base" * ], * "messages": [ - * "\"access_token\" must be a string" + * "\"code\" must be a string" * ] * } * ] * } * - * @apiErrorExample {json} Unauthorized example - * { - * "statusCode": 401, - * "statusText": "Unauthorized", - * "errors": [ - * "Invalid access token" - * ] - * } + */ + this.router + .route('/facebook/callback') + .get( Validate(oauthCb), OauthCallback('facebook'), AuthController.oAuth ); + + /** + * @api {post} /auth/google Google oauth + * @apiDescription Login with google. + * @apiVersion 1.0.0 + * @apiName GoogleLogin + * @apiGroup Auth + * @apiPermission public + * + * @apiUse BaseHeaderSimple + * + * @apiParam {String} access_token Google's access_token + * + * @apiUse SuccessToken + * + * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values + * @apiError (Unauthorized 401) Unauthorized Incorrect access_token * */ this.router - .route('/facebook') - .post(Validate(oAuth), Oauth('facebook'), AuthController.oAuth); + .route('/google') + .get( Oauth('google'), AuthController.oAuth ); /** - * @api {post} /auth/google Google oauth - * @apiDescription Login with google. Creates a new user if it does not exist. + * @api {post} /auth/google/callback Callback URL Google oauth + * @apiDescription Login with Google. Creates a new user if it does not exist. * @apiVersion 1.0.0 * @apiName GoogleLogin * @apiGroup Auth @@ -399,30 +441,140 @@ export class AuthRouter extends Router { * "statusText": "Bad request", * "errors": [ * { - * "field": "access_token", + * "field": "code", * "types": [ * "string.base" * ], * "messages": [ - * "\"access_token\" must be a string" + * "\"code\" must be a string" * ] * } * ] * } + */ + this.router + .route('/google/callback') + .get( Validate(oauthCb), OauthCallback('google'), AuthController.oAuth ); + + /** + * @api {post} /auth/github Github oauth + * @apiDescription Login with Github. + * @apiVersion 1.0.0 + * @apiName GithubLogin + * @apiGroup Auth + * @apiPermission public + * + * @apiUse BaseHeaderSimple + * + * @apiParam {String} access_token Github access_token + * + * @apiUse SuccessToken + * + * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values + * @apiError (Unauthorized 401) Unauthorized Incorrect access_token * - * @apiErrorExample {json} Unauthorized - * { - * "statusCode": 401, - * "statusText": "Unauthorized", - * "errors": [ - * "Invalid access token" - * ] - * } */ + this.router + .route('/github') + .get( Oauth('github'), AuthController.oAuth ); + + /** + * @api {post} /auth/github/callback Callback URL Github oauth + * @apiDescription Login with Github. Creates a new user if it does not exist. + * @apiVersion 1.0.0 + * @apiName GithubLogin + * @apiGroup Auth + * @apiPermission public + * + * @apiUse BaseHeaderSimple + * + * @apiParam {String} access_token Twitter access_token + * + * @apiUse SuccessToken + * + * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values + * @apiError (Unauthorized 401) Unauthorized Incorrect access_token + * + * @apiErrorExample {json} ValidationError + * { + * "statusCode": 400, + * "statusText": "Bad request", + * "errors": [ + * { + * "field": "code", + * "types": [ + * "string.base" + * ], + * "messages": [ + * "\"code\" must be a string" + * ] + * } + * ] + * } + */ this.router - .route('/google') - .post(Validate(oAuth), Oauth('google'), AuthController.oAuth); + .route('/github/callback') + .get( Validate(oauthCb), OauthCallback('github'), AuthController.oAuth ); - } + /** + * @api {post} /auth/linkedin Linkedin oauth + * @apiDescription Login with Linkedin. + * @apiVersion 1.0.0 + * @apiName LinkedinLogin + * @apiGroup Auth + * @apiPermission public + * + * @apiUse BaseHeaderSimple + * + * @apiParam {String} access_token Linkedin access_token + * + * @apiUse SuccessToken + * + * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values + * @apiError (Unauthorized 401) Unauthorized Incorrect access_token + * + */ + this.router + .route('/linkedin') + .get( Oauth('linkedin'), AuthController.oAuth ); + /** + * @api {post} /auth/linkedin/callback Callback URL Github oauth + * @apiDescription Login with Linkedin. Creates a new user if it does not exist. + * @apiVersion 1.0.0 + * @apiName LinkedinLogin + * @apiGroup Auth + * @apiPermission public + * + * @apiUse BaseHeaderSimple + * + * @apiParam {String} access_token Linkedin access_token + * + * @apiUse SuccessToken + * + * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values + * @apiError (Unauthorized 401) Unauthorized Incorrect access_token + * + * @apiErrorExample {json} ValidationError + * { + * "statusCode": 400, + * "statusText": "Bad request", + * "errors": [ + * { + * "field": "code", + * "types": [ + * "string.base" + * ], + * "messages": [ + * "\"code\" must be a string" + * ] + * } + * ] + * } + */ + this.router + .route('/linkedin/callback') + .get( Validate(oauthCb), OauthCallback('linkedin'), AuthController.oAuth ); + + } } \ No newline at end of file diff --git a/src/api/services/auth-provider.service.ts b/src/api/services/auth-provider.service.ts deleted file mode 100644 index eb0b018..0000000 --- a/src/api/services/auth-provider.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { default as Axios } from 'axios'; - -import { IAuthExternalProvider } from '@interfaces/IAuthExternalProvider.interface'; - -/** - * @description - */ -export class AuthProvider { - - constructor() {} - - /** - * @description Try to connect to Facebook - * - * @param access_token Token registered on user - */ - static facebook = async (access_token: string): Promise => { - const fields = 'id, name, email, picture'; - const url = 'https://graph.facebook.com/me'; - const params = { access_token, fields }; - const response = await Axios.get(url, { params }); - const { id, name, email, picture } = response.data as { id: number, name: string, email: string, picture: { data: { url: string } } }; - return { - service: 'facebook', - picture: picture.data.url, - id, - name, - email - }; - } - - /** - * @description Try to connect to Google - * - * @param access_token Token registered on user - */ - static google = async (access_token: string): Promise => { - const url = 'https://www.googleapis.com/oauth2/v3/userinfo'; - const params = { access_token }; - const response = await Axios.get(url, { params }); - const { sub, name, email, picture } = response.data as { sub: number, name: string, email: string, picture: string }; - return { - service: 'google', - picture, - id: sub, - name, - email - }; - } - -} \ No newline at end of file diff --git a/src/api/services/auth.service.ts b/src/api/services/auth.service.ts index 777011f..dcc8f1f 100644 --- a/src/api/services/auth.service.ts +++ b/src/api/services/auth.service.ts @@ -2,7 +2,7 @@ import * as Moment from 'moment-timezone'; import { getCustomRepository, getRepository } from 'typeorm'; -import { jwtExpirationInterval } from '@config/environment.config'; +import { JWT } from '@config/environment.config'; import { UserRepository } from '@repositories/user.repository'; import { RefreshTokenRepository } from '@repositories/refresh-token.repository'; @@ -10,57 +10,83 @@ import { RefreshTokenRepository } from '@repositories/refresh-token.repository'; import { User } from '@models/user.model'; import { RefreshToken } from '@models/refresh-token.model'; -import { AuthProvider } from '@services/auth-provider.service'; +import { badData } from '@hapi/boom'; +import { IOauthResponse } from '@interfaces/IOauthResponse.interface'; -/** - * @description Build a token response and return it - * - * @param user - * @param accessToken - */ -const generateTokenResponse = async (user : User, accessToken : string): Promise<{ tokenType, accessToken, refreshToken, expiresIn }> => { - const tokenType = 'Bearer'; - const oldToken = await getRepository(RefreshToken).findOne({ where : { user } }); - if (oldToken) { - await getRepository(RefreshToken).remove(oldToken) - } - const refreshToken = getCustomRepository(RefreshTokenRepository).generate(user).token; - const expiresIn = Moment().add(jwtExpirationInterval, 'minutes'); - return { tokenType, accessToken, refreshToken, expiresIn }; -} +import { hash } from '@utils/string.util'; + +export class AuthService { -/** - * @description Authentication by oAuth middleware function - * @async - */ -const oAuth = (service: 'facebook' | 'google') => async (token, next: (e?: Error, v?: User) => void): Promise => { - try { - const userRepository = getCustomRepository(UserRepository); - const userData = await AuthProvider[service](token); - const user = await userRepository.oAuthLogin(userData); - next(null, user); - } catch (err) { - return next(err); + /** + * @description Build a token response and return it + * + * @param user + * @param accessToken + */ + static async generateTokenResponse(user: User, accessToken: string): Promise<{ tokenType, accessToken, refreshToken, expiresIn }|Error> { + if (!user || !(user instanceof User)) { + return badData('User is not an instance of User'); + } + try { + const tokenType = 'Bearer'; + const oldToken = await getRepository(RefreshToken).findOne({ where : { user } }); + if (oldToken) { + await getRepository(RefreshToken).remove(oldToken) + } + const refreshToken = getCustomRepository(RefreshTokenRepository).generate(user).token; + const expiresIn = Moment().add(JWT.EXPIRATION, 'minutes'); + return { tokenType, accessToken, refreshToken, expiresIn }; + } catch(e) { + return e; + } } -} -/** - * @description Authentication by JWT middleware function - * @async - * - * FIXME: promise error is not managed - */ -const jwt = async (payload: { sub }, next: (e?: Error, v?: User|boolean) => void): Promise => { - try { - const userRepository = getRepository(User); - const user = await userRepository.findOne( payload.sub ); - if (user) { - return next(null, user); + /** + * @description Authentication by oAuth processing + * + * @param token Access token of provider + * @param refreshToken Refresh token of provider + * @param profile Shared profile information + * @param next Callback function + * + * FIXME: promise error is not managed + * + * @async + */ + static async oAuth(token: string, refreshToken: string, profile: IOauthResponse, next: (e?: Error, v?: User) => void): Promise { + try { + const iRegistrable = { + id: profile.id, + username: profile.username ? profile.username : `${profile.name.givenName.toLowerCase()}${profile.name.familyName.toLowerCase()}`, + email: profile.emails ? profile.emails.filter(email => (email.hasOwnProperty('verified') && email.verified) || email.value).slice().shift()?.value : `${profile.name.givenName.toLowerCase()}${profile.name.familyName.toLowerCase()}@externalprovider.com`, + picture: profile.photos.slice().shift()?.value, + password: hash('email', 16) + } + const userRepository = getCustomRepository(UserRepository); + const user = await userRepository.oAuthLogin(iRegistrable); + next(null, user); + } catch (err) { + return next(err); } - return next(null, false); - } catch (error) { - return next(error, false); } -} -export { generateTokenResponse, oAuth, jwt }; \ No newline at end of file + /** + * @description Authentication by JWT middleware function + * + * @async + * + * FIXME: promise error is not managed + */ + static async jwt(payload: { sub }, next: (e?: Error, v?: User|boolean) => void): Promise { + try { + const userRepository = getRepository(User); + const user = await userRepository.findOne( payload.sub ); + if (user) { + return next(null, user); + } + return next(null, false); + } catch (error) { + return next(error, false); + } + } +} \ No newline at end of file diff --git a/src/api/services/cache.service.ts b/src/api/services/cache.service.ts index d0fc26b..f520485 100644 --- a/src/api/services/cache.service.ts +++ b/src/api/services/cache.service.ts @@ -2,7 +2,7 @@ import { Request } from 'express'; import * as mcache from 'memory-cache'; -import { cache } from '@config/environment.config'; +import { MEMORY_CACHE } from '@config/environment.config'; import { ICache } from '@interfaces/ICache.interface'; /** @@ -13,7 +13,7 @@ export class Cache { /** * @description */ - static options = cache; + static options = MEMORY_CACHE; /** * @description diff --git a/src/api/services/media.service.ts b/src/api/services/media.service.ts index c5a44b1..d104783 100644 --- a/src/api/services/media.service.ts +++ b/src/api/services/media.service.ts @@ -5,11 +5,16 @@ import { promisify } from 'es6-promisify'; import { expectationFailed } from '@hapi/boom'; import { Media } from '@models/media.model'; -import { resize } from '@config/environment.config'; +import { SCALING } from '@config/environment.config'; import { IMAGE_MIME_TYPE } from '@enums/mime-type.enum'; -const SIZES = Object.keys(resize.sizes).map(key => key); +const SIZES = Object.keys(SCALING.SIZES).map(key => key.toLowerCase()); +/** + * @description + * + * @param media + */ const rescale = (media: Media): void => { void Jimp.read(media.path) .then( (image) => { @@ -17,8 +22,8 @@ const rescale = (media: Media): void => { .forEach( size => { image .clone() - .resize(resize.sizes[size], Jimp.AUTO) - .write(`${media.path.split('/').slice(0, -1).join('/').replace(resize.destinations.master, resize.destinations.scale)}/${size}/${media.filename as string}`, (err: Error) => { + .resize(SCALING.SIZES[size], Jimp.AUTO) + .write(`${media.path.split('/').slice(0, -1).join('/').replace(SCALING.PATH_MASTER, SCALING.PATH_SCALE)}/${size}/${media.filename as string}`, (err: Error) => { if(err) throw expectationFailed(err.message); }); }); @@ -26,13 +31,18 @@ const rescale = (media: Media): void => { .catch(); } +/** + * @description + * + * @param media + */ const remove = (media: Media): void => { const ulink = promisify(unlink) as (path: string) => Promise; if ( !IMAGE_MIME_TYPE[media.mimetype] && existsSync(media.path.toString()) ) { void ulink(media.path.toString()); } else { const promises = SIZES - .map( size => media.path.toString().replace(resize.destinations.master, `${resize.destinations.scale}/${size}`) ) + .map( size => media.path.toString().replace(SCALING.PATH_MASTER, `${SCALING.PATH_SCALE}/${size}`) ) .filter( path => existsSync(path) ) .map( path => ulink(path) ); void Promise.all( [ existsSync(media.path.toString()) ? ulink( media.path.toString() ) : Promise.resolve() ].concat( promises ) ); diff --git a/src/api/subscribers/media.subscriber.ts b/src/api/subscribers/media.subscriber.ts index 3f19394..118563f 100644 --- a/src/api/subscribers/media.subscriber.ts +++ b/src/api/subscribers/media.subscriber.ts @@ -5,7 +5,7 @@ import * as Moment from 'moment-timezone'; import { EventSubscriber, EntitySubscriberInterface, InsertEvent, UpdateEvent, RemoveEvent } from 'typeorm'; import { Media } from '@models/media.model'; -import { resize } from '@config/environment.config'; +import { SCALING } from '@config/environment.config'; import { rescale, remove } from '@services/media.service'; @EventSubscriber() @@ -31,7 +31,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { * @description Called after media deletetion. */ afterInsert(event: InsertEvent): void { - if ( resize.isActive ) { + if ( SCALING.IS_ACTIVE ) { rescale(event.entity); } } diff --git a/src/api/subscribers/user.subscriber.ts b/src/api/subscribers/user.subscriber.ts index 9a3ccd0..1263300 100644 --- a/src/api/subscribers/user.subscriber.ts +++ b/src/api/subscribers/user.subscriber.ts @@ -4,7 +4,7 @@ import * as Moment from 'moment-timezone'; import { EventSubscriber, EntitySubscriberInterface, UpdateEvent } from 'typeorm'; import { User } from '@models/user.model'; import { crypt } from '@utils/string.util'; -import { jwtSecret } from '@config/environment.config'; +import { JWT } from '@config/environment.config'; /** * @@ -25,7 +25,7 @@ export class UserSubscriber implements EntitySubscriberInterface { * @description Called after media deletetion. */ beforeInsert(event: UpdateEvent): void { - event.entity.apikey = crypt(event.entity.email + jwtSecret, 64); + event.entity.apikey = crypt(event.entity.email + JWT.SECRET, 64); event.entity.createdAt = Moment( new Date() ).utc(true).toDate(); } @@ -33,7 +33,7 @@ export class UserSubscriber implements EntitySubscriberInterface { * @description Called after media deletetion. */ beforeUpdate(event: UpdateEvent): void { - event.entity.apikey = crypt(event.entity.email + jwtSecret, 64) + event.entity.apikey = crypt(event.entity.email + JWT.SECRET, 64) event.entity.updatedAt = Moment( new Date() ).utc(true).toDate(); } diff --git a/src/api/types/enums/mime-type.enum.ts b/src/api/types/enums/mime-type.enum.ts index a9c5d46..f08d158 100644 --- a/src/api/types/enums/mime-type.enum.ts +++ b/src/api/types/enums/mime-type.enum.ts @@ -27,6 +27,7 @@ type ARCHIVE = 'application/x-7z-compressed' | 'application/x-rar-compressed' | */ enum AUDIO_MIME_TYPE { 'audio/mpeg' = 'audio/mpeg', + 'audio/mp3' = 'audio/mp3', 'audio/mid' = 'audio/mid', 'audio/mp4' = 'audio/mp4', 'audio/x-aiff' = 'audio/x-aiff', @@ -35,7 +36,7 @@ enum AUDIO_MIME_TYPE { 'audio/vnd.wav' = 'audio/vnd.wav', } -type AUDIO = 'audio/mpeg' | 'audio/mid' | 'audio/mp4' | 'audio/x-aiff' | 'audio/ogg' | 'audio/vorbis' | 'audio/vnd.wav'; +type AUDIO = 'audio/mpeg' | 'audio/mp3' | 'audio/mid' | 'audio/mp4' | 'audio/x-aiff' | 'audio/ogg' | 'audio/vorbis' | 'audio/vnd.wav'; /** * @description Define supported documents mime-types diff --git a/src/api/types/errors/server.error.ts b/src/api/types/errors/server.error.ts index c9cf826..fff09ed 100644 --- a/src/api/types/errors/server.error.ts +++ b/src/api/types/errors/server.error.ts @@ -36,7 +36,7 @@ export class ServerError implements Error, IHTTPError { constructor(error: IError) { this.statusCode = 500; this.statusText = 'Ooops... server seems to be broken'; - this.errors = ['Looks like someone\'s done something wrong again\'']; + this.errors = ['Looks like someone\'s was not there while the meeting\'']; this.stack = error?.stack; } diff --git a/src/api/types/interfaces/IAuthExternalProvider.interface.ts b/src/api/types/interfaces/IAuthExternalProvider.interface.ts deleted file mode 100644 index 80a55ef..0000000 --- a/src/api/types/interfaces/IAuthExternalProvider.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface IAuthExternalProvider { - service: string; - id: number; - name: string; - email: string; - picture: string; -} \ No newline at end of file diff --git a/src/api/types/interfaces/IOauthResponse.interface.ts b/src/api/types/interfaces/IOauthResponse.interface.ts new file mode 100644 index 0000000..6a70c96 --- /dev/null +++ b/src/api/types/interfaces/IOauthResponse.interface.ts @@ -0,0 +1,39 @@ +import { OAuthProvider } from '@customtypes/oauth-provider.type'; + +export interface IOauthResponse { + + /** + * + */ + id: number; + + /** + * + */ + displayName: string, + + /** + * + */ + name?: { familyName: string, givenName: string }, + + /** + * + */ + emails: { value: string, verified?: boolean }[], + + /** + * + */ + photos: { value: string }[], + + /** + * + */ + provider: { name: OAuthProvider, _raw: string, _json: Record } + + /** + * + */ + username?: string; +} \ No newline at end of file diff --git a/src/api/types/interfaces/IRegistrable.interface.ts b/src/api/types/interfaces/IRegistrable.interface.ts new file mode 100644 index 0000000..20faecc --- /dev/null +++ b/src/api/types/interfaces/IRegistrable.interface.ts @@ -0,0 +1,22 @@ +export interface IRegistrable { + + /** + * + */ + username: string; + + /** + * + */ + email: string; + + /** + * + */ + password: string; + + /** + * + */ + picture?: string; +} \ No newline at end of file diff --git a/src/api/types/types/oauth-provider.type.ts b/src/api/types/types/oauth-provider.type.ts new file mode 100644 index 0000000..808da15 --- /dev/null +++ b/src/api/types/types/oauth-provider.type.ts @@ -0,0 +1 @@ +export type OAuthProvider = 'facebook' | 'google' | 'twitter' | 'linkedin' | 'github'; \ No newline at end of file diff --git a/src/api/utils/string.util.ts b/src/api/utils/string.util.ts index 1c88613..963ba02 100644 --- a/src/api/utils/string.util.ts +++ b/src/api/utils/string.util.ts @@ -34,6 +34,8 @@ const hash = (str: string, length: number): string => { * * @param str Base string to crypt * @param length Number of chars to return + * + * FIXME: not working */ const crypt = (str: string, length: number): string => { const table = [].concat(chars).concat(numbers).concat(symbols); @@ -106,4 +108,4 @@ const fieldname = (mimetype: string): string => { }; -export { shuffle, hash, crypt, base64Encode, base64Decode, foldername, extension, fieldname }; \ No newline at end of file +export { base64Encode, base64Decode, crypt, extension, fieldname, foldername, hash, shuffle }; \ No newline at end of file diff --git a/src/api/validations/auth.validation.ts b/src/api/validations/auth.validation.ts index 8528927..2808df6 100644 --- a/src/api/validations/auth.validation.ts +++ b/src/api/validations/auth.validation.ts @@ -35,14 +35,6 @@ const login = { }) }; -// POST api/v1/auth/facebook -// POST api/v1/auth/google -const oAuth = { - body: Joi.object({ - access_token: Joi.string().required(), - }) -}; - // POST api/v1/auth/refresh const refresh = { body: Joi.object({ @@ -52,4 +44,11 @@ const refresh = { }) }; -export { register, login, oAuth, refresh }; \ No newline at end of file +// GEET api/v1/auth/:service/callback +const oauthCb = { + query: Joi.object({ + code: Joi.string().min(8).max(88).required(), + }) +}; + +export { register, login, refresh, oauthCb }; \ No newline at end of file diff --git a/src/env/template.env b/src/env/template.env index a312abf..0c151b5 100644 --- a/src/env/template.env +++ b/src/env/template.env @@ -1,32 +1,32 @@ # API version -# API_VERSION = "v1" +API_VERSION = "v1" # CORS authorized domains, by coma separated WITHOUT spacing IE http://localhost:4200,http://my-domain.com AUTHORIZED = "http://localhost:4200" -# Cache activated -# CACHE_IS_ACTIVE = 0 - -# Cache type to use MEMORY|DB -# CACHE_TYPE = "MEMORY" - -# Cache lifetime duration in milliseconds -# CACHE_LIFETIME = 5000 - # Content-type for communication between api/clients # CONTENT_TYPE = "application/json" # API domain DOMAIN = "localhost" -# HTTPS configuration -# HTTPS_IS_ACTIVE = 0 +# Facebook oauth consumer ID +# FACEBOOK_CONSUMER_ID = "" -# SSL certificate path -# HTTPS_CERT = "path-to-my-ssl.cert" +# Facebook oauth consumer secret +# FACEBOOK_CONSUMER_SECRET = "" + +# Github oauth consumer id +# GITHUB_CONSUMER_ID = "" -# Private key path -# HTTPS_KEY = "path-to-my-private.key" +# Github oauth consumer secret +# GITHUB_CONSUMER_SECRET = "" + +# Google oauth consumer id +# GOOGLE_CONSUMER_ID = "" + +# Google oauth consumer secret +# GOOGLE_CONSUMER_SECRET = "" # JWT Secret passphrase JWT_SECRET = "bA2xcjpf8y5aSUFsNB2qN5yymUBSs6es3qHoFpGkec75RCeBb8cpKauGefw5qy4" @@ -34,12 +34,24 @@ JWT_SECRET = "bA2xcjpf8y5aSUFsNB2qN5yymUBSs6es3qHoFpGkec75RCeBb8cpKauGefw5qy4" # JWT Expiration 3 months in dev # JWT_EXPIRATION_MINUTES = 120960 -# Morgan logs pattern. See morgan doc. -# LOGS_MORGAN_TOKEN = ":remote-addr HTTP/:http-version :status :method :url :total-time[2]ms" +# Linkedin oauth consumer id +# LINKEDIN_CONSUMER_ID = "" + +# Linkedin oauth consumer secret +# LINKEDIN_CONSUMER_SECRET = "" # Logs directory name # LOGS_PATH = "logs" +# Morgan logs pattern. See morgan doc. +# LOGS_TOKEN = ":remote-addr HTTP/:http-version :status :method :url :total-time[2]ms" + +# Memory cache activated +# MEMORY_CACHE = 0 + +# Memory cache lifetime duration in milliseconds +# MEMORY_CACHE_DURATION = 5000 + # Application port. Keep it different in development.env and test.env if you wish launch your tests when your api is running PORT = 8101 @@ -73,6 +85,12 @@ PORT = 8101 # Image extra-large size (px) # RESIZE_SIZE_XL = 1366 +# SSL certificate path +# SSL_CERT = "path-to-my-ssl-key.pem" + +# SSL key path +# SSL_KEY = "path-to-my-ssl-key.pem" + # Database engine TYPEORM_TYPE = "mysql" @@ -100,6 +118,12 @@ TYPEORM_PORT = "3306" # TypeORM queries logs activated # TYPEORM_LOG = 0 +# TypeORM cache activated. Relevant only if MEMORY_CACHE is disabled. +# TYPEORM_CACHE = 0 + +# TypeORM cache duration. Relevant only if MEMORY_CACHE is disabled. +# TYPEORM_CACHE_DURATION = 5000 + # File upload directory name # UPLOAD_PATH = "public" @@ -110,4 +134,7 @@ TYPEORM_PORT = "3306" # UPLOAD_MAX_FILES = 5 # File upload accepted file types in ARCHIVE,AUDIO,DOCUMENT,IMAGE,VIDEO -# UPLOAD_WILDCARDS = "ARCHIVE,AUDIO,DOCUMENT,IMAGE,VIDEO" \ No newline at end of file +# UPLOAD_WILDCARDS = "ARCHIVE,AUDIO,DOCUMENT,IMAGE,VIDEO" + +# Main URL of the app +URL = "http://localhost:8101" \ No newline at end of file diff --git a/test/e2e/02-auth-routes.e2e.test.js b/test/e2e/02-auth-routes.e2e.test.js index a45051e..02bdd59 100644 --- a/test/e2e/02-auth-routes.e2e.test.js +++ b/test/e2e/02-auth-routes.e2e.test.js @@ -133,18 +133,21 @@ describe('Authentification routes', function () { }); }); - it('POST /api/v1/auth/login 404 - email not found', function (done) { + it('POST /api/v1/auth/login 403 - invalid refresh token', function (done) { const payload = clone(credentials); - payload.email = 'fake' + chance.email(); - doRequest(agent, 'post', '/api/v1/auth/login', null, null, payload, 404, function(err, res) { - expect(res.statusCode).to.eqls(404); + delete payload.password; + payload.refreshToken = { user: { email: payload.email }, expires: new Date() } + doRequest(agent, 'post', '/api/v1/auth/login', null, null, payload, 401, function(err, res) { + expect(res.statusCode).to.eqls(401); done(); }); }); - it('POST /api/v1/auth/login 201 - with credentials', function (done) { - doRequest(agent, 'post', '/api/v1/auth/login', null, null, { email: credentials.email, password: password }, 201, function(err, res) { - expect(res.statusCode).to.eqls(201); + it('POST /api/v1/auth/login 404 - email not found', function (done) { + const payload = clone(credentials); + payload.email = 'fake' + chance.email(); + doRequest(agent, 'post', '/api/v1/auth/login', null, null, payload, 404, function(err, res) { + expect(res.statusCode).to.eqls(404); done(); }); }); @@ -159,13 +162,6 @@ describe('Authentification routes', function () { }); }); - it('POST /api/v1/auth/login 201 - with api key', function (done) { - doRequest(agent, 'post', '/api/v1/auth/login', null, null, { apikey }, 201, function(err, res) { - expect(res.statusCode).to.eqls(201); - done(); - }); - }); - it('POST /api/v1/auth/login 201 - with api key + data ok', function (done) { doRequest(agent, 'post', '/api/v1/auth/login', null, null, { apikey }, 201, function(err, res) { expect(res.statusCode).to.eqls(201); @@ -178,6 +174,54 @@ describe('Authentification routes', function () { }); + describe('OAuth', function() { + + describe('Facebook', function() { + + it.skip('GET /api/v1/auth/facebook 302 - oauth redirection is ok', function (done) { + doRequest(agent, 'get', '/api/v1/auth/facebook', null, null, {}, 302, function(err, res) { + expect(res.statusCode).to.eqls(302); + done(); + }); + }); + + }); + + describe('Google', function() { + + it.skip('GET /api/v1/auth/google 302 - oauth redirection is ok', function (done) { + doRequest(agent, 'get', '/api/v1/auth/google', null, null, {}, 302, function(err, res) { + expect(res.statusCode).to.eqls(302); + done(); + }); + }); + + }); + + describe('Github', function() { + + it.skip('GET /api/v1/auth/github 302 - oauth redirection is ok', function (done) { + doRequest(agent, 'get', '/api/v1/auth/github', null, null, {}, 302, function(err, res) { + expect(res.statusCode).to.eqls(302); + done(); + }); + }); + + }); + + describe('Linkedin', function() { + + it.skip('GET /api/v1/auth/linkedin 302 - oauth redirection is ok', function (done) { + doRequest(agent, 'get', '/api/v1/auth/linkedin', null, null, {}, 302, function(err, res) { + expect(res.statusCode).to.eqls(302); + done(); + }); + }); + + }); + + }); + describe('Refresh token', function() { it('POST /api/v1/auth/refresh-token 400 - empty payload', function (done) { @@ -212,26 +256,4 @@ describe('Authentification routes', function () { }); - describe('Oauth Google', function() { - - it('POST /api/v1/auth/google 400 - empty payload', function (done) { - doRequest(agent, 'post', '/api/v1/auth/google', null, null, {}, 400, function(err, res) { - expect(res.statusCode).to.eqls(400); - done(); - }); - }); - - }); - - describe('Oauth Facebook', function() { - - it('POST /api/v1/auth/facebook 400 - empty payload', function (done) { - doRequest(agent, 'post', '/api/v1/auth/facebook', null, null, {}, 400, function(err, res) { - expect(res.statusCode).to.eqls(400); - done(); - }); - }); - - }); - }); \ No newline at end of file diff --git a/test/e2e/04-media-routes.e2e.test.js b/test/e2e/04-media-routes.e2e.test.js index f2bf547..1895325 100644 --- a/test/e2e/04-media-routes.e2e.test.js +++ b/test/e2e/04-media-routes.e2e.test.js @@ -182,7 +182,6 @@ describe('Media routes', function () { it('GET /api/v1/medias 200 - pagination get n results by query param', function (done) { doQueryRequest(agent, '/api/v1/medias/', null, token, { perPage: 50 }, 200, function(err, res) { - // console.log('res', res) expect(res.statusCode).to.eqls(200); expect(res.body).length.lte(50); done(); @@ -225,6 +224,18 @@ describe('Media routes', function () { }); }); + it('GET /api/v1/medias 200 - results matches size query param', function (done) { + doQueryRequest(agent, '/api/v1/medias/', null, token, { size: 30000 }, 200, function(err, res) { + expect(res.statusCode).to.eqls(200); + expect(res.body).satisfy(function(value) { + return value.map( (entry) => { + expect(entry.size).to.be.gte(3000); + }) + }); + done(); + }); + }); + it('GET /api/v1/medias 200 - results matches multiple query params', function (done) { doQueryRequest(agent, '/api/v1/medias/', null, token, { filename: 'Facture', mimetype: 'application/pdf' }, 200, function(err, res) { expect(res.statusCode).to.eqls(200); diff --git a/test/units/00-application.unit.test.js b/test/units/00-application.unit.test.js index 020c77c..8a2df9c 100644 --- a/test/units/00-application.unit.test.js +++ b/test/units/00-application.unit.test.js @@ -14,5 +14,6 @@ describe('Units tests', () => { require('./02-config.unit.test'); require('./02-utils.unit.test'); require('./03-services.unit.test'); + require('./04-middlewares.unit.test'); }); \ No newline at end of file diff --git a/test/units/02-config.unit.test.js b/test/units/02-config.unit.test.js index c083b16..1f43d8d 100644 --- a/test/units/02-config.unit.test.js +++ b/test/units/02-config.unit.test.js @@ -1,5 +1,145 @@ const expect = require('chai').expect; +const sinon = require('sinon'); +const fs = require('fs'); + +const { clone } = require('lodash'); + +const { Environment } = require(process.cwd() + '/dist/api/config/environment.config'); + +const { TYPEORM } = require(process.cwd() + '/dist/api/config/environment.config'); +const { TypeormConfiguration } = require(process.cwd() + '/dist/api/config/typeorm.config'); describe('Config', function () { + describe('Environment', () => { + + it('Current runtime environment should be test', () => { + const instance = new Environment(); + instance.loads(process.versions.node); + expect(instance.environment).to.be.eqls('test'); + }); + + it('Current base directory should be dist', () => { + const instance = new Environment(); + instance.loads(process.versions.node); + expect(instance.base).to.be.eqls('dist'); + }); + + it('Environment should expose a validation rule for each env variable', () => { + const instance = new Environment(); + expect(instance.keys.every(key => instance.rules[key])).to.be.true; + }); + + it('Environment.load() should exit if node version is too low', () => { + const instance = new Environment(); + const stub = sinon.stub(instance, 'exit'); + stub.callsFake( (message) => { + expect(message).to.be.eqls('The node version of the server is too low. Please consider at least v14.16.0.') + }) + instance.loads('13.0.0'); + expect(stub.called).to.be.true; + stub.restore(); + }); + + it('Environment.load() should exit if .env file is not found', () => { + const instance = new Environment(); + const stub_exit = sinon.stub(instance, 'exit'); + const stub_fs = sinon.stub(fs, 'existsSync'); + stub_exit.callsFake( (message) => { + expect(message).includes('Environment file not found at'); + }); + stub_fs.callsFake(() => false); + instance.loads(process.versions.node); + expect(stub_exit.called).to.be.true; + stub_exit.restore(); + stub_fs.restore(); + }); + + it('Environment.extracts() should retrieve variables from process.env and assign it on .variables', () => { + const instance = new Environment(); + instance.loads(process.versions.node); + expect(instance.variables).to.be.undefined; + instance.extracts(process.env); + expect(instance.variables).to.be.an('object'); + }); + + it('Environment.validates() should validates variables issued from process.env', () => { + const instance = new Environment(); + instance.loads(process.versions.node).extracts(process.env).validates(); + expect(instance.variables).to.be.an('object'); + }); + + it('Environment.validates() should set default value if not provided, without error', () => { + const instance = new Environment(); + const env = clone(process.env); + env.TYPEORM_NAME = undefined; + instance.loads(process.versions.node).extracts(env).validates(); + expect(instance.variables.TYPEORM_NAME).to.be.eqls('default'); + expect(instance.errors.length).to.be.eqls(0); + }); + + it('Environment.validates() should populates error', () => { + const instance = new Environment(); + const env = clone(process.env); + env.FACEBOOK_CONSUMER_ID = 'sdf5/'; + instance.loads(process.versions.node).extracts(env).validates(); + expect(instance.errors.length).to.be.eqls(1); + }); + + describe('Environment.validates() should validate fields', () => { + + [ + 'AUTHORIZED', 'JWT_SECRET', 'TYPEORM_DB', 'TYPEORM_HOST', 'TYPEORM_PORT', 'TYPEORM_TYPE', 'TYPEORM_USER' + ].forEach( key => { + + it(`${key} is required`, (done) => { + const instance = new Environment(); + const env = clone(process.env); + env[key] = undefined; + instance.loads(process.versions.node).extracts(env).validates(); + expect(instance.errors.length).to.be.eqls(1); + expect(instance.errors.some(e => e.includes(key))).to.be.true; + done(); + }); + + }); + + [ + 'AUTHORIZED', 'CONTENT_TYPE', 'FACEBOOK_CONSUMER_ID', 'FACEBOOK_CONSUMER_SECRET', 'GITHUB_CONSUMER_ID', 'GITHUB_CONSUMER_SECRET', + 'GOOGLE_CONSUMER_ID', 'GOOGLE_CONSUMER_SECRET', 'JWT_EXPIRATION_MINUTES', 'JWT_SECRET', 'LINKEDIN_CONSUMER_ID', 'LINKEDIN_CONSUMER_SECRET', 'PORT', + 'REFRESH_TOKEN_DURATION', 'REFRESH_TOKEN_UNIT', 'SSL_CERT', 'SSL_KEY', 'TYPEORM_DB', 'TYPEORM_CACHE', 'TYPEORM_HOST', 'TYPEORM_CACHE_DURATION', + 'UPLOAD_MAX_FILE_SIZE', 'UPLOAD_MAX_FILES', 'UPLOAD_WILDCARDS' + ].forEach( key => { + + it(`${key} should be well formed`, (done) => { + const instance = new Environment(); + const env = clone(process.env); + env[key] = 'yoda-'; + instance.loads(process.versions.node).extracts(env); + instance.validates(); + expect(instance.errors.length).to.be.eqls(1); + expect(instance.errors.some(e => e.includes(key))).to.be.true; + done(); + }); + + }) + + }) + + }); + + describe('Typeorm', () => { + + it('TypeormConfiguration.connect() should failed', async () => { + const options = clone(TYPEORM); + options.TYPE = 'yoda'; + options.NAME = 'yoda'; + await TypeormConfiguration.connect(options).catch(e => { + expect(e).to.be.instanceOf(Error); + expect(e.name).to.be.eqls('MissingDriverError'); + }); + }) + + }); + }); \ No newline at end of file diff --git a/test/units/02-utils.unit.test.js b/test/units/02-utils.unit.test.js index f1f8cae..b4f3549 100644 --- a/test/units/02-utils.unit.test.js +++ b/test/units/02-utils.unit.test.js @@ -5,8 +5,8 @@ const { MEDIA } = require(process.cwd() + '/dist/api/types/enums/media.enum'); const { getAge } = require(process.cwd() + '/dist/api/utils/date.util'); const { getErrorStatusCode } = require(process.cwd() + '/dist/api/utils/error.util'); const { list } = require(process.cwd() + '/dist/api/utils/enum.util'); +const { fieldname, hash, crypt, shuffle, base64Decode, base64Encode } = require(process.cwd() + '/dist/api/utils/string.util'); -var str = {}; str.util = require(process.cwd() + '/dist/api/utils/string.util'); var fs = require('fs'); @@ -37,26 +37,26 @@ describe('Utils', () => { describe('Error', () => { - it("getErrorStatusCode() returns status when status property match", function(done) { + it('getErrorStatusCode() returns status when status property match', function(done) { const result = getErrorStatusCode({ status: 400 }); expect(result).to.eqls(400); done(); }); - it("getErrorStatusCode() returns statusCode when statusCode property match", function(done) { + it('getErrorStatusCode() returns statusCode when statusCode property match', function(done) { const result = getErrorStatusCode({ statusCode: 400 }); expect(result).to.eqls(400); done(); }); - it("getErrorStatusCode() returns output.statusCode when output.statusCode property match", function(done) { + it('getErrorStatusCode() returns output.statusCode when output.statusCode property match', function(done) { const result = getErrorStatusCode({ output: { statusCode: 400 } }); expect(result).to.eqls(400); done(); }); - it("getErrorStatusCode() returns a 500 status if no match", function(done) { - const err = { name: 'QueryFailedError', errno: 1052, sqlMessage: "Duplicate entry 'lambda' for key 'IDX_78a916df40e02a9deb1c4b75ed'" }; + it('getErrorStatusCode() returns a 500 status if no match', function(done) { + const err = { name: 'QueryFailedError', errno: 1052, sqlMessage: 'Duplicate entry \'lambda\' for key \'IDX_78a916df40e02a9deb1c4b75ed\'' }; const result = getErrorStatusCode(err); expect(result).to.eqls(500); done(); @@ -64,43 +64,61 @@ describe('Utils', () => { }); - describe("String", () => { + describe('String', () => { - it("shuffle() returns a shuffled value", function() { + it('shuffle() returns a shuffled value', function() { const array = [0,1,2,3,4,5]; - const phrase = "I'm a test string"; - expect(str.util.shuffle(array)).to.not.eqls(array); - expect(str.util.shuffle(phrase.split(''))).to.be.a('string'); - expect(str.util.shuffle(phrase.split(''))).to.not.eqls(phrase); + const phrase = 'Test string'; + expect(shuffle(array)).to.not.eqls(array); + expect(shuffle(phrase.split(''))).to.be.a('string'); + expect(shuffle(phrase.split(''))).to.not.eqls(phrase); }); - it("hash() returns a shuffled value to n", function() { - const phrase = "I'm a test string"; - const hash = str.util.hash(phrase,8); - expect(hash).to.be.a('string'); - expect(hash).to.not.eqls(phrase); - expect(hash.length).to.eqls(8); + it('hash() returns a shuffled value to n', function() { + const phrase = 'Test string'; + const h = hash(phrase,8); + expect(h).to.be.a('string'); + expect(h).to.not.eqls(phrase); + expect(h.length).to.eqls(8); }); - it("base64Encode() returns a base64 encoded string", function() { + it('base64Encode() returns a base64 encoded string', function() { const origine = process.cwd() + '/test/utils/fixtures/files/javascript.jpg'; - const base64Encoded = str.util.base64Encode(origine); + const base64Encoded = base64Encode(origine); expect(base64Encoded).to.be.a('string'); expect(base64Encoded).match(/^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/) - }); - it("base64Decode() write ascii file", function(done) { + it('base64Decode() write ascii file', function(done) { const origine = process.cwd() + '/test/utils/fixtures/files/javascript.jpg'; const copy = process.cwd() + '/test/utils/fixtures/files/javascript-rewrited.jpg'; const stream = fs.readFileSync(origine); - str.util.base64Decode(stream, copy); + base64Decode(stream, copy); expect( fs.statSync(copy).isFile() ).to.eqls(true); expect( fs.statSync(copy).size ).to.eqls( fs.statSync(origine).size ); fs.unlinkSync(copy); done(); }); - + + it('fieldname() should returns audio', function() { + expect(fieldname('audio/mp3')).to.be.eqls('audio'); + }); + + it('fieldname() should returns archive', function() { + expect(fieldname('application/zip')).to.be.eqls('archive'); + }); + + it('fieldname() should returns document', function() { + expect(fieldname('application/pdf')).to.be.eqls('document'); + }); + + it('fieldname() should returns image', function() { + expect(fieldname('image/jpg')).to.be.eqls('image'); + }); + + it('fieldname() should returns video', function() { + expect(fieldname('video/mp4')).to.be.eqls('video'); + }); }); }); diff --git a/test/units/03-services.unit.test.js b/test/units/03-services.unit.test.js index 49530ac..1fcd119 100644 --- a/test/units/03-services.unit.test.js +++ b/test/units/03-services.unit.test.js @@ -1,83 +1,71 @@ const expect = require('chai').expect; const sinon = require('sinon'); -const Axios = require('axios'); + +const fs = require('fs'); + +const fixtures = require(process.cwd() + '/test/utils/fixtures'); const { User } = require(process.cwd() + '/dist/api/models/user.model'); + const { isSanitizable, sanitize } = require(process.cwd() + '/dist/api/services/sanitizer.service'); -const { AuthProvider } = require(process.cwd() + '/dist/api/services/auth-provider.service'); -const { jwt } = require(process.cwd() + '/dist/api/services/auth.service'); +const { AuthService } = require(process.cwd() + '/dist/api/services/auth.service'); const { Cache } = require(process.cwd() + '/dist/api/services/cache.service'); +const { remove, rescale } = require(process.cwd() + '/dist/api/services/media.service'); describe('Services', () => { - describe('Auth provider', () => { - - it('AuthProvider.facebook() should return credentials from Facebook API', async () => { - - const stub = sinon.stub(Axios, 'get') - - stub.callsFake( async (url) => { - return { data: { id: 1, name: 'Yoda', email: 'yoda@theforce.com', picture: { data: { url: 'https://media.giphy.com/media/3o7abrH8o4HMgEAV9e/giphy.gif' } } } }; - }) - - const result = await AuthProvider.facebook('my-token'); - - expect(result.service).to.be.eqls('facebook'); - expect(result.id).to.be.eqls(1); - expect(result.name).to.be.eqls('Yoda'); - expect(result.email).to.be.eqls('yoda@theforce.com'); - - stub.restore(); + describe('AuthService', () => { + it('AuthService.generateTokenResponse() should return error', async () => { + const result = await AuthService.generateTokenResponse({}, '', null); + expect(result).is.instanceOf(Error); }); - it('AuthProvider.google() should return credentials from Google API', async () => { - - const stub = sinon.stub(Axios, 'get') - - stub.callsFake( async (url) => { - return { data: { sub: 1, name: 'Yoda', email: 'yoda@theforce.com', picture: 'https://media.giphy.com/media/3o7abrH8o4HMgEAV9e/giphy.gif' } }; - }) - - const result = await AuthProvider.google('my-token'); - - expect(result.service).to.be.eqls('google'); - expect(result.id).to.be.eqls(1); - expect(result.name).to.be.eqls('Yoda'); - expect(result.email).to.be.eqls('yoda@theforce.com'); - - stub.restore(); + it('AuthService.generateTokenResponse() should return well formed token', async () => { + const result = await AuthService.generateTokenResponse(new User(), '', null); + expect(result).to.haveOwnProperty('tokenType'); + expect(result).to.haveOwnProperty('accessToken'); + expect(result).to.haveOwnProperty('refreshToken'); + expect(result).to.haveOwnProperty('expiresIn'); + }); - }) - - }); + it('AuthService.oAuth() next with error if data cannot be retrieved from provider', async () => { + await AuthService.oAuth('', '', null, (error, user) => { + expect(error).is.instanceOf(Error); + }); + }); - describe('Auth', function() { + it('AuthService.oAuth() next with User instance', async () => { + await AuthService.oAuth('', '', fixtures.token.oauthFacebook, (error, user) => { + if (error) throw error; + expect(user).to.haveOwnProperty('id'); + expect(user).to.haveOwnProperty('username'); + expect(user).to.haveOwnProperty('email'); + expect(user).to.haveOwnProperty('role'); + expect(user).to.haveOwnProperty('password'); + expect(user).to.haveOwnProperty('apikey'); + }); + }); - it('jwt() next with false if user not found', function(done) { - jwt({ sub: 0 }, function(error, result) { - expect(error).is.null; + it('AuthService.jwt() next with error', async () => { + await AuthService.jwt({ alter: 0 }, (error, result) => { + expect(error).is.instanceOf(Error); expect(result).is.false; - done(); }); }); - it('jwt() next with error if error occurrs', function(done) { - jwt({ alter: 0 }, function(error, result) { - expect(error).is.not.null; + it('AuthService.jwt() next with false if user not found', async () => { + await AuthService.jwt({ sub: 0 }, (error, result) => { expect(result).is.false; - done(); }); }); - it('jwt() next with error if error occurrs', function(done) { - jwt({ alter: 0 }, function(error, result) { - expect(error).is.not.null; - expect(result).is.false; - done(); + it('AuthService.jwt() next with User instance', async () => { + await AuthService.jwt({ sub: 1 }, (error, result) => { + expect(result).is.an('object'); }); }); - + }); describe('Cache', () => { @@ -113,6 +101,27 @@ describe('Services', () => { describe('Media', () => { + describe('remove()', () => { + + it('should remove all scaled images', () => { + const image = fixtures.media.image({id:1}); + fs.copyFileSync(`${process.cwd()}/test/utils/fixtures/files/${image.filename}`, `${process.cwd()}/dist/public/images/master-copy/${image.filename}`); + ['xs', 'sm', 'md', 'lg', 'xl'].forEach(size => { + fs.copyFileSync(`${process.cwd()}/test/utils/fixtures/files/${image.filename}`, `${process.cwd()}/dist/public/images/rescale/${size}/${image.filename}`); + }); + remove(fixtures.media.image({id:1})); + setTimeout(() => { + expect(fs.existsSync(`${process.cwd()}/dist/public/images/master-copy/${image.filename}`)).to.be.false; + ['xs', 'sm', 'md', 'lg', 'xl'].forEach(size => { + expect(fs.existsSync(`${process.cwd()}/dist/public/images/rescale/${size}/${image.filename}`)).to.be.false; + }); + done(); + }, 500) + + }); + + }); + }); describe('Sanitize', () => { diff --git a/test/units/04-middlewares.unit.test.js b/test/units/04-middlewares.unit.test.js new file mode 100644 index 0000000..1a416ea --- /dev/null +++ b/test/units/04-middlewares.unit.test.js @@ -0,0 +1,76 @@ +const expect = require('chai').expect; +const sinon = require('sinon'); + +const { Cache } = require(process.cwd() + '/dist/api/services/cache.service'); +const { Kache } = require(process.cwd() + '/dist/api/middlewares/cache.middleware'); + +describe('Middlewares', () => { + + describe('Cache', () => { + + it('should next when not activated', async () => { + Cache.options.IS_ACTIVE = false; + const stub = sinon.stub(Cache.resolve, 'get'); + stub.callsFake((key) => {}); + Kache({method: 'GET'}, {}, () => {}); + expect(stub.called).to.be.false; + stub.restore() + }); + + it('should next when method is not GET', async () => { + Cache.options.IS_ACTIVE = true; + const stub = sinon.stub(Cache.resolve, 'get'); + stub.callsFake((key) => {}); + Kache({method: 'POST'}, {}, () => {}); + expect(stub.called).to.be.false; + stub.restore() + }); + + it('should try to retrieve cached data', async () => { + Cache.options.IS_ACTIVE = true; + const stub = sinon.stub(Cache.resolve, 'get'); + stub.callsFake((key) => {}); + Kache({method: 'GET'}, {}, () => {}); + expect(stub.called).to.be.true; + stub.restore() + }); + + it('should output the cached data', async () => { + const res = { + status: (code) => {}, + json: (data) => {} + }; + Cache.options.IS_ACTIVE = true; + const stub = sinon.stub(Cache.resolve, 'get'); + const stub_res = sinon.stub(res, 'json'); + stub.callsFake((key) => { + return { body: 'Hello World' }; + }); + stub_res.callsFake((data) => {}); + Kache({method: 'GET'}, res, () => {}); + expect(stub.called).to.be.true; + expect(stub_res.called).to.be.true; + stub.restore(); + stub_res.restore(); + }); + + it('should next if not available cached data', async () => { + const res = { + status: (code) => {}, + json: (data) => {} + }; + Cache.options.IS_ACTIVE = true; + const stub = sinon.stub(Cache.resolve, 'get'); + const stub_res = sinon.stub(res, 'json'); + stub.callsFake((key) => null); + stub_res.callsFake((data) => {}); + Kache({method: 'GET'}, res, () => {}); + expect(stub.called).to.be.true; + expect(stub_res.called).to.be.false; + stub.restore(); + stub_res.restore(); + }); + + }); + +}); \ No newline at end of file diff --git a/test/utils/fixtures/entities/token.fixture.js b/test/utils/fixtures/entities/token.fixture.js index e69de29..41caea2 100644 --- a/test/utils/fixtures/entities/token.fixture.js +++ b/test/utils/fixtures/entities/token.fixture.js @@ -0,0 +1,60 @@ +const oauthFacebook = { + id: '10226107961312549', + username: undefined, + displayName: undefined, + name: { familyName: 'Doe', givenName: 'John', middleName: undefined }, + gender: undefined, + profileUrl: undefined, + photos: [ + { + value: 'https://platform-lookaside.fbsbx.com/platform/profilepic/?asid=10226107961312549&height=50&width=50&ext=1618134835&hash=AeQG7JwQDHxpvYzf5Rk' + } + ], + provider: 'facebook', + _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}}}', + _json: { + id: '10226107961312549', + last_name: 'Doe', + first_name: 'John', + picture: { data: [Object] } + } +} + +exports.oauthFacebook = oauthFacebook; + + +/** OAuth Google + * { + id: '100381987564055936818', + displayName: 'Steve Lebleu', + name: { familyName: 'Lebleu', givenName: 'Steve' }, + emails: [ { value: 'steve.lebleu1979@gmail.com', verified: true } ], + photos: [ + { + value: 'https://lh6.googleusercontent.com/-QmuEXYjq2Ag/AAAAAAAAAAI/AAAAAAAAFT4/AMZuucmaVXn8Gopeny7NpT9A5uM5lJH6yQ/s96-c/photo.jpg' + } + ], + provider: 'google', + _raw: '{\n' + + ' "sub": "100381987564055936818",\n' + + ' "name": "Steve Lebleu",\n' + + ' "given_name": "Steve",\n' + + ' "family_name": "Lebleu",\n' + + ' "picture": "https://lh6.googleusercontent.com/-QmuEXYjq2Ag/AAAAAAAAAAI/AAAAAAAAFT4/AMZuucmaVXn8Gopeny7NpT9A5uM5lJH6yQ/s96-c/photo.jpg",\n' + + ' "email": "steve.lebleu1979@gmail.com",\n' + + ' "email_verified": true,\n' + + ' "locale": "fr"\n' + + '}', + _json: { + sub: '100381987564055936818', + name: 'Steve Lebleu', + given_name: 'Steve', + family_name: 'Lebleu', + picture: 'https://lh6.googleusercontent.com/-QmuEXYjq2Ag/AAAAAAAAAAI/AAAAAAAAFT4/AMZuucmaVXn8Gopeny7NpT9A5uM5lJH6yQ/s96-c/photo.jpg', + email: 'steve.lebleu1979@gmail.com', + email_verified: true, + locale: 'fr' + } +} + + */ \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 9524b46..8b6b023 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "@bases/*": ["api/types/classes/*"], "@config/*": ["api/config/*"], "@controllers/*": ["api/controllers/*"], + "@customtypes/*": ["api/types/types/*"], "@decorators/*": ["api/decorators/*"], "@enums/*": ["api/types/enums/*"], "@errors": ["api/types/errors/"],