├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc ├── .mocharc.json ├── .npmrc ├── .nycrc ├── .travis.yml ├── README.md ├── acl ├── base-validation.js ├── index.js ├── private-resource.js ├── products.js ├── profiles.js ├── public-resource.js ├── tickets.js └── users.js ├── app.js ├── config ├── custom-environment-variables.json ├── default.json ├── development.json └── production.json ├── handlers ├── auth.js ├── base-handler.js ├── index.js ├── products.js ├── profiles.js ├── tickets.js └── users.js ├── lib ├── acl.js ├── analytics.js ├── checksum.js ├── cluster.js ├── email.js ├── init.js ├── jwt.js ├── logger-http.js ├── logger.js ├── mongodb.js ├── oauth.js ├── options.js ├── redis.js └── uuid.js ├── models ├── base-model.js ├── product.js ├── profile.js ├── schemas │ ├── index.js │ ├── products.js │ ├── tickets.js │ └── users.js ├── ticket.js └── user.js ├── package.json ├── public ├── favicon.png ├── images │ ├── anonymous.jpg │ ├── email │ │ ├── footer.jpg │ │ └── header.jpg │ └── logo_big.png └── stylesheets │ └── style.css ├── routes ├── api.js ├── api.yaml ├── common.js ├── error.js └── web.js ├── server.js ├── test ├── acl │ ├── base-validation.spec.js │ ├── private-resources.spec.js │ ├── products.spec.js │ ├── profiles.spec.js │ ├── public-resource.spec.js │ ├── tickets.spec.js │ └── users.spec.js ├── handlers │ └── auth.spec.js ├── lib │ ├── mongodb.spec.js │ └── redis.spec.js ├── mocha.opts ├── models │ └── schemas.spec.js └── routes │ ├── error.spec.js │ └── web.spec.js ├── tools └── db.js ├── views ├── emails │ ├── base.pug │ └── general.pug ├── error.pug ├── index.pug └── layout.pug └── worker.js /.eslintignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | coverage 3 | mochawesome-report 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true 5 | }, 6 | "extends": "airbnb-base", 7 | "rules": { 8 | "max-len": ["error", { "code": 120 }] 9 | }, 10 | "parserOptions": { 11 | "ecmaVersion": "latest" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | .nyc_output 16 | mochawesome-report 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # https://docs.npmjs.com/cli/shrinkwrap#caveats 29 | node_modules 30 | package-lock.json 31 | 32 | # Debug log from npm 33 | npm-debug.log 34 | 35 | # WebStorm configuration (remove after first git push) 36 | .idea 37 | 38 | # Local configuration files 39 | config/local* 40 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": [ 3 | "node_modules/.bin/eslint" 4 | ], 5 | "routes/*.{json,yaml}": [ 6 | "node_modules/.bin/swagger-cli validate" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "recursive": true, 3 | "reporter": "mochawesome" 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "check-coverage": true, 3 | "per-file": true, 4 | "lines": 0, 5 | "statements": 0, 6 | "functions": 0, 7 | "branches": 0, 8 | "include": [ 9 | "**/*.js" 10 | ], 11 | "exclude": [ 12 | "test/**", 13 | "coverage/**", 14 | "mochawesome-report/**" 15 | ], 16 | "reporter": [ 17 | "lcov", 18 | "text-summary", 19 | "html" 20 | ], 21 | "cache": true, 22 | "all": true 23 | } 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | "node" 4 | script: 5 | - npm run lint 6 | - npm run swagger 7 | - npm test 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # basejs 2 | 3 | My own personal taste server implementation with 4 | a list of features I use in most of the cases. 5 | Instead of developing a server from scratch each project, I start 6 | with this one and develop on top of it. 7 | 8 | [![Build Status](https://travis-ci.org/gonenduk/basejs.svg?branch=master)](https://travis-ci.org/gonenduk/basejs) 9 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/gonenduk/basejs/#license) 10 | 11 | A running server with the latest code can be found here: 12 | 13 | [https://basejs.herokuapp.com](https://basejs.herokuapp.com) 14 | 15 | ## Contribution 16 | 17 | Any help would be appreciated! 18 | 19 | Feel free to fork, copy, suggest, report issues and create pull requests. 20 | 21 | ## Quick Start 22 | 23 | basejs is not a package nor a generator. It's a complete project. 24 | Once you clone it, you can edit the configuration files, 25 | delete the features you don't require and add your own logic on top of it. 26 | ```sh 27 | $ git clone https://www.github.com/gonenduk/basejs 28 | $ cd basejs 29 | $ npm install 30 | $ npm run db all 31 | $ npm start 32 | ``` 33 | 34 | ## Project Structure 35 | 36 | ``` 37 | root 38 | |--config 39 | |--acl 40 | |--handlers 41 | |--lib 42 | |--models 43 | | |--plugins 44 | | |--schemas 45 | |--public 46 | |--routes 47 | |--tools 48 | |--views 49 | | |--emails 50 | ``` 51 | **root**: Main server and app files. 52 | 53 | **config**: Configuration files. 54 | 55 | **acl**: Definition of access control levels and handlers of routes for access control. 56 | 57 | **handlers**: Handlers of routes both for API calls and web pages. 58 | Routes are built by OpenAPI. This is the end of each route where the input is validated 59 | and moved into the models and the where the output of the models is transferredd back to 60 | json and sent via http to the client. 61 | 62 | **lib**: Wrappers around 3rd party packages to initialize and isolate them. 63 | Allows required behavior and replacing of packages without changing project code. 64 | 65 | **models**: Models of resources. Does not have to have a DB collection associated with it. 66 | This is where the logic of the model is done and where the data layer is controlled. 67 | Can be access by the handlers of the routes or internally. 68 | 69 | **models schemas**: DB Schema and index definitions of each model. 70 | 71 | **public**: Public static files 72 | 73 | **routes**: Automatic routes builder and input verification according to OpenAPI standard. 74 | The OpenAPI definition file is located here. 75 | 76 | **tools**: Project tools. For example, initializing the DB schemas and indexes 77 | and creating system users. 78 | 79 | **views**: Templates of web pages. 80 | 81 | **views emails**: Templates of emails. 82 | 83 | ## Features 84 | 85 | ### Express 86 | 87 | Project was build on top of [express](https://www.npmjs.com/package/express) 88 | which is the most popular framework for Node.js. 89 | 90 | ### Configuration 91 | 92 | Configuration files are located under the config directory. 93 | Key features: 94 | * configuration per node environment. 95 | * environment variables override for secrets support. 96 | * local configuration for testing while developing (not uploaded to git). 97 | 98 | 99 | More info about configuration in [config](https://www.npmjs.com/package/config) 100 | 101 | ### Logger 102 | 103 | Using [winston](https://www.npmjs.com/package/winston) logger. 104 | 105 | Can be configured under 'log': 106 | * level: log level 107 | * console: true to log to the console 108 | * file: file name to log to or empty to disable file logs 109 | 110 | Both console and file can be enabled together. 111 | 112 | HTTP requests are logged using [morgan](https://www.npmjs.com/package/morgan) 113 | and can be configured under 'morgan'. 114 | 115 | ### Protocols 116 | 117 | In most managed servers environments, like [heroku](https://heroku.com) or [aws](https://aws.amazon.com), 118 | only http is used and the port is set in an environment variable. Should be set in 119 | custom-environment-variables.json file to overwrite the port setting under the server configuration. 120 | 121 | To breakdown and log outgoing network operations, enable the relevant protocol under log: 122 | * http: true to enable breakdown logs for outgoing http calls 123 | * https: true to enable breakdown logs for outgoing https calls 124 | 125 | ### Clustering 126 | 127 | Can be configured under 'server': 128 | * workers: number of workers to fork. 0 (default) to not use clustering, 129 | auto to fork by cpu count. 130 | 131 | When using clustering, logger will automatically add worker id to each log message. 132 | 133 | Some managed servers, like [heroku](https://heroku.com), set an enviroment variable with 134 | the optimum amount of workers to use, calculated by cpu count and available memory. 135 | Can be configured in custom-environment-variables.json config file to overwrite the configuration 136 | to get optimum performance. 137 | 138 | Clustering using [throng](https://www.npmjs.com/package/throng) 139 | 140 | ### OpenAPI driven development 141 | 142 | API routes are built and exposed in runtime according to the provided OpenAPI definition file. 143 | Input is verified and errors are sent back to clients on invalid data. 144 | Handlers are the last part of the routes chain and they are called only after validation and 145 | authentication have passed successfully. Handlers is where the actual business logic of the route 146 | is done. 147 | 148 | Can be configured under 'api': 149 | * docs: true to expose a route with the OpenAPI definition 150 | * ui: true to expose a route with the swagger ui tool 151 | 152 | Building routes using [express-openapi-validator](https://www.npmjs.com/package/express-openapi-validator) 153 | 154 | OpenAPI definition file can be built using [Swagger Tools](https://swagger.io/) 155 | 156 | ### Unique request ID for each api call 157 | 158 | All log messages coming from the same request will share the same ID in the log message. 159 | This is helpful to relate different log messages to one specific request. 160 | 161 | Can be configured under 'api': 162 | * id: true to add the unique id per request 163 | 164 | IDs created using [cls-rtracer](https://www.npmjs.com/package/cls-rtracer) 165 | 166 | ### Database management 167 | 168 | All operations that require DB are done by models. Models usually represent a collection but don't 169 | have to. Models expose operations in their interface thus allowing to replace a DB vendor and 170 | keeping the code of the server unchanged. 171 | 172 | Each DB vendor should have a wrapping lib which exposes a unified interface to connect and exchange 173 | data with the DB driver. Using the lib is limited to models and tools. This allows the option to 174 | replace DB vendors easily. 175 | 176 | Support for [MongoDB](https://www.npmjs.com/package/mongodb) 177 | and [Redis](https://www.npmjs.com/package/redis) 178 | 179 | ### Authentication and authorization 180 | 181 | Using JWT to authenticate and authorize users. All calls to the server must contain the token 182 | or will be considered as a guest. Secrets and TTL can be configured under server.JWT: 183 | * secret: secret to use to sign and verify the token 184 | * accessTTL: life time of access token, default 1 hour 185 | * refreshTTL: life time of refresh token, default to 1 week 186 | 187 | Authentication can be done by: 188 | * user credentials 189 | * refresh token 190 | * oauth sign in: facebook, google and apple 191 | 192 | Once authenticated the user will get its user id and user role for access control. 193 | To log out a user from a device, clients need just to delete the token they got and not use it anymore. 194 | A change of password, or calling the logout API will log out the user from all devices: Existing refresh 195 | tokens will be expired. Existing access tokens will continue to work even after logout. 196 | 197 | ### User and profile management 198 | 199 | User is the identity of a real person. It usually contains credentials, tokens, contact details, 200 | billing info and such. Profile is the how the user is seen to other users in the system. 201 | The profile contains a virtual sub set of the user information. 202 | 203 | Users have roles. Each role defines a set of permissions and restrictions. By default the 204 | following roles are defined and can be easily changed: 205 | * guest: unidentified user, can register as a new user and view any resource. 206 | * user: can update its own account and resources and view any resource. 207 | * moderator: same as user + can view any account. 208 | * admin: same as moderator + can update any account and resource. 209 | 210 | Only admin can change user role. 211 | 212 | Access control is done using [accesscontrol](https://www.npmjs.com/package/accesscontrol) 213 | 214 | ### Resource management 215 | 216 | Most collections and models, except system data and users will be resources owned by users. 217 | Resource logic is easily made from several plugins and may have its own extra logic. 218 | Plugins can be done in model level, like the timestamp plugin on, or in API level like the ownership 219 | plugin that limits each user to manage its own resources. 220 | All resources share the same access control rules by default, but each can be overwritten. 221 | Managing ownership of resources is also available by default and can be removed for resources that should not be movable. 222 | 223 | ### Google Analytics 224 | 225 | Page views and API calls can be reported to Google analytics. 226 | For web page views, both server side and client side reports are supported. 227 | 228 | 229 | Configuration under 'analytics': 230 | * ua: universal analytics id 231 | * api: true to enable server side API calls reports. 232 | * web: true to enable server side web page views reports. 233 | * client: report from client side. 234 | 235 | 236 | Server side reports using [universal-analytics](https://www.npmjs.com/package/universal-analytics) 237 | 238 | ### ESLint 239 | 240 | ESLint is used with the super strict airbnb coding style. The 241 | only exception is line length which is set to 120 characters 242 | instead of 80. 243 | 244 | ### Using travis-ci 245 | 246 | The GitHub repository is hooked with travis-ci. Every pull 247 | request to master or commit on master will create a new build 248 | and run both lint and tests. A successful build will be 249 | deployed to heroku. 250 | 251 | ### Email delivery and templates 252 | 253 | ### Extras 254 | 255 | Checksum calculation and validation and generating uuid. 256 | 257 | ## Planned Features 258 | 259 | ### Unit Testing & Coverage 260 | 261 | ## License 262 | 263 | [MIT](LICENSE) 264 | 265 | Buy Me A Coffee 266 | -------------------------------------------------------------------------------- /acl/base-validation.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle,no-param-reassign */ 2 | const Boom = require('@hapi/boom'); 3 | const ac = require('../lib/acl'); 4 | 5 | function safeParse(str) { 6 | if (!str) return {}; 7 | let obj; 8 | try { 9 | obj = JSON.parse(str); 10 | } catch (err) { 11 | throw Boom.badRequest(`failed to parse filter: ${err.message}`); 12 | } 13 | if (typeof obj !== 'object') throw Boom.badRequest('filter is not an object'); 14 | return obj; 15 | } 16 | 17 | // Validations according to user id field in collection 18 | module.exports = { 19 | _id(req, action, resource) { 20 | const { user, params } = req; 21 | // Validate user role can access any item 22 | let actionType = `${action}Any`; 23 | let permission = ac.can(user.role)[actionType](resource); 24 | if (!permission.granted) { 25 | // Validate user can access own item 26 | actionType = `${action}Own`; 27 | permission = ac.can(user.role)[actionType](resource); 28 | if (!permission.granted) throw Boom.forbidden(); 29 | // Validate user is accessing own item 30 | if (user.id !== params.id) throw Boom.forbidden(); 31 | } 32 | }, 33 | 34 | ownerId(req, action, resource) { 35 | const { user, query } = req; 36 | // Validate user role can access any item 37 | let actionType = `${action}Any`; 38 | let permission = ac.can(user.role)[actionType](resource); 39 | if (!permission.granted) { 40 | // Validate user can access own item 41 | actionType = `${action}Own`; 42 | permission = ac.can(user.role)[actionType](resource); 43 | if (!permission.granted) throw Boom.forbidden(); 44 | // Validate user not trying to access not own items 45 | const filter = safeParse(query.filter); 46 | if (filter.ownerId && filter.ownerId !== user.id) throw Boom.forbidden(); 47 | // Filter own items 48 | filter.ownerId = user.id; 49 | query.filter = JSON.stringify(filter); 50 | } 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /acl/index.js: -------------------------------------------------------------------------------- 1 | /* eslint newline-per-chained-call: "off" */ 2 | const requireDirectory = require('require-directory'); 3 | const ac = require('../lib/acl'); 4 | 5 | // Access control definitions 6 | ac.grant('guest') 7 | .readAny('resource-public') 8 | .readAny('webpage') 9 | 10 | .grant('user').extend('guest') 11 | .createOwn('resource').updateOwn('resource').deleteOwn('resource') 12 | .readOwn('resource') 13 | 14 | .grant('moderator').extend('user') 15 | .readAny('resource') 16 | 17 | .grant('admin').extend('moderator') 18 | .updateAny('resource').deleteAny('resource') 19 | .createAny('resource-system').updateAny('resource-system').deleteAny('resource-system') 20 | .readAny('resource-system'); 21 | 22 | module.exports = requireDirectory(module, { recurse: false }); 23 | -------------------------------------------------------------------------------- /acl/private-resource.js: -------------------------------------------------------------------------------- 1 | const PublicResource = require('./public-resource'); 2 | 3 | class PrivateResource extends PublicResource { 4 | getMany(req) { this.validate(req, 'read', 'resource'); } 5 | 6 | getOne(req) { this.validate(req, 'read', 'resource'); } 7 | } 8 | 9 | module.exports = PrivateResource; 10 | -------------------------------------------------------------------------------- /acl/products.js: -------------------------------------------------------------------------------- 1 | const PublicResource = require('./public-resource'); 2 | 3 | class Products extends PublicResource { 4 | getProducts(req) { this.getMany(req); } 5 | 6 | createProduct(req) { this.create(req); } 7 | 8 | getProduct(req) { this.getOne(req); } 9 | 10 | updateProduct(req) { this.updateOne(req); } 11 | 12 | deleteProduct(req) { this.deleteOne(req); } 13 | 14 | updateProductOwner(req) { this.updateSystem(req); } 15 | } 16 | 17 | module.exports = new Products(); 18 | -------------------------------------------------------------------------------- /acl/profiles.js: -------------------------------------------------------------------------------- 1 | const PublicResource = require('./public-resource'); 2 | 3 | class Profiles extends PublicResource { 4 | getProfile(req) { this.getOne(req); } 5 | } 6 | 7 | module.exports = new Profiles('_id'); 8 | -------------------------------------------------------------------------------- /acl/public-resource.js: -------------------------------------------------------------------------------- 1 | const baseValidation = require('./base-validation'); 2 | 3 | class PublicResource { 4 | constructor(userIdField = 'ownerId') { 5 | this.validate = baseValidation[userIdField]; 6 | } 7 | 8 | getMany(req) { this.validate(req, 'read', 'resource-public'); } 9 | 10 | create(req) { this.validate(req, 'create', 'resource'); } 11 | 12 | getOne(req) { this.validate(req, 'read', 'resource-public'); } 13 | 14 | updateOne(req) { this.validate(req, 'update', 'resource'); } 15 | 16 | deleteOne(req) { this.validate(req, 'delete', 'resource'); } 17 | 18 | updateSystem(req) { this.validate(req, 'update', 'resource-system'); } 19 | } 20 | 21 | module.exports = PublicResource; 22 | -------------------------------------------------------------------------------- /acl/tickets.js: -------------------------------------------------------------------------------- 1 | const PrivateResource = require('./private-resource'); 2 | 3 | class Tickets extends PrivateResource { 4 | getTickets(req) { this.getMany(req); } 5 | 6 | createTicket(req) { this.create(req); } 7 | 8 | getTicket(req) { this.getOne(req); } 9 | 10 | updateTicket(req) { this.updateOne(req); } 11 | 12 | deleteTicket(req) { this.deleteOne(req); } 13 | 14 | updateTicketOwner(req) { this.updateSystem(req); } 15 | } 16 | 17 | module.exports = new Tickets(); 18 | -------------------------------------------------------------------------------- /acl/users.js: -------------------------------------------------------------------------------- 1 | const PrivateResource = require('./private-resource'); 2 | 3 | class Users extends PrivateResource { 4 | getUsers(req) { this.getMany(req); } 5 | 6 | getUser(req) { this.getOne(req); } 7 | 8 | updateUser(req) { this.updateOne(req); } 9 | 10 | updateUserRole(req) { this.updateSystem(req); } 11 | } 12 | 13 | module.exports = new Users('_id'); 14 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const config = require('config'); 3 | const path = require('path'); 4 | const favicon = require('serve-favicon'); 5 | const morgan = require('morgan'); 6 | const cookieParser = require('cookie-parser'); 7 | const logger = require('./lib/logger'); 8 | 9 | // Create express app and export it before requiring routes 10 | const app = express(); 11 | module.exports = app; 12 | 13 | const web = require('./routes/web'); 14 | const api = require('./routes/api'); 15 | const common = require('./routes/common'); 16 | const error = require('./routes/error'); 17 | 18 | // View engine setup 19 | app.set('views', path.join(__dirname, 'views')); 20 | app.set('view engine', 'pug'); 21 | 22 | // General app setup 23 | app.use(favicon(path.join(__dirname, 'public', 'favicon.png'))); 24 | app.use(morgan(config.get('morgan').format, { stream: logger.stream })); 25 | app.use(express.json()); 26 | app.use(express.text()); 27 | app.use(express.urlencoded({ extended: false })); 28 | app.use(cookieParser()); 29 | app.use(express.static(path.join(__dirname, 'public'))); 30 | app.locals.config = config; 31 | 32 | // Routes setup (order is important) 33 | app.locals.isReady = Promise.all([common, api, web, error]).then((routers) => { 34 | routers.forEach((router) => { 35 | app.use(router); 36 | }); 37 | logger.info('Routes are ready'); 38 | }); 39 | -------------------------------------------------------------------------------- /config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": "PORT", 4 | "JWT": { 5 | "secret": "JWT_SECRET" 6 | }, 7 | "workers": "WEB_CONCURRENCY" 8 | }, 9 | "mongodb": { 10 | "url": "MONGODB_URL" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 3000, 4 | "workers": 0, 5 | "JWT": { 6 | "secret": "secret", 7 | "accessTTL": "1h", 8 | "refreshTTL": "7d" 9 | } 10 | }, 11 | "api": { 12 | "docs": true, 13 | "ui": true, 14 | "id": true 15 | }, 16 | "mongodb": { 17 | "url": "mongodb://127.0.0.1:27017", 18 | "db": "basejs" 19 | }, 20 | "redis": { 21 | "host": "127.0.0.1", 22 | "port": 6379 23 | }, 24 | "log": { 25 | "level": "debug", 26 | "console": true, 27 | "file": "", 28 | "http": false, 29 | "https": false 30 | }, 31 | "morgan": { 32 | "format": "dev" 33 | }, 34 | "email": { 35 | "service": "Gmail", 36 | "template": "general" 37 | }, 38 | "analytics": { 39 | "ua": "UA-84705052-1", 40 | "web": false, 41 | "api": true, 42 | "client": true 43 | }, 44 | "oauth": { 45 | "facebook": "https://graph.facebook.com/me", 46 | "google": "https://www.googleapis.com/oauth2/v3/tokeninfo", 47 | "github": "https://api.github.com/user", 48 | "windows": "https://apis.live.net/v5.0/me" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 80, 4 | "workers": "auto" 5 | }, 6 | "log": { 7 | "level": "info" 8 | }, 9 | "morgan": { 10 | "format": "short" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /handlers/auth.js: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | const Boom = require('@hapi/boom'); 3 | const user = require('../models/user'); 4 | const oauth = require('../lib/oauth'); 5 | const jwt = require('../lib/jwt'); 6 | 7 | function createJWT(match) { 8 | const accessPayload = { id: match._id, role: match.role }; 9 | const refreshPayload = { id: match._id }; 10 | return Promise.all([jwt.signAccessToken(accessPayload), jwt.signRefreshToken(refreshPayload)]) 11 | .then((tokens) => ({ access_token: tokens[0], refresh_token: tokens[1] })) 12 | .catch((err) => { throw Boom.unauthorized(`Failed to sign user tokens: ${err.message}`); }); 13 | } 14 | 15 | module.exports = { 16 | signInWithCredentials: async (req, res) => { 17 | const { username, password } = req.body; 18 | 19 | // Validate email/username and password 20 | const projection = { password: 1, role: 1 }; 21 | const match = await user.getOneByFilter({ $or: [{ email: username }, { username }] }, { projection }); 22 | if (!match) { 23 | throw Boom.unauthorized('Incorrect credentials'); 24 | } 25 | if (!(await user.validatePassword(password, match.password))) { 26 | throw Boom.unauthorized('Incorrect credentials'); 27 | } 28 | 29 | // Create JWT with access and refresh tokens 30 | res.json(await createJWT(match)); 31 | }, 32 | 33 | signInWithRefreshToken: async (req, res) => { 34 | // Verify refresh token 35 | const token = await jwt.verifyToken(req.body.token).catch((err) => { 36 | throw Boom.unauthorized(`Invalid refresh token: ${err.message}`); 37 | }); 38 | 39 | // Find related user 40 | const projection = { role: 1, signedOutAt: 1 }; 41 | const match = await user.getOneById(token.id, {}, { projection }); 42 | if (!match) throw Boom.unauthorized('Invalid user in refresh token'); 43 | 44 | // Validate user did not log off after refresh token was created 45 | if (match.signedOutAt && token.iat * 1000 < match.signedOutAt.getTime()) { 46 | throw Boom.unauthorized('Refresh token expired'); 47 | } 48 | 49 | // Create JWT with access and refresh tokens 50 | res.json(await createJWT(match)); 51 | }, 52 | signInWithOAuthToken: async (req, res) => { 53 | const { provider, token } = req.body; 54 | 55 | // Verify provider is supported 56 | if (!oauth.isProviderSupported(provider)) throw Boom.badRequest(`Unsupported provider '${provider}'`); 57 | 58 | // Verify token with provider and get user profile data 59 | const profile = await oauth.validateWithProvider(provider, token).catch((err) => { 60 | throw Boom.unauthorized(err.error); 61 | }); 62 | 63 | // Find user by provider id 64 | const projection = { role: 1 }; 65 | const match = await user.getOneByFilter({ [provider]: profile.id }, { projection }); 66 | if (!match) throw Boom.unauthorized('No matching user found'); 67 | 68 | // Create JWT with access and refresh tokens 69 | res.json(await createJWT(match)); 70 | }, 71 | 72 | signOut: async (req, res) => { 73 | await user.signOut(req.user.id); 74 | res.status(204).end(); 75 | }, 76 | 77 | connectOAuthProvider: async (req, res) => { 78 | const { provider, token } = req.body; 79 | 80 | // Verify provider is supported 81 | if (!oauth.isProviderSupported(provider)) throw Boom.badRequest(`Unsupported provider '${provider}'`); 82 | 83 | // Verify token with provider and get user profile data 84 | const profile = await oauth.validateWithProvider(provider, token).catch((err) => { 85 | throw Boom.unauthorized(err.error); 86 | }); 87 | 88 | // Check if already connected to provider 89 | if (await user.isOAuthProviderConnected(req.user.id, provider)) { 90 | throw Boom.badRequest('Provider already connected'); 91 | } 92 | 93 | // Connect with provider 94 | await user.connectOAuthProvider(req.user.id, provider, profile.id); 95 | res.status(204).end(); 96 | }, 97 | 98 | disconnectOAuthProvider: async (req, res) => { 99 | const { provider } = req.body; 100 | 101 | // Verify provider is supported 102 | if (!oauth.isProviderSupported(provider)) throw Boom.badRequest(`Unsupported provider '${provider}'`); 103 | 104 | await user.disconnectOAuthProvider(req.user.id, provider); 105 | res.status(204).end(); 106 | }, 107 | }; 108 | -------------------------------------------------------------------------------- /handlers/base-handler.js: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | const Boom = require('@hapi/boom'); 3 | 4 | function safeParse(name, str) { 5 | if (str === undefined) return undefined; 6 | let obj; 7 | try { 8 | obj = JSON.parse(str); 9 | } catch (err) { 10 | throw Boom.badRequest(`failed to parse ${name}: ${err.message}`); 11 | } 12 | if (typeof obj !== 'object') throw Boom.badRequest(`${name} is not an object`); 13 | return obj; 14 | } 15 | 16 | class BaseHandler { 17 | constructor(model) { 18 | this.model = model; 19 | } 20 | 21 | async getOne(req, res) { 22 | // Get id and filter 23 | let { id } = req.params; 24 | if (id === 'me') id = req.user.id; 25 | const filter = safeParse('filter', req.query.filter); 26 | 27 | // Get item 28 | const item = await this.model.getOneById(id, filter); 29 | if (!item) throw Boom.notFound(`${req.originalUrl} not found`); 30 | res.json(item); 31 | } 32 | 33 | async deleteOne(req, res) { 34 | // Get id and filter 35 | let { id } = req.params; 36 | if (id === 'me') id = req.user.id; 37 | const filter = safeParse('filter', req.query.filter); 38 | 39 | // Delete item 40 | const item = await this.model.deleteOneById(id, filter); 41 | if (!item) throw Boom.notFound(`${req.originalUrl} not found`); 42 | res.status(204).end(); 43 | } 44 | 45 | async updateOne(req, res) { 46 | // Get id and filter 47 | let { id } = req.params; 48 | if (id === 'me') id = req.user.id; 49 | const filter = safeParse('filter', req.query.filter); 50 | 51 | // Update item fields 52 | const item = await this.model.updateOneById(id, filter, req.body); 53 | if (!item) throw Boom.notFound(`${req.originalUrl} not found`); 54 | res.status(204).end(); 55 | } 56 | 57 | async create(req, res) { 58 | // Set ownership to current user 59 | const { user } = req; 60 | if (user.id) req.body.ownerId = user.id; 61 | 62 | // Add one item to collection 63 | const item = await this.model.addOne(req.body); 64 | res.status(201).location(`${req.originalUrl}/${item._id}`).json(item); 65 | } 66 | 67 | async getMany(req, res) { 68 | // Get filter and sort objects (in OpenAPI 3 won't need to verify they are objects) 69 | const filter = safeParse('filter', req.query.filter); 70 | const sort = safeParse('sort', req.query.sort); 71 | 72 | // Get skip and limit 73 | const { skip, limit } = req.query; 74 | 75 | // Get list of items 76 | res.json(await this.model.getMany(filter, { sort, skip, limit })); 77 | } 78 | 79 | async updateMany(req, res) { 80 | // Get filter object (in OpenAPI 3 won't need to verify they are objects) 81 | const filter = safeParse('filter', req.query.filter); 82 | 83 | // Update list of items 84 | await this.model.updateMany(filter, req.body); 85 | res.status(204).end(); 86 | } 87 | 88 | async deleteMany(req, res) { 89 | // Get filter object (in OpenAPI 3 won't need to verify it is an object) 90 | const filter = safeParse('filter', req.query.filter); 91 | 92 | // Delete items from collection 93 | await this.model.deleteMany(filter); 94 | res.status(204).end(); 95 | } 96 | 97 | async updateOwner(req, res) { 98 | // Get id and filter 99 | let { id } = req.params; 100 | if (id === 'me') id = req.user.id; 101 | const filter = safeParse('filter', req.query.filter); 102 | 103 | // Update item owner 104 | const item = await this.model.replaceOwnerById(id, filter, req.body); 105 | if (!item) throw Boom.notFound(`${req.originalUrl} not found`); 106 | res.status(204).end(); 107 | } 108 | } 109 | 110 | module.exports = BaseHandler; 111 | -------------------------------------------------------------------------------- /handlers/index.js: -------------------------------------------------------------------------------- 1 | const requireDirectory = require('require-directory'); 2 | 3 | module.exports = requireDirectory(module, { recurse: false }); 4 | -------------------------------------------------------------------------------- /handlers/products.js: -------------------------------------------------------------------------------- 1 | const BaseHandler = require('./base-handler'); 2 | const model = require('../models/product'); 3 | 4 | const baseHandler = new BaseHandler(model); 5 | 6 | module.exports = { 7 | getProducts: (req, res) => baseHandler.getMany(req, res), 8 | createProduct: (req, res) => baseHandler.create(req, res), 9 | getProduct: (req, res) => baseHandler.getOne(req, res), 10 | updateProduct: (req, res) => baseHandler.updateOne(req, res), 11 | deleteProduct: (req, res) => baseHandler.deleteOne(req, res), 12 | updateProductOwner: (req, res) => baseHandler.updateOwner(req, res), 13 | }; 14 | -------------------------------------------------------------------------------- /handlers/profiles.js: -------------------------------------------------------------------------------- 1 | const BaseHandler = require('./base-handler'); 2 | const model = require('../models/profile'); 3 | 4 | const baseHandler = new BaseHandler(model); 5 | 6 | module.exports = { 7 | getProfile: (req, res) => baseHandler.getOne(req, res), 8 | }; 9 | -------------------------------------------------------------------------------- /handlers/tickets.js: -------------------------------------------------------------------------------- 1 | const BaseHandler = require('./base-handler'); 2 | const model = require('../models/ticket'); 3 | 4 | const baseHandler = new BaseHandler(model); 5 | 6 | module.exports = { 7 | getTickets: (req, res) => baseHandler.getMany(req, res), 8 | createTicket: (req, res) => baseHandler.create(req, res), 9 | getTicket: (req, res) => baseHandler.getOne(req, res), 10 | updateTicket: (req, res) => baseHandler.updateOne(req, res), 11 | deleteTicket: (req, res) => baseHandler.deleteOne(req, res), 12 | updateTicketOwner: (req, res) => baseHandler.updateOwner(req, res), 13 | }; 14 | -------------------------------------------------------------------------------- /handlers/users.js: -------------------------------------------------------------------------------- 1 | const Boom = require('@hapi/boom'); 2 | const BaseHandler = require('./base-handler'); 3 | const model = require('../models/user'); 4 | 5 | const baseHandler = new BaseHandler(model); 6 | 7 | module.exports = { 8 | getUsers: (req, res) => baseHandler.getMany(req, res), 9 | createUser: (req, res) => baseHandler.create(req, res), 10 | getUser: (req, res) => baseHandler.getOne(req, res), 11 | updateUser: (req, res) => baseHandler.updateOne(req, res), 12 | 13 | updateUserRole: async (req, res) => { 14 | // Update item owner 15 | const item = await model.setRole(req.params.id, req.body); 16 | if (!item) throw Boom.notFound(`${req.originalUrl} not found`); 17 | res.status(204).end(); 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/acl.js: -------------------------------------------------------------------------------- 1 | const { AccessControl } = require('accesscontrol'); 2 | 3 | module.exports = new AccessControl(); 4 | -------------------------------------------------------------------------------- /lib/analytics.js: -------------------------------------------------------------------------------- 1 | const ua = require('universal-analytics'); 2 | const options = require('./options')('analytics'); 3 | 4 | module.exports = (visitorId) => ((visitorId) ? ua(options.ua, visitorId, { strictCidFormat: false }) : ua(options.ua)); 5 | -------------------------------------------------------------------------------- /lib/checksum.js: -------------------------------------------------------------------------------- 1 | module.exports = (string) => { 2 | let checksum = 0x12345678; 3 | for (let index = 0; index < string.length; index += 1) { 4 | checksum += (string.charCodeAt(index) * (index + 1)); 5 | } 6 | return checksum; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/cluster.js: -------------------------------------------------------------------------------- 1 | const throng = require('throng'); 2 | const cpuCount = require('os').cpus().length; 3 | const options = require('./options')('server'); 4 | 5 | // Get number of workers (auto for cpu count) 6 | let { workers } = options; 7 | if (workers === 'auto') workers = cpuCount; 8 | workers = Number(workers); 9 | process.worker = { count: workers }; 10 | 11 | module.exports = async (startMaster, startWorker, startNoCluster) => { 12 | if (workers > 0) return throng({ master: startMaster, worker: startWorker, count: workers }); 13 | return startNoCluster(); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/email.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer'); 2 | const util = require('util'); 3 | const app = require('../app'); 4 | const options = require('./options')('email'); 5 | 6 | // Create transporter 7 | const transporter = nodemailer.createTransport({ service: options.service }, options); 8 | 9 | // Promisify 10 | const render = util.promisify(app.render).bind(app); 11 | 12 | // Send email 13 | function send(settings = {}, data = {}) { 14 | // Default template 15 | const mail = { ...settings }; 16 | mail.template = mail.template || options.template; 17 | 18 | // Render template into html 19 | return render(`emails/${mail.template}`, data).then((html) => { 20 | mail.html = html; 21 | 22 | // Send email 23 | return transporter.sendMail(mail); 24 | }); 25 | } 26 | 27 | module.exports = { send }; 28 | -------------------------------------------------------------------------------- /lib/init.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const mongo = require('./mongodb'); 3 | const redis = require('./redis'); 4 | 5 | // Init async services before setting up the express app 6 | module.exports = async () => { 7 | const services = []; 8 | if (config.has('mongodb.url')) services.push(mongo.connect()); 9 | if (config.has('redis.host')) services.push(redis.connect()); 10 | return Promise.all(services); 11 | }; 12 | -------------------------------------------------------------------------------- /lib/jwt.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const util = require('util'); 3 | const { secret, accessTTL, refreshTTL } = require('./options')('server.JWT'); 4 | 5 | const signAsync = util.promisify(jwt.sign); 6 | const verifyAsync = util.promisify(jwt.verify); 7 | 8 | function signJWT(payload, ttl) { 9 | return signAsync(payload, secret, { expiresIn: ttl }); 10 | } 11 | 12 | module.exports = { 13 | signAccessToken(payload) { 14 | return signJWT(payload, accessTTL); 15 | }, 16 | 17 | signRefreshToken(payload) { 18 | return signJWT(payload, refreshTTL); 19 | }, 20 | 21 | verifyToken(token) { 22 | return verifyAsync(token, secret); 23 | }, 24 | 25 | get secret() { 26 | return secret; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /lib/logger-http.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const https = require('https'); 3 | const logger = require('./logger'); 4 | const logOptions = require('./options')('log'); 5 | 6 | const NS_PER_SEC = 1e9; 7 | const MS_PER_NS = 1e6; 8 | 9 | function getHrDeltaInMs(startTime, endTime) { 10 | // Convert to nanoseconds 11 | const start = startTime[0] * NS_PER_SEC + startTime[1]; 12 | const end = endTime[0] * NS_PER_SEC + endTime[1]; 13 | 14 | // Return delta in milliseconds 15 | return (end - start) / MS_PER_NS; 16 | } 17 | 18 | function request(protocol, originalRequest, options, originalCallback) { 19 | const timings = { 20 | startAt: process.hrtime(), 21 | dnsLookupAt: undefined, 22 | tcpConnectionAt: undefined, 23 | tlsHandshakeAt: undefined, 24 | firstByteAt: undefined, 25 | endAt: undefined, 26 | }; 27 | 28 | const req = originalRequest.call(protocol, options, (res) => { 29 | res.once('readable', () => { 30 | timings.firstByteAt = process.hrtime(); 31 | }); 32 | res.on('end', () => { 33 | timings.endAt = process.hrtime(); 34 | const deltaTimings = { 35 | method: options.method, 36 | host: options.hostname, 37 | port: options.port, 38 | path: options.path, 39 | // There is no DNS lookup with IP address 40 | dnsLookup: timings.dnsLookupAt ? getHrDeltaInMs(timings.startAt, timings.dnsLookupAt) : undefined, 41 | tcpConnection: getHrDeltaInMs(timings.dnsLookupAt || timings.startAt, timings.tcpConnectionAt), 42 | // There is no TLS handshake without https 43 | tlsHandshake: timings.tlsHandshakeAt 44 | ? (getHrDeltaInMs(timings.tcpConnectionAt, timings.tlsHandshakeAt)) : undefined, 45 | firstByte: getHrDeltaInMs((timings.tlsHandshakeAt || timings.tcpConnectionAt), timings.firstByteAt), 46 | contentTransfer: getHrDeltaInMs(timings.firstByteAt, timings.endAt), 47 | total: getHrDeltaInMs(timings.startAt, timings.endAt), 48 | }; 49 | logger.info(`HTTP request: ${JSON.stringify(deltaTimings)}`); 50 | }); 51 | 52 | if (typeof originalCallback === 'function') originalCallback(res); 53 | }); 54 | 55 | req.on('socket', (socket) => { 56 | socket.on('lookup', () => { 57 | timings.dnsLookupAt = process.hrtime(); 58 | }); 59 | socket.on('connect', () => { 60 | timings.tcpConnectionAt = process.hrtime(); 61 | }); 62 | socket.on('secureConnect', () => { 63 | timings.tlsHandshakeAt = process.hrtime(); 64 | }); 65 | }); 66 | 67 | return req; 68 | } 69 | 70 | // Override request and get functions of http and https 71 | if (logOptions.http) { 72 | http.request = request.bind(null, http, http.request); 73 | http.get = request.bind(null, http, http.get); 74 | } 75 | 76 | if (logOptions.https) { 77 | https.request = request.bind(null, https, https.request); 78 | https.get = request.bind(null, https, https.get); 79 | } 80 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: "off" */ 2 | const config = require('config'); 3 | const winston = require('winston'); 4 | const rTracer = require('cls-rtracer'); 5 | 6 | const { transports } = winston; 7 | 8 | // Verify logger is configured 9 | if (!config.has('log')) { 10 | console.log('Logger is not configured - aborting'); 11 | process.exit(1); 12 | } 13 | const options = config.get('log'); 14 | 15 | // Message format 16 | const { combine, timestamp, printf } = winston.format; 17 | 18 | const consoleFormat = printf((info) => { 19 | const workerId = process.worker && process.worker.id; 20 | const wid = workerId ? ` (wid: ${process.worker.id})` : ''; 21 | const requestId = rTracer.id(); 22 | const rid = requestId ? ` [rid: ${requestId}]` : ''; 23 | return `${info.level}${wid}${rid}: ${info.message}`; 24 | }); 25 | 26 | const fileFormat = printf((info) => { 27 | const workerId = process.worker && process.worker.id; 28 | const wid = workerId ? ` (wid: ${process.worker.id})` : ''; 29 | const requestId = rTracer.id(); 30 | const rid = requestId ? ` [rid: ${requestId}]` : ''; 31 | return `${info.timestamp} ${info.level}${wid}${rid}: ${info.message}`; 32 | }); 33 | 34 | // Create logger 35 | const logger = winston.createLogger({ level: options.level }); 36 | 37 | // Add console output 38 | if (options.console) logger.add(new transports.Console({ format: consoleFormat })); 39 | 40 | // Add file output 41 | if (options.file) logger.add(new transports.File({ filename: options.file, format: combine(timestamp(), fileFormat) })); 42 | 43 | // Add general stream support 44 | logger.stream.write = (message) => { 45 | logger.info(message.trim()); 46 | }; 47 | 48 | module.exports = logger; 49 | -------------------------------------------------------------------------------- /lib/mongodb.js: -------------------------------------------------------------------------------- 1 | const MongoDriver = require('mongodb'); 2 | const logger = require('./logger'); 3 | const mongoOptions = require('./options')('mongodb'); 4 | 5 | class MongoConnection { 6 | constructor(options = {}) { 7 | this.driver = MongoDriver; 8 | this.options = { ...mongoOptions, ...options }; 9 | } 10 | 11 | // eslint-disable-next-line class-methods-use-this 12 | createNew(options) { 13 | return new MongoConnection(options); 14 | } 15 | 16 | get isConnected() { 17 | return this.connection !== undefined; 18 | } 19 | 20 | async connect() { 21 | // Connect only once 22 | if (this.isConnected) throw Error('MongoDB already connected'); 23 | 24 | // Take out connection settings from options 25 | const { url, db } = this.options; 26 | if (!url || !db) throw Error('MongoDB not connected: configuration is missing'); 27 | delete this.options.url; 28 | delete this.options.db; 29 | 30 | // Connect to db and report success or failure 31 | try { 32 | this.client = new MongoDriver.MongoClient(url, this.options); 33 | this.connection = await this.client.connect(); 34 | logger.info('MongoDB connected'); 35 | this.db = this.connection.db(db); 36 | } catch (err) { 37 | throw Error(`MongoDB failed to connect: ${err.message}`); 38 | } 39 | } 40 | 41 | async disconnect() { 42 | // Ignore if already not connected 43 | if (this.isConnected) { 44 | await this.connection.close(false); 45 | this.connection = undefined; 46 | this.db = undefined; 47 | this.client = undefined; 48 | } 49 | } 50 | } 51 | 52 | module.exports = new MongoConnection(); 53 | -------------------------------------------------------------------------------- /lib/oauth.js: -------------------------------------------------------------------------------- 1 | const sa = require('superagent'); 2 | const providers = require('./options')('oauth'); 3 | 4 | module.exports = { 5 | // Check provider is on the list of providers 6 | isProviderSupported(provider) { 7 | return provider in providers; 8 | }, 9 | 10 | // Send a GET request to the oauth provider with the token as query string 11 | validateWithProvider(provider, token) { 12 | return sa.get(providers[provider]).query({ access_token: token }).then((res) => JSON.parse(res.body)); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lib/options.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | 3 | module.exports = (name, copy = false) => { 4 | let options; 5 | 6 | if (config.has(name)) { 7 | // Copy or send original options from config 8 | options = copy ? { ...config.get(name) } : config.get(name); 9 | } else { 10 | options = {}; 11 | } 12 | 13 | // Return options object 14 | return options; 15 | }; 16 | -------------------------------------------------------------------------------- /lib/redis.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-unresolved: "off" */ 2 | const RedisDriver = require('ioredis'); 3 | const logger = require('./logger'); 4 | const redisOptions = require('./options')('redis'); 5 | 6 | class RedisConnection { 7 | constructor(options = {}) { 8 | this.driver = RedisDriver; 9 | this.options = { ...redisOptions, ...options }; 10 | } 11 | 12 | // eslint-disable-next-line class-methods-use-this 13 | createNew(options) { 14 | return new RedisConnection(options); 15 | } 16 | 17 | get isConnected() { 18 | return this.connection !== undefined; 19 | } 20 | 21 | async connect() { 22 | // Connect only once 23 | if (this.isConnected) throw Error('Redis already connected'); 24 | 25 | return new Promise((resolve, reject) => { 26 | // Connect to db and report success or failure 27 | this.client = new RedisDriver(this.options); 28 | this.client.once('ready', () => { 29 | logger.info('Redis connected'); 30 | this.connection = this.client; 31 | this.db = this.client; 32 | resolve(); 33 | }); 34 | this.client.on('error', (err) => { 35 | reject(Error(`Redis failed to connect: ${err.message}`)); 36 | }); 37 | }); 38 | } 39 | 40 | async disconnect() { 41 | // Ignore if already not connected 42 | if (this.isConnected) { 43 | await this.connection.disconnect(); 44 | this.client = undefined; 45 | this.connection = undefined; 46 | this.db = undefined; 47 | } 48 | } 49 | } 50 | 51 | module.exports = new RedisConnection(); 52 | -------------------------------------------------------------------------------- /lib/uuid.js: -------------------------------------------------------------------------------- 1 | const { v4: uuid } = require('uuid'); 2 | 3 | module.exports = () => uuid(); 4 | -------------------------------------------------------------------------------- /models/base-model.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: ["error", { "props": true, "ignorePropertyModificationsFor": ["item"] }] */ 2 | const { MongoError } = require('mongodb'); 3 | const Boom = require('@hapi/boom'); 4 | const mongo = require('../lib/mongodb'); 5 | 6 | const { ObjectId } = mongo.driver; 7 | 8 | function convertError(err) { 9 | if (err instanceof MongoError && err.code === 11000) { 10 | const keys = Object.keys(err.keyValue).toString(); 11 | throw Boom.conflict(`Already in use: ${keys}`); 12 | } 13 | throw err; 14 | } 15 | 16 | class BaseModel { 17 | constructor(collectionName, options = {}) { 18 | if (mongo.isConnected) { 19 | this.collection = mongo.db.collection(collectionName); 20 | this.ownership = options.ownership; 21 | this.timestamps = options.timestamps; 22 | } else { 23 | this.connection = () => { throw Error('Not connected'); }; 24 | } 25 | } 26 | 27 | static toObjectId(id) { 28 | // Invalid id should rethrow with status 400 29 | try { 30 | return new ObjectId(id); 31 | } catch (err) { 32 | throw Boom.badRequest(`id: ${err.message}`); 33 | } 34 | } 35 | 36 | // ***** Timestamps 37 | addTimestamp(item = {}) { 38 | if (this.timestamps) { 39 | item.createdAt = new Date(); 40 | item.updatedAt = item.createdAt; 41 | } 42 | } 43 | 44 | updateTimestamp(item = {}) { 45 | if (this.timestamps) { 46 | // Check if timestamp should be updated 47 | if (item.updatedAt !== null) { 48 | item.updatedAt = new Date(); 49 | } else { 50 | delete item.updatedAt; 51 | } 52 | } 53 | } 54 | 55 | // ***** Ownership 56 | convertOwnerId(item = {}) { 57 | if (this.ownership && item.ownerId) { 58 | item.ownerId = BaseModel.toObjectId(item.ownerId); 59 | } 60 | } 61 | 62 | // ***** Collections 63 | async addOne(item = {}) { 64 | this.addTimestamp(item); 65 | this.convertOwnerId(item); 66 | await this.collection.insertOne(item).catch(convertError); 67 | return item; 68 | } 69 | 70 | getMany(filter = {}, options = {}) { 71 | this.convertOwnerId(filter); 72 | return this.collection.find(filter, options).toArray(); 73 | } 74 | 75 | updateMany(filter = {}, item = {}, extra = {}) { 76 | this.updateTimestamp(item); 77 | this.convertOwnerId(filter); 78 | const pipeline = { $set: item, ...extra }; 79 | return this.collection.updateMany(filter, pipeline).catch(convertError); 80 | } 81 | 82 | deleteMany(filter = {}) { 83 | this.convertOwnerId(filter); 84 | return this.collection.deleteMany(filter); 85 | } 86 | 87 | // ***** Documents 88 | isExists(filter = {}) { 89 | this.convertOwnerId(filter); 90 | return this.collection.countDocuments(filter, { limit: 1 }); 91 | } 92 | 93 | getOneByFilter(filter = {}, options = {}) { 94 | this.convertOwnerId(filter); 95 | return this.collection.findOne(filter, options); 96 | } 97 | 98 | getOneById(id, filter = {}, options = {}) { 99 | const objectId = BaseModel.toObjectId(id); 100 | this.convertOwnerId(filter); 101 | const query = { _id: objectId, ...filter }; 102 | return this.collection.findOne(query, options); 103 | } 104 | 105 | async updateOneById(id, filter = {}, item = {}, extra = {}) { 106 | const objectId = BaseModel.toObjectId(id); 107 | this.updateTimestamp(item); 108 | this.convertOwnerId(filter); 109 | const query = { _id: objectId, ...filter }; 110 | const pipeline = { $set: item, ...extra }; 111 | const result = await this.collection.updateOne(query, pipeline).catch(convertError); 112 | return result.modifiedCount === 1; 113 | } 114 | 115 | async deleteOneById(id, filter = {}) { 116 | const objectId = BaseModel.toObjectId(id); 117 | this.convertOwnerId(filter); 118 | const query = { _id: objectId, ...filter }; 119 | const result = await this.collection.deleteOne(query); 120 | return result.deletedCount === 1; 121 | } 122 | 123 | replaceOwnerById(id, filter, ownerId) { 124 | const item = { ownerId: BaseModel.toObjectId(ownerId) }; 125 | return this.updateOneById(id, filter, item); 126 | } 127 | } 128 | 129 | module.exports = BaseModel; 130 | -------------------------------------------------------------------------------- /models/product.js: -------------------------------------------------------------------------------- 1 | const BaseModel = require('./base-model'); 2 | 3 | class ProductModel extends BaseModel { 4 | constructor() { 5 | super('products', { ownership: true, timestamps: true }); 6 | } 7 | } 8 | 9 | module.exports = new ProductModel(); 10 | -------------------------------------------------------------------------------- /models/profile.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | const BaseModel = require('./base-model'); 3 | 4 | class ProfileModel extends BaseModel { 5 | constructor() { 6 | super('users'); 7 | } 8 | 9 | getOneById(id, filter, options = {}) { 10 | // Get profile fields only 11 | options.projection = { username: 1 }; 12 | return super.getOneById(id, filter, options); 13 | } 14 | } 15 | 16 | module.exports = new ProfileModel(); 17 | -------------------------------------------------------------------------------- /models/schemas/index.js: -------------------------------------------------------------------------------- 1 | const requireDirectory = require('require-directory'); 2 | 3 | // Export directory and subdirectories 4 | module.exports = requireDirectory(module, { recurse: false }); 5 | -------------------------------------------------------------------------------- /models/schemas/products.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | schema: { 3 | $jsonSchema: { 4 | bsonType: 'object', 5 | required: ['title', 'ownerId'], 6 | properties: { 7 | title: { bsonType: 'string' }, 8 | price: { bsonType: 'number' }, 9 | // Shared properties 10 | ownerId: { bsonType: 'objectId' }, 11 | createdAt: { bsonType: 'date' }, 12 | updatedAt: { bsonType: 'date' }, 13 | }, 14 | }, 15 | }, 16 | indexes: [ 17 | { fields: { ownerId: 1 }, options: {} }, 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /models/schemas/tickets.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | schema: { 3 | $jsonSchema: { 4 | bsonType: 'object', 5 | required: ['title', 'ownerId'], 6 | properties: { 7 | title: { bsonType: 'string' }, 8 | venue: { bsonType: 'string' }, 9 | price: { bsonType: 'number' }, 10 | // Shared properties 11 | ownerId: { bsonType: 'objectId' }, 12 | createdAt: { bsonType: 'date' }, 13 | updatedAt: { bsonType: 'date' }, 14 | }, 15 | }, 16 | }, 17 | indexes: [ 18 | { fields: { ownerId: 1 }, options: {} }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /models/schemas/users.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | module.exports = { 3 | schema: { 4 | $jsonSchema: { 5 | bsonType: 'object', 6 | required: ['email', 'role'], 7 | properties: { 8 | username: { bsonType: 'string' }, 9 | email: { bsonType: 'string' }, 10 | password: { bsonType: 'string' }, 11 | role: { enum: ['user', 'moderator', 'admin'] }, 12 | createdAt: { bsonType: 'date' }, 13 | updatedAt: { bsonType: 'date' }, 14 | signedOutAt: { bsonType: 'date' }, 15 | oauth: { 16 | bsonType: 'array', 17 | items: { 18 | bsonType: 'object', 19 | properties: { 20 | provider: { bsonType: 'string' }, 21 | id: { bsonType: 'string' }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | }, 28 | indexes: [ 29 | { fields: { email: 1 }, options: { unique: true } }, 30 | { fields: { username: 1 }, options: { unique: true, partialFilterExpression: { username: { $exists: true } } } }, 31 | { 32 | fields: { 'oauth.provider': 1, 'oauth.id': 1 }, 33 | options: { 34 | unique: true, 35 | partialFilterExpression: { $and: [{ 'oauth.provider': { $exists: true } }, { 'oauth.id': { $exists: true } }] }, 36 | }, 37 | }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /models/ticket.js: -------------------------------------------------------------------------------- 1 | const BaseModel = require('./base-model'); 2 | 3 | class TicketModel extends BaseModel { 4 | constructor() { 5 | super('tickets', { ownership: true, timestamps: true }); 6 | } 7 | } 8 | 9 | module.exports = new TicketModel(); 10 | -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | /* eslint class-methods-use-this: "off" */ 2 | /* eslint-disable no-param-reassign */ 3 | const bcrypt = require('bcryptjs'); 4 | const logger = require('../lib/logger'); 5 | const BaseModel = require('./base-model'); 6 | 7 | class UserModel extends BaseModel { 8 | constructor() { 9 | super('users', { timestamps: true }); 10 | } 11 | 12 | validatePassword(password, hash) { 13 | try { 14 | return bcrypt.compare(password, hash); 15 | } catch (err) { 16 | logger.warn(`Cannot validate password: ${err.message}`); 17 | return false; 18 | } 19 | } 20 | 21 | getMany(filter, options = {}) { 22 | options.projection = { password: 0 }; 23 | return super.getMany(filter, options); 24 | } 25 | 26 | getOneById(id, filter, options = {}) { 27 | options.projection = { password: 0 }; 28 | return super.getOneById(id, filter, options); 29 | } 30 | 31 | async addOne(item = {}) { 32 | // Hash password 33 | if (item.password) item.password = await bcrypt.hash(item.password, 10); 34 | // New user role is always set to user 35 | item.role = 'user'; 36 | return super.addOne(item); 37 | } 38 | 39 | async updateOneById(id, filter, item = {}) { 40 | if (item.password) { 41 | // Hash password 42 | item.password = await bcrypt.hash(item.password, 10); 43 | // Logout user to force use of new password on all devices 44 | item.signedOutAt = new Date(); 45 | } 46 | return super.updateOneById(id, filter, item); 47 | } 48 | 49 | signOut(id) { 50 | const item = { signedOutAt: new Date(), updatedAt: null }; 51 | return super.updateOneById(id, {}, item); 52 | } 53 | 54 | setRole(id, role) { 55 | const item = { role }; 56 | return super.updateOneById(id, {}, item); 57 | } 58 | 59 | connectOAuthProvider(id, provider, providerId) { 60 | const extra = { $push: { oauth: { provider, id: providerId } } }; 61 | return super.updateOneById(id, {}, {}, extra); 62 | } 63 | 64 | disconnectOAuthProvider(id, provider) { 65 | const extra = { $pull: { oauth: { provider } } }; 66 | return super.updateOneById(id, {}, {}, extra); 67 | } 68 | 69 | isOAuthProviderConnected(id, provider) { 70 | return super.isExists({ _id: BaseModel.toObjectId(id), 'oauth.provider': provider }); 71 | } 72 | } 73 | 74 | module.exports = new UserModel(); 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basejs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node server", 7 | "db": "node tools/db", 8 | "lint": "node_modules/.bin/eslint .", 9 | "test": "node_modules/.bin/nyc node_modules/.bin/mocha --reporter mochawesome", 10 | "swagger": "node_modules/.bin/swagger-cli validate routes/api.yaml", 11 | "prepare": "husky install" 12 | }, 13 | "engines": { 14 | "node": ">=16.0.0" 15 | }, 16 | "dependencies": { 17 | "@hapi/boom": "^10.0.0", 18 | "accesscontrol": "^2.2.1", 19 | "auto-strict": "^0.0.5", 20 | "bcryptjs": "^3.0.2", 21 | "cls-rtracer": "^2.4.0", 22 | "commander": "^13.1.0", 23 | "config": "^3.3.2", 24 | "cookie-parser": "^1.4.5", 25 | "express": "^4.21.1", 26 | "express-jwt": "^8.2.1", 27 | "express-openapi-validator": "^5.0.4", 28 | "ioredis": "^5.0.1", 29 | "jsonwebtoken": "^9.0.0", 30 | "mongodb": "^6.1.0", 31 | "morgan": "^1.10.0", 32 | "nodemailer": "^6.4.16", 33 | "pug": "^3.0.0", 34 | "request-ip": "^3.0.2", 35 | "require-directory": "^2.1.1", 36 | "serve-favicon": "^2.5.0", 37 | "superagent": "^10.1.1", 38 | "swagger-ui-express": "^5.0.0", 39 | "throng": "^5.0.0", 40 | "universal-analytics": "^0.5.1", 41 | "uuid": "^11.0.3", 42 | "winston": "^3.3.3" 43 | }, 44 | "devDependencies": { 45 | "eslint": "^8.57.1", 46 | "eslint-config-airbnb-base": "^15.0.0", 47 | "eslint-plugin-import": "^2.25.3", 48 | "husky": "^9.0.2", 49 | "lint-staged": "^15.2.0", 50 | "mocha": "^11.1.0", 51 | "mochawesome": "^7.0.1", 52 | "nyc": "^17.1.0", 53 | "sinon": "^19.0.2", 54 | "supertest": "^7.0.0", 55 | "swagger-cli": "^4.0.4" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonenduk/basejs/9a0377c85d15ac9fd8a146d4ca0520615970594d/public/favicon.png -------------------------------------------------------------------------------- /public/images/anonymous.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonenduk/basejs/9a0377c85d15ac9fd8a146d4ca0520615970594d/public/images/anonymous.jpg -------------------------------------------------------------------------------- /public/images/email/footer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonenduk/basejs/9a0377c85d15ac9fd8a146d4ca0520615970594d/public/images/email/footer.jpg -------------------------------------------------------------------------------- /public/images/email/header.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonenduk/basejs/9a0377c85d15ac9fd8a146d4ca0520615970594d/public/images/email/header.jpg -------------------------------------------------------------------------------- /public/images/logo_big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonenduk/basejs/9a0377c85d15ac9fd8a146d4ca0520615970594d/public/images/logo_big.png -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } 9 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const OpenApiValidator = require('express-openapi-validator'); 4 | const Boom = require('@hapi/boom'); 5 | const rTracer = require('cls-rtracer'); 6 | const ua = require('../lib/analytics'); 7 | const acl = require('../acl'); 8 | const handlers = require('../handlers'); 9 | const options = require('../lib/options'); 10 | 11 | const apiOptions = options('api'); 12 | const analyticsOptions = options('analytics'); 13 | 14 | const routerAPI = async () => { 15 | const router = express.Router(); 16 | 17 | // Google Analytics 18 | if (analyticsOptions.api) { 19 | router.use('/api', (req, res, next) => { 20 | const visitor = ua(req.user.id); 21 | const ip = req.clientIp; 22 | if (ip !== '::1') visitor.set('uip', ip); 23 | visitor.pageview(req.originalUrl).send(); 24 | next(); 25 | }); 26 | } 27 | 28 | // Request id 29 | if (apiOptions.id) { 30 | router.use('/api', rTracer.expressMiddleware()); 31 | } 32 | 33 | // Swagger validations 34 | router.use(OpenApiValidator.middleware({ 35 | apiSpec: path.join(__dirname, 'api.yaml'), 36 | operationHandlers: { 37 | basePath: path.join(__dirname, '../handlers'), 38 | resolver: (basePath, route, apiDoc) => { 39 | const pathKey = route.openApiRoute.substring(route.basePath.length); 40 | const schema = apiDoc.paths[pathKey][route.method.toLowerCase()]; 41 | 42 | // Get operation handler and id 43 | const { operationId } = schema; 44 | const operationHandler = schema['x-eov-operation-handler']; 45 | 46 | // Handler and access control level validations 47 | const operation = handlers?.[operationHandler]?.[operationId]; 48 | if (operation) { 49 | return async (req, res, next) => { 50 | try { 51 | acl?.[operationHandler]?.[operationId]?.(req); 52 | await operation(req, res); 53 | } catch (err) { 54 | next(err); 55 | } 56 | }; 57 | } 58 | 59 | // Default handler (not implemented error) 60 | return (req, res, next) => { 61 | next(Boom.notImplemented(`${req.method} ${req.path} not implemented`)); 62 | }; 63 | }, 64 | }, 65 | })); 66 | 67 | return router; 68 | }; 69 | 70 | module.exports = routerAPI(); 71 | -------------------------------------------------------------------------------- /routes/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | servers: 3 | - url: /api 4 | info: 5 | title: basejs API 6 | version: 0.0.1 7 | description: Documentation of the RESTful API exposed by basejs 8 | security: 9 | - JWT: [] 10 | paths: 11 | /auth/token/credentials: 12 | post: 13 | tags: 14 | - Authentication 15 | summary: Sign in existing user by credentials and return JWTs 16 | security: [] 17 | operationId: signInWithCredentials 18 | x-eov-operation-handler: auth 19 | responses: 20 | '200': 21 | description: New generated JWTs 22 | content: 23 | application/json: 24 | schema: 25 | $ref: '#/components/schemas/AccessRefreshJWTs' 26 | default: 27 | $ref: '#/components/responses/ErrorResponse' 28 | requestBody: 29 | content: 30 | application/json: 31 | schema: 32 | type: object 33 | required: 34 | - username 35 | - password 36 | properties: 37 | username: 38 | type: string 39 | description: email or username 40 | password: 41 | type: string 42 | format: password 43 | description: User credentials 44 | required: true 45 | /auth/token/refresh: 46 | post: 47 | tags: 48 | - Authentication 49 | summary: Renew JWTs based on a refresh token 50 | security: [] 51 | operationId: signInWithRefreshToken 52 | x-eov-operation-handler: auth 53 | responses: 54 | '200': 55 | description: New generated JWTs 56 | content: 57 | application/json: 58 | schema: 59 | $ref: '#/components/schemas/AccessRefreshJWTs' 60 | default: 61 | $ref: '#/components/responses/ErrorResponse' 62 | requestBody: 63 | content: 64 | application/json: 65 | schema: 66 | type: object 67 | required: 68 | - token 69 | properties: 70 | token: 71 | type: string 72 | description: refresh token 73 | required: true 74 | /auth/token/oauth: 75 | post: 76 | tags: 77 | - Authentication 78 | summary: Sign in or sign up a user by OAUth provider and return JWTs 79 | security: [] 80 | operationId: signInWithOAuthToken 81 | x-eov-operation-handler: auth 82 | responses: 83 | '200': 84 | description: New generated JWTs 85 | content: 86 | application/json: 87 | schema: 88 | $ref: '#/components/schemas/AccessRefreshJWTs' 89 | default: 90 | $ref: '#/components/responses/ErrorResponse' 91 | requestBody: 92 | content: 93 | application/json: 94 | schema: 95 | type: object 96 | required: 97 | - provider 98 | - token 99 | properties: 100 | provider: 101 | type: string 102 | token: 103 | type: string 104 | description: OAuth provider and its access token 105 | required: true 106 | /auth/token: 107 | delete: 108 | tags: 109 | - Authentication 110 | summary: Sign out current user and invalidate existing refresh tokens 111 | operationId: signOut 112 | x-eov-operation-handler: auth 113 | responses: 114 | '204': 115 | $ref: '#/components/responses/NoContentResponse' 116 | default: 117 | $ref: '#/components/responses/ErrorResponse' 118 | /auth/oauth: 119 | post: 120 | tags: 121 | - Authentication 122 | summary: Connect current user to OAuth provider 123 | operationId: connectOAuthProvider 124 | x-eov-operation-handler: auth 125 | responses: 126 | '204': 127 | $ref: '#/components/responses/NoContentResponse' 128 | default: 129 | $ref: '#/components/responses/ErrorResponse' 130 | requestBody: 131 | content: 132 | application/json: 133 | schema: 134 | type: object 135 | required: 136 | - provider 137 | - token 138 | properties: 139 | provider: 140 | type: string 141 | token: 142 | type: string 143 | description: OAuth provider 144 | required: true 145 | delete: 146 | tags: 147 | - Authentication 148 | summary: Disconnect current user from OAuth provider 149 | operationId: disconnectOAuthProvider 150 | x-eov-operation-handler: auth 151 | responses: 152 | '204': 153 | $ref: '#/components/responses/NoContentResponse' 154 | default: 155 | $ref: '#/components/responses/ErrorResponse' 156 | requestBody: 157 | content: 158 | application/json: 159 | schema: 160 | type: object 161 | required: 162 | - provider 163 | properties: 164 | provider: 165 | type: string 166 | description: OAuth provider 167 | required: true 168 | /users: 169 | get: 170 | tags: 171 | - Users 172 | summary: Get all users 173 | parameters: 174 | - $ref: '#/components/parameters/filterParam' 175 | - $ref: '#/components/parameters/sortParam' 176 | - $ref: '#/components/parameters/skipParam' 177 | - $ref: '#/components/parameters/limitParam' 178 | operationId: getUsers 179 | x-eov-operation-handler: users 180 | responses: 181 | '200': 182 | description: Users 183 | content: 184 | application/json: 185 | schema: 186 | $ref: '#/components/schemas/UserList' 187 | default: 188 | $ref: '#/components/responses/ErrorResponse' 189 | post: 190 | tags: 191 | - Users 192 | summary: Create a user 193 | security: [] 194 | operationId: createUser 195 | x-eov-operation-handler: users 196 | responses: 197 | '201': 198 | description: New user 199 | content: 200 | application/json: 201 | schema: 202 | $ref: '#/components/schemas/User' 203 | default: 204 | $ref: '#/components/responses/ErrorResponse' 205 | requestBody: 206 | content: 207 | application/json: 208 | schema: 209 | allOf: 210 | - $ref: '#/components/schemas/User' 211 | - required: 212 | - email 213 | - password 214 | description: User to create 215 | required: true 216 | /users/{id}: 217 | parameters: 218 | - $ref: '#/components/parameters/idParam' 219 | get: 220 | tags: 221 | - Users 222 | summary: Get a user by id 223 | operationId: getUser 224 | x-eov-operation-handler: users 225 | responses: 226 | '200': 227 | description: User 228 | content: 229 | application/json: 230 | schema: 231 | $ref: '#/components/schemas/User' 232 | default: 233 | $ref: '#/components/responses/ErrorResponse' 234 | patch: 235 | tags: 236 | - Users 237 | summary: Update user fields by id 238 | operationId: updateUser 239 | x-eov-operation-handler: users 240 | responses: 241 | '204': 242 | $ref: '#/components/responses/NoContentResponse' 243 | default: 244 | $ref: '#/components/responses/ErrorResponse' 245 | requestBody: 246 | content: 247 | application/json: 248 | schema: 249 | $ref: '#/components/schemas/User' 250 | description: User fields to update 251 | required: true 252 | /users/{id}/role: 253 | parameters: 254 | - $ref: '#/components/parameters/idParam' 255 | put: 256 | tags: 257 | - Users 258 | summary: Set user role 259 | operationId: updateUserRole 260 | x-eov-operation-handler: users 261 | responses: 262 | '204': 263 | $ref: '#/components/responses/NoContentResponse' 264 | default: 265 | $ref: '#/components/responses/ErrorResponse' 266 | requestBody: 267 | content: 268 | text/plain: 269 | schema: 270 | type: string 271 | enum: 272 | - user 273 | - moderator 274 | - admin 275 | description: New role 276 | required: true 277 | /profiles/{id}: 278 | parameters: 279 | - $ref: '#/components/parameters/idParam' 280 | get: 281 | tags: 282 | - Users 283 | summary: Get a user profile by id 284 | security: 285 | - {} 286 | - JWT: [] 287 | operationId: getProfile 288 | x-eov-operation-handler: profiles 289 | responses: 290 | '200': 291 | description: Profile 292 | content: 293 | application/json: 294 | schema: 295 | $ref: '#/components/schemas/Profile' 296 | default: 297 | $ref: '#/components/responses/ErrorResponse' 298 | /products: 299 | get: 300 | tags: 301 | - Products 302 | summary: Get all products 303 | security: 304 | - {} 305 | - JWT: [] 306 | parameters: 307 | - $ref: '#/components/parameters/filterParam' 308 | - $ref: '#/components/parameters/sortParam' 309 | - $ref: '#/components/parameters/skipParam' 310 | - $ref: '#/components/parameters/limitParam' 311 | operationId: getProducts 312 | x-eov-operation-handler: products 313 | responses: 314 | '200': 315 | description: Products 316 | content: 317 | application/json: 318 | schema: 319 | $ref: '#/components/schemas/ProductList' 320 | default: 321 | $ref: '#/components/responses/ErrorResponse' 322 | post: 323 | tags: 324 | - Products 325 | summary: Create a product 326 | operationId: createProduct 327 | x-eov-operation-handler: products 328 | responses: 329 | '201': 330 | description: New product 331 | content: 332 | application/json: 333 | schema: 334 | $ref: '#/components/schemas/Product' 335 | default: 336 | $ref: '#/components/responses/ErrorResponse' 337 | requestBody: 338 | content: 339 | application/json: 340 | schema: 341 | allOf: 342 | - $ref: '#/components/schemas/Product' 343 | - required: 344 | - title 345 | description: Product to create 346 | required: true 347 | /products/{id}: 348 | parameters: 349 | - $ref: '#/components/parameters/idParam' 350 | get: 351 | tags: 352 | - Products 353 | summary: Get a product by id 354 | security: 355 | - {} 356 | - JWT: [] 357 | operationId: getProduct 358 | x-eov-operation-handler: products 359 | responses: 360 | '200': 361 | description: Product 362 | content: 363 | application/json: 364 | schema: 365 | $ref: '#/components/schemas/Product' 366 | default: 367 | $ref: '#/components/responses/ErrorResponse' 368 | patch: 369 | tags: 370 | - Products 371 | summary: Update product fields by id 372 | operationId: updateProduct 373 | x-eov-operation-handler: products 374 | responses: 375 | '204': 376 | $ref: '#/components/responses/NoContentResponse' 377 | default: 378 | $ref: '#/components/responses/ErrorResponse' 379 | requestBody: 380 | content: 381 | application/json: 382 | schema: 383 | $ref: '#/components/schemas/Product' 384 | description: Product fields to update 385 | required: true 386 | delete: 387 | tags: 388 | - Products 389 | summary: Delete a product by id 390 | operationId: deleteProduct 391 | x-eov-operation-handler: products 392 | responses: 393 | '204': 394 | $ref: '#/components/responses/NoContentResponse' 395 | default: 396 | $ref: '#/components/responses/ErrorResponse' 397 | /products/{id}/owner: 398 | parameters: 399 | - $ref: '#/components/parameters/idParam' 400 | put: 401 | tags: 402 | - Products 403 | summary: Replace owner of product 404 | operationId: updateProductOwner 405 | x-eov-operation-handler: products 406 | responses: 407 | '204': 408 | $ref: '#/components/responses/NoContentResponse' 409 | default: 410 | $ref: '#/components/responses/ErrorResponse' 411 | requestBody: 412 | content: 413 | text/plain: 414 | schema: 415 | type: string 416 | description: New owner id 417 | required: true 418 | /tickets: 419 | get: 420 | tags: 421 | - Tickets 422 | summary: Get all tickets 423 | parameters: 424 | - $ref: '#/components/parameters/filterParam' 425 | - $ref: '#/components/parameters/sortParam' 426 | - $ref: '#/components/parameters/skipParam' 427 | - $ref: '#/components/parameters/limitParam' 428 | operationId: getTickets 429 | x-eov-operation-handler: tickets 430 | responses: 431 | '200': 432 | description: Tickets 433 | content: 434 | application/json: 435 | schema: 436 | $ref: '#/components/schemas/TicketList' 437 | default: 438 | $ref: '#/components/responses/ErrorResponse' 439 | post: 440 | tags: 441 | - Tickets 442 | summary: Create a ticket 443 | operationId: createTicket 444 | x-eov-operation-handler: tickets 445 | responses: 446 | '201': 447 | description: New ticket 448 | content: 449 | application/json: 450 | schema: 451 | $ref: '#/components/schemas/Ticket' 452 | default: 453 | $ref: '#/components/responses/ErrorResponse' 454 | requestBody: 455 | content: 456 | application/json: 457 | schema: 458 | allOf: 459 | - $ref: '#/components/schemas/Ticket' 460 | - required: 461 | - title 462 | description: Ticket to create 463 | required: true 464 | /tickets/{id}: 465 | parameters: 466 | - $ref: '#/components/parameters/idParam' 467 | get: 468 | tags: 469 | - Tickets 470 | summary: Get a ticket by id 471 | operationId: getTicket 472 | x-eov-operation-handler: tickets 473 | responses: 474 | '200': 475 | description: Ticket 476 | content: 477 | application/json: 478 | schema: 479 | $ref: '#/components/schemas/Ticket' 480 | default: 481 | $ref: '#/components/responses/ErrorResponse' 482 | patch: 483 | tags: 484 | - Tickets 485 | summary: Update ticket fields by id 486 | operationId: updateTicket 487 | x-eov-operation-handler: tickets 488 | responses: 489 | '204': 490 | $ref: '#/components/responses/NoContentResponse' 491 | default: 492 | $ref: '#/components/responses/ErrorResponse' 493 | requestBody: 494 | content: 495 | application/json: 496 | schema: 497 | $ref: '#/components/schemas/Ticket' 498 | description: Ticket fields to update 499 | required: true 500 | delete: 501 | tags: 502 | - Tickets 503 | summary: Delete a ticket by id 504 | operationId: deleteTicket 505 | x-eov-operation-handler: tickets 506 | responses: 507 | '204': 508 | $ref: '#/components/responses/NoContentResponse' 509 | default: 510 | $ref: '#/components/responses/ErrorResponse' 511 | /tickets/{id}/owner: 512 | parameters: 513 | - $ref: '#/components/parameters/idParam' 514 | put: 515 | tags: 516 | - Tickets 517 | summary: Replace owner of ticket 518 | operationId: updateTicketOwner 519 | x-eov-operation-handler: tickets 520 | responses: 521 | '204': 522 | $ref: '#/components/responses/NoContentResponse' 523 | default: 524 | $ref: '#/components/responses/ErrorResponse' 525 | requestBody: 526 | content: 527 | text/plain: 528 | schema: 529 | type: string 530 | description: New owner id 531 | required: true 532 | tags: 533 | - name: Authentication 534 | - name: Users 535 | - name: Products 536 | description: Sample public collection 537 | - name: Tickets 538 | description: Sample private collection 539 | components: 540 | responses: 541 | NoContentResponse: 542 | description: OK 543 | ErrorResponse: 544 | description: Error 545 | content: 546 | application/json: 547 | schema: 548 | $ref: '#/components/schemas/Error' 549 | parameters: 550 | idParam: 551 | name: id 552 | in: path 553 | description: id of item 554 | required: true 555 | schema: 556 | type: string 557 | filterParam: 558 | name: filter 559 | in: query 560 | description: 'Filter by { "field": "value", ... }' 561 | schema: 562 | type: string 563 | sortParam: 564 | name: sort 565 | in: query 566 | description: 'Sort by { "field": "asc/desc", "field": 1/-1, ... }' 567 | schema: 568 | type: string 569 | skipParam: 570 | name: skip 571 | in: query 572 | description: Number of items to skip 573 | schema: 574 | type: integer 575 | minimum: 0 576 | limitParam: 577 | name: limit 578 | in: query 579 | description: Number of items to return (1 - 100, default is 20) 580 | schema: 581 | type: integer 582 | minimum: 1 583 | maximum: 100 584 | default: 20 585 | securitySchemes: 586 | JWT: 587 | type: http 588 | scheme: bearer 589 | bearerFormat: Bearer 590 | schemas: 591 | User: 592 | allOf: 593 | - $ref: '#/components/schemas/MongoID' 594 | - type: object 595 | properties: 596 | username: 597 | type: string 598 | email: 599 | type: string 600 | password: 601 | type: string 602 | format: password 603 | writeOnly: true 604 | role: 605 | type: string 606 | enum: 607 | - user 608 | - moderator 609 | - admin 610 | readOnly: true 611 | signedOutAt: 612 | type: string 613 | format: date-time 614 | readOnly: true 615 | oauth: 616 | type: array 617 | readOnly: true 618 | items: 619 | type: object 620 | properties: 621 | provider: 622 | type: string 623 | readOnly: true 624 | id: 625 | type: string 626 | readOnly: true 627 | - $ref: '#/components/schemas/TimeStamp' 628 | UserList: 629 | type: array 630 | items: 631 | $ref: '#/components/schemas/User' 632 | Profile: 633 | allOf: 634 | - $ref: '#/components/schemas/MongoID' 635 | - type: object 636 | properties: 637 | username: 638 | type: string 639 | Product: 640 | allOf: 641 | - $ref: '#/components/schemas/MongoID' 642 | - type: object 643 | properties: 644 | title: 645 | type: string 646 | price: 647 | type: number 648 | - $ref: '#/components/schemas/OwnerID' 649 | - $ref: '#/components/schemas/TimeStamp' 650 | ProductList: 651 | type: array 652 | items: 653 | $ref: '#/components/schemas/Product' 654 | Ticket: 655 | allOf: 656 | - $ref: '#/components/schemas/MongoID' 657 | - type: object 658 | properties: 659 | title: 660 | type: string 661 | venue: 662 | type: string 663 | price: 664 | type: number 665 | - $ref: '#/components/schemas/OwnerID' 666 | - $ref: '#/components/schemas/TimeStamp' 667 | TicketList: 668 | type: array 669 | items: 670 | $ref: '#/components/schemas/Ticket' 671 | MongoID: 672 | type: object 673 | properties: 674 | _id: 675 | type: string 676 | readOnly: true 677 | OwnerID: 678 | type: object 679 | properties: 680 | ownerId: 681 | type: string 682 | readOnly: true 683 | TimeStamp: 684 | type: object 685 | properties: 686 | createdAt: 687 | type: string 688 | format: date-time 689 | readOnly: true 690 | updatedAt: 691 | type: string 692 | format: date-time 693 | readOnly: true 694 | Error: 695 | type: object 696 | required: 697 | - statusCode 698 | properties: 699 | statusCode: 700 | type: integer 701 | error: 702 | type: string 703 | message: 704 | type: string 705 | AccessRefreshJWTs: 706 | type: object 707 | properties: 708 | access_token: 709 | type: string 710 | refresh_token: 711 | type: string 712 | -------------------------------------------------------------------------------- /routes/common.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const requestIp = require('request-ip'); 3 | const { expressjwt } = require('express-jwt'); 4 | const Boom = require('@hapi/boom'); 5 | const jwt = require('../lib/jwt'); 6 | 7 | const router = express.Router(); 8 | 9 | // Default JWT extraction options 10 | const jwtOptions = { secret: jwt.secret, credentialsRequired: false, algorithms: ['HS256'] }; 11 | 12 | // Client IP 13 | router.use(requestIp.mw()); 14 | 15 | // JWT extraction 16 | router.use(expressjwt(jwtOptions), (req, res, next) => { 17 | req.user = req.auth; 18 | // Create a default guest user if token not given 19 | if (!req.user) req.user = { role: 'guest' }; 20 | // Verify role exists in user 21 | if (!req.user.role) throw Boom.badRequest('jwt missing role'); 22 | next(); 23 | }); 24 | 25 | module.exports = router; 26 | -------------------------------------------------------------------------------- /routes/error.js: -------------------------------------------------------------------------------- 1 | const Boom = require('@hapi/boom'); 2 | const logger = require('../lib/logger'); 3 | 4 | // noinspection JSUnusedLocalSymbols 5 | module.exports = (err, req, res, next) => { // eslint-disable-line no-unused-vars 6 | // Convert error to Boom error and set status to 500 if not set 7 | const { payload, headers } = Boom.boomify(err, { statusCode: err.status, override: false }).output; 8 | 9 | // Log stack on server internal error 10 | if (payload.statusCode === 500) logger.error(err.stack); 11 | 12 | // Respond with correct content type 13 | res.set(headers); 14 | if (req.path.startsWith('/api')) { 15 | res.status(payload.statusCode).json(payload); 16 | } else { 17 | res.status(payload.statusCode).render('error', { error: payload }); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /routes/web.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const Boom = require('@hapi/boom'); 4 | const swaggerUi = require('swagger-ui-express'); 5 | const ac = require('../lib/acl'); 6 | const ua = require('../lib/analytics'); 7 | const options = require('../lib/options'); 8 | 9 | const router = express.Router(); 10 | 11 | const apiOptions = options('api'); 12 | const analyticsOptions = options('analytics'); 13 | 14 | // Google analytics (server side) 15 | if (analyticsOptions.web) { 16 | router.use((req, res, next) => { 17 | const visitor = ua(req.user.id); 18 | const ip = req.clientIp; 19 | if (ip !== '::1') visitor.set('uip', ip); 20 | visitor.pageview(req.originalUrl).send(); 21 | next(); 22 | }); 23 | } 24 | 25 | // Access control 26 | router.use((req, res, next) => { 27 | const permission = ac.can(req.user.role).readAny('webpage'); 28 | if (!permission.granted) throw Boom.forbidden('Access denied'); 29 | return next(); 30 | }); 31 | 32 | // Pages 33 | router.get('/', (req, res) => { 34 | let ip = req.clientIp; 35 | if (ip === '::1') ip = '127.0.0.1'; 36 | res.render('index', { ip }); 37 | }); 38 | 39 | router.get('/ping', (req, res) => { 40 | res.send('pong'); 41 | }); 42 | 43 | router.get('/health', (req, res) => { 44 | res.send('OK'); 45 | }); 46 | 47 | // Swagger UI 48 | if (apiOptions.ui) { 49 | const swaggerConfig = { 50 | explorer: true, 51 | swaggerOptions: { 52 | url: '/api-docs', 53 | }, 54 | }; 55 | router.use('/api-ui', swaggerUi.serve); 56 | router.get('/api-ui', swaggerUi.setup(null, swaggerConfig)); 57 | } 58 | 59 | // Swagger docs 60 | if (apiOptions.docs) { 61 | router.use('/api-docs', express.static(path.join(__dirname, 'api.yaml'))); 62 | } 63 | 64 | // Catch 404 and forward to error handler 65 | router.use(() => { 66 | throw Boom.notFound('Page not found'); 67 | }); 68 | 69 | module.exports = router; 70 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint global-require: "off" */ 3 | 4 | // Use strict mode in all project modules 5 | require('auto-strict'); 6 | 7 | // Initialize config, logger and cluster 8 | const logger = require('./lib/logger'); 9 | const cluster = require('./lib/cluster'); 10 | 11 | // Cluster callback functions 12 | function startMaster() { 13 | logger.info(`Master started on pid ${process.pid}, forking ${process.worker.count} processes`); 14 | } 15 | 16 | function startWorker(id) { 17 | process.worker.id = id; 18 | logger.info('Worker started'); 19 | 20 | process.on('SIGTERM', () => { 21 | logger.info('Worker exiting...'); 22 | process.exit(); 23 | }); 24 | 25 | require('./worker'); 26 | } 27 | 28 | function startNoCluster() { 29 | require('./worker'); 30 | } 31 | 32 | // Start cluster support 33 | cluster(startMaster, startWorker, startNoCluster).catch((err) => { 34 | logger.error(`Failed to start: ${err.message}`); 35 | }); 36 | -------------------------------------------------------------------------------- /test/acl/base-validation.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const acl = require('../../acl'); 3 | 4 | const PrivateResource = acl['private-resource']; 5 | const pr = new PrivateResource(); 6 | 7 | const req = { api: true, params: {} }; 8 | const moderator = { role: 'moderator', id: '2' }; 9 | const user = { role: 'user', id: '3' }; 10 | 11 | describe('General access control validation cases', () => { 12 | beforeEach(() => { 13 | req.query = {}; 14 | }); 15 | 16 | it('should not allow to provide ownerId when limited to own', () => { 17 | req.user = user; 18 | req.query.filter = JSON.stringify({ ownerId: moderator.id }); 19 | assert.throws(() => pr.getMany(req)); 20 | }); 21 | 22 | it('should throw if filter provided is not an object', () => { 23 | req.user = user; 24 | req.query.filter = 1; 25 | assert.throws(() => pr.getMany(req)); 26 | }); 27 | 28 | it('should throw if filter provided cannot be parsed', () => { 29 | req.user = user; 30 | req.query.filter = 'a'; 31 | assert.throws(() => pr.getMany(req)); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/acl/private-resources.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const acl = require('../../acl'); 3 | 4 | const PrivateResource = acl['private-resource']; 5 | const pr = new PrivateResource(); 6 | 7 | const req = { api: true, params: {} }; 8 | const moderator = { role: 'moderator', id: '2' }; 9 | const user = { role: 'user', id: '3' }; 10 | const guest = { role: 'guest' }; 11 | 12 | describe('Access control for private resources', () => { 13 | beforeEach(() => { 14 | req.query = {}; 15 | }); 16 | 17 | context('Get many', () => { 18 | it('should not allow guest to read', () => { 19 | req.user = guest; 20 | assert.throws(() => pr.getMany(req)); 21 | }); 22 | 23 | it('should limit user to read own', () => { 24 | req.user = user; 25 | assert.doesNotThrow(() => pr.getMany(req)); 26 | assert.equal(req.query.filter, JSON.stringify({ ownerId: user.id })); 27 | }); 28 | 29 | it('should allow moderator to read any', () => { 30 | req.user = moderator; 31 | assert.doesNotThrow(() => pr.getMany(req)); 32 | assert.equal(req.query.filter, undefined); 33 | }); 34 | }); 35 | 36 | context('Get resource', () => { 37 | it('should not allow guest to read', () => { 38 | req.user = guest; 39 | assert.throws(() => pr.getOne(req)); 40 | }); 41 | 42 | it('should limit user to read own', () => { 43 | req.user = user; 44 | assert.doesNotThrow(() => pr.getOne(req)); 45 | assert.equal(req.query.filter, JSON.stringify({ ownerId: user.id })); 46 | }); 47 | 48 | it('should allow moderator to read any', () => { 49 | req.user = moderator; 50 | assert.doesNotThrow(() => pr.getMany(req)); 51 | assert.equal(req.query.filter, undefined); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/acl/products.spec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const acl = require('../../acl'); 3 | 4 | const { products } = acl; 5 | 6 | describe('Access control for products', () => { 7 | let getMany; 8 | let create; 9 | let getOne; 10 | let updateOne; 11 | let deleteOne; 12 | let updateOwner; 13 | 14 | before(() => { 15 | getMany = sinon.stub(products, 'getMany'); 16 | create = sinon.stub(products, 'create'); 17 | getOne = sinon.stub(products, 'getOne'); 18 | updateOne = sinon.stub(products, 'updateOne'); 19 | deleteOne = sinon.stub(products, 'deleteOne'); 20 | updateOwner = sinon.stub(products, 'updateSystem'); 21 | }); 22 | 23 | after(() => { 24 | sinon.restore(); 25 | }); 26 | 27 | it('should map to a public resource', () => { 28 | products.getProducts(); 29 | products.createProduct(); 30 | products.getProduct(); 31 | products.updateProduct(); 32 | products.deleteProduct(); 33 | products.updateProductOwner(); 34 | sinon.assert.calledOnce(getMany); 35 | sinon.assert.calledOnce(create); 36 | sinon.assert.calledOnce(getOne); 37 | sinon.assert.calledOnce(updateOne); 38 | sinon.assert.calledOnce(deleteOne); 39 | sinon.assert.calledOnce(updateOwner); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/acl/profiles.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const acl = require('../../acl'); 3 | 4 | const { profiles } = acl; 5 | 6 | const req = { api: true, params: {}, query: {} }; 7 | const guest = { role: 'guest' }; 8 | 9 | describe('Access control for profiles', () => { 10 | context('Get profile', () => { 11 | it('should allow guest to read any', () => { 12 | req.user = guest; 13 | assert.doesNotThrow(() => profiles.getProfile(req)); 14 | assert.equal(req.query.filter, undefined); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/acl/public-resource.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const acl = require('../../acl'); 3 | 4 | const PublicResource = acl['public-resource']; 5 | const pr = new PublicResource(); 6 | 7 | const req = { api: true, params: {} }; 8 | const admin = { role: 'admin', id: '1' }; 9 | const moderator = { role: 'moderator', id: '2' }; 10 | const user = { role: 'user', id: '3' }; 11 | const guest = { role: 'guest' }; 12 | 13 | describe('Access control for public resources', () => { 14 | beforeEach(() => { 15 | req.query = {}; 16 | }); 17 | 18 | context('Get many', () => { 19 | it('should allow guest to read any', () => { 20 | req.user = guest; 21 | assert.doesNotThrow(() => pr.getMany(req)); 22 | assert.equal(req.query.filter, undefined); 23 | }); 24 | }); 25 | 26 | context('Create resource', () => { 27 | it('should not allow guest to create', () => { 28 | req.user = guest; 29 | assert.throws(() => pr.create(req)); 30 | }); 31 | 32 | it('should allow user to create', () => { 33 | req.user = user; 34 | assert.doesNotThrow(() => pr.create(req)); 35 | }); 36 | }); 37 | 38 | context('Get resource', () => { 39 | it('should allow guest to read any', () => { 40 | req.user = guest; 41 | assert.doesNotThrow(() => pr.getOne(req)); 42 | assert.equal(req.query.filter, undefined); 43 | }); 44 | }); 45 | 46 | context('Update resource', () => { 47 | it('should not allow guest to update', () => { 48 | req.user = guest; 49 | assert.throws(() => pr.updateOne(req)); 50 | }); 51 | 52 | it('should limit user to update own', () => { 53 | req.user = user; 54 | assert.doesNotThrow(() => pr.updateOne(req)); 55 | assert.equal(req.query.filter, JSON.stringify({ ownerId: user.id })); 56 | }); 57 | 58 | it('should limit moderator to update own', () => { 59 | req.user = moderator; 60 | assert.doesNotThrow(() => pr.updateOne(req)); 61 | assert.equal(req.query.filter, JSON.stringify({ ownerId: moderator.id })); 62 | }); 63 | 64 | it('should allow admin to update any', () => { 65 | req.user = admin; 66 | assert.doesNotThrow(() => pr.updateOne(req)); 67 | assert.equal(req.query.filter, undefined); 68 | }); 69 | }); 70 | 71 | context('Delete resource', () => { 72 | it('should not allow guest to delete', () => { 73 | req.user = guest; 74 | assert.throws(() => pr.deleteOne(req)); 75 | }); 76 | 77 | it('should limit user to delete own', () => { 78 | req.user = user; 79 | req.query = {}; 80 | assert.doesNotThrow(() => pr.deleteOne(req)); 81 | assert.equal(req.query.filter, JSON.stringify({ ownerId: user.id })); 82 | }); 83 | 84 | it('should limit moderator to delete own', () => { 85 | req.user = moderator; 86 | req.query = {}; 87 | assert.doesNotThrow(() => pr.deleteOne(req)); 88 | assert.equal(req.query.filter, JSON.stringify({ ownerId: moderator.id })); 89 | }); 90 | 91 | it('should not limit admin to delete any', () => { 92 | req.user = admin; 93 | req.query = {}; 94 | assert.doesNotThrow(() => pr.deleteOne(req)); 95 | assert.equal(req.query.filter, undefined); 96 | }); 97 | }); 98 | 99 | context('Update resource ownership', () => { 100 | it('should not allow moderator to update own', () => { 101 | req.user = moderator; 102 | assert.throws(() => pr.updateOwner(req)); 103 | }); 104 | 105 | it('should allow admin to update any', () => { 106 | req.user = admin; 107 | assert.doesNotThrow(() => pr.updateSystem(req)); 108 | assert.equal(req.query.filter, undefined); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/acl/tickets.spec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const acl = require('../../acl'); 3 | 4 | const { tickets } = acl; 5 | 6 | describe('Access control for tickets', () => { 7 | let getMany; 8 | let create; 9 | let getOne; 10 | let updateOne; 11 | let deleteOne; 12 | let updateOwner; 13 | 14 | before(() => { 15 | getMany = sinon.stub(tickets, 'getMany'); 16 | create = sinon.stub(tickets, 'create'); 17 | getOne = sinon.stub(tickets, 'getOne'); 18 | updateOne = sinon.stub(tickets, 'updateOne'); 19 | deleteOne = sinon.stub(tickets, 'deleteOne'); 20 | updateOwner = sinon.stub(tickets, 'updateSystem'); 21 | }); 22 | 23 | after(() => { 24 | sinon.restore(); 25 | }); 26 | 27 | it('should map to a private resource', () => { 28 | tickets.getTickets(); 29 | tickets.createTicket(); 30 | tickets.getTicket(); 31 | tickets.updateTicket(); 32 | tickets.deleteTicket(); 33 | tickets.updateTicketOwner(); 34 | sinon.assert.calledOnce(getMany); 35 | sinon.assert.calledOnce(create); 36 | sinon.assert.calledOnce(getOne); 37 | sinon.assert.calledOnce(updateOne); 38 | sinon.assert.calledOnce(deleteOne); 39 | sinon.assert.calledOnce(updateOwner); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/acl/users.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const acl = require('../../acl'); 3 | 4 | const { users } = acl; 5 | 6 | const req = { api: true }; 7 | const admin = { role: 'admin', id: '1' }; 8 | const moderator = { role: 'moderator', id: '2' }; 9 | const user = { role: 'user', id: '3' }; 10 | 11 | describe('Access control for users', () => { 12 | beforeEach(() => { 13 | req.params = {}; 14 | }); 15 | 16 | context('Get user list', () => { 17 | it('should not allow user to read', () => { 18 | req.user = user; 19 | assert.throws(() => users.getUsers(req)); 20 | }); 21 | 22 | it('should allow moderator to read any', () => { 23 | req.user = moderator; 24 | assert.doesNotThrow(() => users.getUsers(req)); 25 | }); 26 | }); 27 | 28 | context('Create user', () => { 29 | it('should allow guest to create', () => { 30 | assert.equal(users.createUser, undefined); 31 | }); 32 | }); 33 | 34 | context('Get user', () => { 35 | it('should not allow user to read any', () => { 36 | req.user = user; 37 | req.params.id = moderator.id; 38 | assert.throws(() => users.getUser(req)); 39 | }); 40 | 41 | it('should allow user to read own', () => { 42 | req.user = user; 43 | req.params.id = user.id; 44 | assert.doesNotThrow(() => users.getUser(req)); 45 | }); 46 | 47 | it('should allow moderator to read any', () => { 48 | req.user = moderator; 49 | req.params.id = user.id; 50 | assert.doesNotThrow(() => users.getUser(req)); 51 | }); 52 | }); 53 | 54 | context('Update user', () => { 55 | it('should not allow moderator to update any', () => { 56 | req.user = moderator; 57 | req.params.id = user.id; 58 | assert.throws(() => users.updateUser(req)); 59 | }); 60 | 61 | it('should allow user to update own', () => { 62 | req.user = user; 63 | req.params.id = user.id; 64 | assert.doesNotThrow(() => users.updateUser(req)); 65 | }); 66 | 67 | it('should allow admin to update any', () => { 68 | req.user = admin; 69 | req.params.id = moderator.id; 70 | assert.doesNotThrow(() => users.updateUser(req)); 71 | }); 72 | }); 73 | 74 | context('Update user role', () => { 75 | it('should not allow moderator to update own', () => { 76 | req.user = moderator; 77 | req.params.id = req.user.id; 78 | assert.throws(() => users.updateUserRole(req)); 79 | }); 80 | 81 | it('should allow admin to update any', () => { 82 | req.user = admin; 83 | req.params.id = moderator.id; 84 | assert.doesNotThrow(() => users.updateUserRole(req)); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/handlers/auth.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const sinon = require('sinon'); 3 | const { auth } = require('../../handlers'); 4 | const user = require('../../models/user'); 5 | const jwt = require('../../lib/jwt'); 6 | const oauth = require('../../lib/oauth'); 7 | 8 | const req = { 9 | api: true, 10 | params: {}, 11 | body: {}, 12 | user: {}, 13 | }; 14 | const res = { json: () => {}, status: () => res, end: () => {} }; 15 | 16 | describe('Handler of authentication', () => { 17 | let getOneStub; 18 | let getOneByIdStub; 19 | let validatePasswordStub; 20 | let logoutStub; 21 | let signAccessTokenStub; 22 | let signRefreshTokenStub; 23 | let verifyTokenStub; 24 | let isProviderSupported; 25 | let validateWithProviderStub; 26 | let jsonStub; 27 | 28 | before(() => { 29 | getOneStub = sinon.stub(user, 'getOneByFilter'); 30 | getOneByIdStub = sinon.stub(user, 'getOneById'); 31 | validatePasswordStub = sinon.stub(user, 'validatePassword'); 32 | logoutStub = sinon.stub(user, 'signOut'); 33 | signAccessTokenStub = sinon.stub(jwt, 'signAccessToken'); 34 | signRefreshTokenStub = sinon.stub(jwt, 'signRefreshToken'); 35 | verifyTokenStub = sinon.stub(jwt, 'verifyToken'); 36 | isProviderSupported = sinon.stub(oauth, 'isProviderSupported'); 37 | validateWithProviderStub = sinon.stub(oauth, 'validateWithProvider'); 38 | jsonStub = sinon.stub(res, 'json'); 39 | }); 40 | 41 | after(() => { 42 | sinon.restore(); 43 | }); 44 | 45 | context('Create access and refresh tokens from username and password', () => { 46 | it('should fail on invalid user', async () => { 47 | await assert.rejects(() => auth.signInWithCredentials(req, res)); 48 | }); 49 | 50 | it('should fail on invalid password', async () => { 51 | getOneStub.resolves({}); 52 | await assert.rejects(() => auth.signInWithCredentials(req, res)); 53 | }); 54 | 55 | it('should return access and refresh tokens on success', async () => { 56 | getOneStub.resolves({}); 57 | validatePasswordStub.resolves(true); 58 | signAccessTokenStub.resolves('ta'); 59 | signRefreshTokenStub.resolves('tr'); 60 | await assert.doesNotReject(() => auth.signInWithCredentials(req, res)); 61 | sinon.assert.calledWith(jsonStub, { access_token: 'ta', refresh_token: 'tr' }); 62 | }); 63 | 64 | it('should fail if cannot create tokens', async () => { 65 | signAccessTokenStub.rejects(Error('e')); 66 | await assert.rejects(() => auth.signInWithCredentials(req, res)); 67 | }); 68 | }); 69 | 70 | context('Delete token', () => { 71 | it('should always succeed', async () => { 72 | logoutStub.resolves(); 73 | await auth.signOut(req, res); 74 | await assert.doesNotReject(() => auth.signOut(req, res)); 75 | }); 76 | }); 77 | 78 | context('Create access and refresh tokens from refresh token', () => { 79 | it('should fail on invalid token', async () => { 80 | verifyTokenStub.rejects(Error('e')); 81 | await assert.rejects(() => auth.signInWithRefreshToken(req, res)); 82 | }); 83 | 84 | it('should fail on invalid user in token', async () => { 85 | verifyTokenStub.resolves({ id: '1' }); 86 | getOneByIdStub.resolves(); 87 | await assert.rejects(() => auth.signInWithRefreshToken(req, res)); 88 | }); 89 | 90 | it('should fail after logout', async () => { 91 | verifyTokenStub.resolves({ id: '1', iat: 1 }); 92 | getOneByIdStub.resolves({ signedOutAt: new Date() }); 93 | await assert.rejects(() => auth.signInWithRefreshToken(req, res)); 94 | }); 95 | 96 | it('should return access and refresh tokens on success', async () => { 97 | verifyTokenStub.resolves({ id: '1', iat: Date.now() * 2 }); 98 | getOneByIdStub.resolves({}); 99 | signAccessTokenStub.resolves('ta'); 100 | signRefreshTokenStub.resolves('tr'); 101 | jsonStub.reset(); 102 | await assert.doesNotReject(() => auth.signInWithRefreshToken(req, res)); 103 | sinon.assert.calledWith(jsonStub, { access_token: 'ta', refresh_token: 'tr' }); 104 | }); 105 | }); 106 | 107 | context('Create access and refresh tokens from oauth token', () => { 108 | it('should fail on unknown provider', async () => { 109 | isProviderSupported.returns(false); 110 | await assert.rejects(() => auth.signInWithOAuthToken(req, res)); 111 | }); 112 | 113 | it('should fail if token not valid by provider', async () => { 114 | isProviderSupported.returns(true); 115 | validateWithProviderStub.rejects(Error()); 116 | await assert.rejects(() => auth.signInWithOAuthToken(req, res)); 117 | }); 118 | 119 | it('should fail if user in token not found', async () => { 120 | validateWithProviderStub.resolves({}); 121 | getOneStub.resolves(); 122 | await assert.rejects(() => auth.signInWithOAuthToken(req, res)); 123 | }); 124 | 125 | it('should return access and refresh tokens on success', async () => { 126 | getOneStub.resolves({}); 127 | signAccessTokenStub.resolves('ta'); 128 | signRefreshTokenStub.resolves('tr'); 129 | jsonStub.reset(); 130 | await assert.doesNotReject(() => auth.signInWithOAuthToken(req, res)); 131 | sinon.assert.calledWith(jsonStub, { access_token: 'ta', refresh_token: 'tr' }); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /test/lib/mongodb.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const mongodb = require('../../lib/mongodb'); 3 | 4 | describe('MongoDB library', () => { 5 | context('Default connection', () => { 6 | it('should be exported by module', () => { 7 | assert(mongodb.driver); 8 | assert(mongodb.options); 9 | }); 10 | 11 | it('should be disconnected', () => { 12 | assert.equal(mongodb.isConnected, false); 13 | }); 14 | 15 | it('should allow to create new connections', () => { 16 | const con = mongodb.createNew(); 17 | assert(con.driver); 18 | assert(con.options); 19 | }); 20 | }); 21 | 22 | context('Disconnect', () => { 23 | it('should disconnect if connected', async () => { 24 | mongodb.connection = { close: () => {} }; 25 | mongodb.db = {}; 26 | assert.equal(mongodb.isConnected, true); 27 | await mongodb.disconnect(); 28 | assert.equal(mongodb.isConnected, false); 29 | }); 30 | 31 | it('should not try to disconnect if already disconnected', async () => { 32 | mongodb.db = 'db'; 33 | await mongodb.disconnect(); 34 | assert.equal(mongodb.db, 'db'); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/lib/redis.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const redis = require('../../lib/redis'); 3 | 4 | describe('Redis library', () => { 5 | context('Default connection', () => { 6 | it('should be exported by module', () => { 7 | assert(redis.driver); 8 | }); 9 | 10 | it('should be disconnected', () => { 11 | assert.equal(redis.isConnected, false); 12 | }); 13 | 14 | it('should allow to create new connections', () => { 15 | const con = redis.createNew(); 16 | assert(con.driver); 17 | }); 18 | }); 19 | 20 | context('Disconnect', () => { 21 | it('should disconnect if connected', async () => { 22 | redis.connection = { disconnect: () => {} }; 23 | redis.client = {}; 24 | redis.db = {}; 25 | assert.equal(redis.isConnected, true); 26 | await redis.disconnect(); 27 | assert.equal(redis.isConnected, false); 28 | }); 29 | 30 | it('should not try to disconnect if already disconnected', async () => { 31 | redis.db = 'db'; 32 | await redis.disconnect(); 33 | assert.equal(redis.db, 'db'); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | -------------------------------------------------------------------------------- /test/models/schemas.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const schemas = require('../../models/schemas'); 3 | 4 | describe('Schema of Models', () => { 5 | context('Get schemas', () => { 6 | it('should be defined for every model', () => { 7 | assert(schemas.users); 8 | assert(schemas.products); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/routes/error.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const request = require('supertest'); 3 | const express = require('express'); 4 | const Boom = require('@hapi/boom'); 5 | const sinon = require('sinon'); 6 | const logger = require('../../lib/logger'); 7 | const errorRouter = require('../../routes/error'); 8 | 9 | const app = express(); 10 | 11 | app.use(express.json()); 12 | app.use('/api', (req, res, next) => { 13 | req.api = true; 14 | next(); 15 | }); 16 | app.use('/web', (req, res, next) => { 17 | res.render = () => res.send('text'); 18 | next(); 19 | }); 20 | app.use((req, res, next) => { 21 | next(Boom[req.body.type]('message')); 22 | }); 23 | app.use(errorRouter); 24 | 25 | describe('Error router', () => { 26 | let loggerStub; 27 | 28 | before(() => { 29 | loggerStub = sinon.stub(logger, 'error'); 30 | }); 31 | 32 | after(() => { 33 | sinon.restore(); 34 | }); 35 | 36 | it('should give status 500 if not given', (done) => { 37 | request(app) 38 | .post('/api') 39 | .expect(500, done); 40 | }); 41 | 42 | it('should not change status if given', (done) => { 43 | request(app) 44 | .post('/api') 45 | .send({ type: 'notFound' }) 46 | .expect(404, done); 47 | }); 48 | 49 | it('should return text when not an api call', (done) => { 50 | request(app) 51 | .post('/api') 52 | .send({ type: 'notFound' }) 53 | .expect(404, done); 54 | }); 55 | 56 | it('should not change status if given', (done) => { 57 | request(app) 58 | .post('/web') 59 | .send({ type: 'notFound' }) 60 | .expect(404, 'text', done); 61 | }); 62 | 63 | it('should only log stack once on server error', () => { 64 | assert(loggerStub.calledOnce); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/routes/web.spec.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const express = require('express'); 3 | const sinon = require('sinon'); 4 | const acl = require('../../lib/acl'); 5 | require('../../acl'); 6 | const router = require('../../routes/web'); 7 | const errorRouter = require('../../routes/error'); 8 | 9 | const app = express(); 10 | 11 | app.use((req, res, next) => { 12 | res.render = () => res.send('text'); 13 | req.user = { role: 'guest' }; 14 | next(); 15 | }); 16 | app.use(router); 17 | app.use(errorRouter); 18 | 19 | describe('Web router', () => { 20 | after(() => { 21 | sinon.restore(); 22 | }); 23 | 24 | it('should return home page', (done) => { 25 | request(app) 26 | .get('/') 27 | .expect(200, 'text', done); 28 | }); 29 | 30 | it('should return home page on local machine', (done) => { 31 | request(app) 32 | .get('/') 33 | .set('x-forwarded-for', '::1') 34 | .expect(200, 'text', done); 35 | }); 36 | 37 | it('should return pong on ping', (done) => { 38 | request(app) 39 | .get('/ping') 40 | .expect(200, 'pong', done); 41 | }); 42 | 43 | it('should return OK on health', (done) => { 44 | request(app) 45 | .get('/health') 46 | .expect(200, 'OK', done); 47 | }); 48 | 49 | it('should return 404 on non existing page', (done) => { 50 | request(app) 51 | .get('/a') 52 | .expect(404, 'text', done); 53 | }); 54 | 55 | it('should return 403 when no read permissions', (done) => { 56 | sinon.stub(acl, 'can').returns({ 57 | readAny: () => ({ granted: false }), 58 | }); 59 | request(app) 60 | .get('/') 61 | .expect(403, done); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /tools/db.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | const bcrypt = require('bcryptjs'); 3 | const { program } = require('commander'); 4 | const schemas = require('../models/schemas'); 5 | const mongo = require('../lib/mongodb'); 6 | 7 | const { log } = console; 8 | const { ObjectId } = mongo.driver; 9 | 10 | const defaultUserId = '6335515d0245a17258f96c69'; 11 | let user1Id; 12 | let user2Id; 13 | 14 | Promise.each = async (arr, fn) => { 15 | // eslint-disable-next-line no-restricted-syntax,no-await-in-loop 16 | for (const item of arr) await fn(item); 17 | }; 18 | 19 | async function connect() { 20 | if (!mongo.isConnected) await mongo.connect(); 21 | return mongo.db; 22 | } 23 | 24 | const commands = { 25 | async clean() { 26 | const db = await connect(); 27 | log('Deleting DB...'); 28 | return db.dropDatabase(); 29 | }, 30 | 31 | async init() { 32 | const db = await connect(); 33 | log('Creating collections with their schema and indexes...'); 34 | 35 | // Create collections and update schema 36 | return Promise.each(Object.keys(schemas), async (collectionName) => { 37 | log(` ${collectionName}`); 38 | await db.createCollection(collectionName); 39 | await db.command({ collMod: collectionName, validator: schemas[collectionName].schema }); 40 | return Promise.each(schemas[collectionName].indexes, async (index) => { 41 | await db.createIndex(collectionName, index.fields, index.options); 42 | }); 43 | }); 44 | }, 45 | 46 | async users() { 47 | const db = await connect(); 48 | log('Creating users...'); 49 | 50 | const users = db.collection('users'); 51 | 52 | async function createUser(username, role) { 53 | log(` ${username}`); 54 | let newItemId; 55 | 56 | const isExist = await users.countDocuments({ username }, { limit: 1 }); 57 | if (!isExist) { 58 | const item = { 59 | username, 60 | email: `${username}@example.com`, 61 | password: bcrypt.hashSync(username), 62 | role, 63 | createdAt: new Date(), 64 | updatedAt: new Date(), 65 | }; 66 | await users.insertOne(item); 67 | newItemId = item._id; 68 | } else { 69 | log('User already exists. Skipping'); 70 | } 71 | return newItemId; 72 | } 73 | 74 | await createUser('admin', 'admin'); 75 | await createUser('moderator', 'moderator'); 76 | user1Id = await createUser('user', 'user'); 77 | user2Id = await createUser('user2', 'user'); 78 | }, 79 | 80 | async products() { 81 | const db = await connect(); 82 | log('Creating products...'); 83 | 84 | const products = db.collection('products'); 85 | 86 | async function createProduct(title, price, ownerId) { 87 | log(` ${title}`); 88 | 89 | const isExist = await products.countDocuments({ title }, { limit: 1 }); 90 | if (!isExist) { 91 | await products.insertOne({ 92 | title, 93 | price, 94 | createdAt: new Date(), 95 | updatedAt: new Date(), 96 | ownerId: new ObjectId(ownerId || defaultUserId), 97 | }); 98 | } else { 99 | log('Product already exists. Skipping'); 100 | } 101 | } 102 | 103 | await createProduct('table', 10, user1Id); 104 | await createProduct('chair', 5, user1Id); 105 | await createProduct('picture', 2, user1Id); 106 | await createProduct('carpet', 3, user2Id); 107 | await createProduct('closet', 20, user2Id); 108 | }, 109 | 110 | async tickets() { 111 | const db = await connect(); 112 | log('Creating tickets...'); 113 | 114 | const tickets = db.collection('tickets'); 115 | 116 | async function createTicket(title, venue, price, ownerId) { 117 | log(` ${title}`); 118 | 119 | const isExist = await tickets.countDocuments({ title }, { limit: 1 }); 120 | if (!isExist) { 121 | await tickets.insertOne({ 122 | title, 123 | venue, 124 | price, 125 | createdAt: new Date(), 126 | updatedAt: new Date(), 127 | ownerId: new ObjectId(ownerId || defaultUserId), 128 | }); 129 | } else { 130 | log('Ticket already exists. Skipping'); 131 | } 132 | } 133 | 134 | await createTicket('ofra', 'expo', 300, user1Id); 135 | await createTicket('forever', 'haoman', 200, user1Id); 136 | await createTicket('luly', 'zizi', 80, user2Id); 137 | }, 138 | 139 | async all() { 140 | await commands.clean(); 141 | await commands.init(); 142 | await commands.users(); 143 | await commands.products(); 144 | await commands.tickets(); 145 | }, 146 | }; 147 | 148 | async function run(command) { 149 | try { 150 | await commands[command](); 151 | } catch (err) { 152 | log(`error: ${err.message}`); 153 | process.exit(1); 154 | } 155 | } 156 | 157 | function done() { 158 | log('Done!'); 159 | process.exit(); 160 | } 161 | 162 | // Command line options 163 | program 164 | .command('clean') 165 | .description('delete all data and DB') 166 | .action(() => { run('clean').then(done); }); 167 | 168 | program 169 | .command('init') 170 | .description('create collections with their schema') 171 | .action(() => { run('init').then(done); }); 172 | 173 | program 174 | .command('users') 175 | .description('create sample users in different roles') 176 | .action(() => { run('users').then(done); }); 177 | 178 | program 179 | .command('products') 180 | .description('create sample products') 181 | .action(() => { run('products').then(done); }); 182 | 183 | program 184 | .command('tickets') 185 | .description('create sample tickets') 186 | .action(() => { run('tickets').then(done); }); 187 | 188 | program 189 | .command('all') 190 | .description('clean, init and create sample users and products') 191 | .action(() => { run('all').then(done); }); 192 | 193 | program 194 | .parse(process.argv); 195 | 196 | // Display help whe no input 197 | if (process.argv.length <= 2) program.help(); 198 | -------------------------------------------------------------------------------- /views/emails/base.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta(http-equiv='Content-Type', content='text/html; charset=utf-8') 5 | 6 | body(bgcolor='#FFFFFF', leftmargin='0', topmargin='0', marginwidth='0', marginheight='0', style='font-family:Arial;') 7 | table(width='750', border='0', cellpadding='0', cellspacing='0') 8 | tr 9 | td(colspan='5') 10 | //- Replace link to server 11 | img(src='http://basejs.herokuapp.com/images/email/header.jpg', width='750', height='285', style='vertical-align:middle') 12 | 13 | tr 14 | td(width='64', bgcolor='#F3F3F3') 15 | td(width='20', bgcolor='#FFFFFF') 16 | td(width='598', bgcolor='#FFFFFF') 17 | block content 18 | block signature 19 | | Thanks, 20 | br 21 | | The base.js Team 22 | td(width='20', bgcolor='#FFFFFF') 23 | td(width='48', bgcolor='#F3F3F3') 24 | 25 | tr 26 | td(colspan='5') 27 | //- Replace link to server 28 | img(src='http://basejs.herokuapp.com/images/email/footer.jpg', width='750', height='176', style='vertical-align:middle') 29 | 30 | block footer 31 | -------------------------------------------------------------------------------- /views/emails/general.pug: -------------------------------------------------------------------------------- 1 | extends base 2 | 3 | block content 4 | | !{text} 5 | 6 | block signature 7 | -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= error.message 5 | h2 #{error.statusCode} (#{error.error}) 6 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1 basejs 5 | p Server is running 6 | p Client address: #{ip} 7 | a(href='/api-ui') API Documentation - OpenAPI 3 8 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(prefix="og: http://ogp.me/ns#") 3 | head 4 | title basejs 5 | meta(name="description" content="Node.js server starter project. More than a boilerplate, less than a framework.") 6 | meta(name="viewport" content="width=device-width, initial-scale=1") 7 | meta(name="og:image" content="http://basejs.herokuapp.com/images/logo_big.png") 8 | link(rel='stylesheet', href='/stylesheets/style.css') 9 | if config.analytics && config.analytics.client 10 | script(type='text/javascript'). 11 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 12 | (i[r].q=i[r].q || []).push(arguments)},i[r].l=1 * new Date();a=s.createElement(o), 13 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 14 | })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); 15 | ga('create', '#{config.analytics.ua}', 'auto'); 16 | ga('send', 'pageview'); 17 | body 18 | block content 19 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: "off" */ 2 | const http = require('http'); 3 | const logger = require('./lib/logger'); 4 | let { port } = require('./lib/options')('server'); 5 | require('./lib/logger-http'); 6 | const initServices = require('./lib/init'); 7 | 8 | async function initApp() { 9 | const app = require('./app'); 10 | await app.locals.isReady; 11 | return app; 12 | } 13 | 14 | function listen(app) { 15 | // Normalize a port into a number, string, or false 16 | function normalizePort(val) { 17 | const portNumber = parseInt(val, 10); 18 | 19 | if (Number.isNaN(portNumber)) { 20 | // named pipe 21 | return val; 22 | } 23 | 24 | if (portNumber > 0) { 25 | // port number 26 | return portNumber; 27 | } 28 | 29 | // invalid port 30 | return 0; 31 | } 32 | 33 | // Get port from configuration, throw if not given 34 | port = normalizePort(port); 35 | return new Promise((resolve, reject) => { 36 | if (!port) throw Error('Invalid port'); 37 | 38 | // Create server 39 | const server = http.createServer(app) 40 | 41 | // Listen to port 42 | .listen(port) 43 | 44 | // Event listener for server "error" event 45 | .on('error', (error) => { 46 | const bind = typeof port === 'string' 47 | ? `Pipe ${port}` 48 | : `Port ${port}`; 49 | 50 | // handle specific listen errors with friendly messages 51 | switch (error.code) { 52 | case 'EACCES': 53 | return reject(Error(`${bind} requires elevated privileges`)); 54 | case 'EADDRINUSE': 55 | return reject(Error(`${bind} is already in use`)); 56 | default: 57 | return reject(error); 58 | } 59 | }) 60 | 61 | // Event listener for server "listening" event 62 | .on('listening', () => { 63 | const addr = server.address(); 64 | const bind = typeof addr === 'string' 65 | ? `pipe ${addr}` 66 | : `port ${addr.port}`; 67 | logger.info(`Listening for http on ${bind}`); 68 | resolve(); 69 | }); 70 | }); 71 | } 72 | 73 | // Init app, exit on failure 74 | initServices() 75 | .then(initApp) 76 | .then(listen) 77 | .catch((err) => { 78 | logger.error(`Failed to start. ${err.message}`); 79 | process.exit(1); 80 | }); 81 | --------------------------------------------------------------------------------