├── .dependabot └── config.yml ├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── nodejs.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bin ├── migrate.js └── start.js ├── config.js ├── docker-compose.yaml ├── package-lock.json ├── package.json ├── server.js └── src ├── api ├── index.js ├── session.js └── user.js ├── middleware └── session-middleware.js ├── migrations └── 1550969025172-authentication.js ├── modules └── .gitkeep └── persistence ├── db.js ├── postgres-state-storage.js ├── sessions.js └── users.js /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "javascript" 4 | update_schedule: "daily" 5 | directory: "." 6 | default_assignees: 7 | - "HugoDF" 8 | automerged_updates: 9 | - match: 10 | dependency_type: "all" 11 | update_type: "all" 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | 13 | # The JSON files contain newlines inconsistently 14 | [*.json] 15 | insert_final_newline = ignore 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: HugoDF 2 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install & lint 21 | run: | 22 | npm ci 23 | npm run lint 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless/ 79 | 80 | # FuseBox cache 81 | .fusebox/ 82 | 83 | # DynamoDB Local files 84 | .dynamodb/ 85 | .DS_Store 86 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts 2 | 3 | WORKDIR /app 4 | COPY ./package*.json ./ 5 | RUN npm ci 6 | COPY . . 7 | RUN chown -R node:node /app 8 | USER node 9 | 10 | EXPOSE 3000 11 | CMD npm start 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2019 Hugo Di Francesco 3 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 21 | OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Express Postgres Starter 2 | 3 | > A starter project for Node.js with Express and Postgres 4 | 5 | ## Setup 6 | 7 | Pre-requisites: 8 | 9 | - Docker for Desktop 10 | 11 | Run `docker-compose up` in the root of the project. 12 | 13 | It will bring up Postgres and the Express application server in development mode. 14 | 15 | It binds the application server to `localhost:3000`, this can be re-mapped this by changing the first 3000 in `3000:3000` of [./docker-compose.yaml](./docker-compose.yaml)). 16 | 17 | Postgres is exposed on port `35432`. The connection string is `postgres://user:pass@localhost:35432/db` (username, password and database name are defined in [./docker-compose.yaml](./docker-compose.yaml)). 18 | 19 | You can connect to Postgres using the psql client: 20 | 21 | ```sh 22 | psql postgres://user:pass@localhost:35432/db 23 | ``` 24 | 25 | The default Docker `CMD` is `npm start`, [./docker-compose.yaml](./docker-compose.yaml) overrides this to `npm run dev` which runs the application using nodemon (auto-restart on file change). 26 | 27 | 28 | ## Express API setup 29 | 30 | The Express API is located in [./src/api](./src/api). 31 | 32 | Applications routes for resources are defined in [./src/api/index.js](./src/api/index.js). 33 | 34 | Global concerns like security, cookie parsing, body parsing and request logging are handled in [./server.js](./server.js). 35 | 36 | This application loosely follows the [Presentation Domain Data Layering](https://www.martinfowler.com/bliki/PresentationDomainDataLayering.html): 37 | 38 | - Presentation is dealt with in the `./src/api` folder 39 | - Domain is dealt with in the `./src/modules` folder. It's currently non-existent since we've only got generic user and session resources. 40 | - Data is dealt with in the `./src/persistence` folder 41 | 42 | ## Database setup + management 43 | 44 | `npm run migrate up` will run the migrations. 45 | 46 | `npm run migrate down` will roll back the migrations. 47 | 48 | `npm run migrate:create ` will create a new migration file in [./src/migrations](./src/migrations). 49 | 50 | To run the migrations inside of docker-compose. Which will run a bash instance inside the `app` container. 51 | ```sh 52 | docker-compose run app bash 53 | ``` 54 | 55 | Followed by: 56 | ```sh 57 | npm run migrate up 58 | ``` 59 | 60 | ## Session/Authentication management 61 | 62 | Session management is done through a custom sessions table, `/api/session` endpoints (see [./src/api/session.js](./src/api/session.js)) and leveraging [client-sessions](https://github.com/mozilla/node-client-sessions). 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /bin/migrate.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const migrate = require('migrate'); 3 | 4 | const stateStore = require('../src/persistence/postgres-state-storage'); 5 | 6 | const migrationsDirectory = path.resolve(__dirname, '../src/migrations'); 7 | 8 | const [command] = process.argv.slice(2); 9 | 10 | new Promise((resolve, reject) => { 11 | migrate.load( 12 | { 13 | stateStore, 14 | migrationsDirectory 15 | }, 16 | (err, set) => { 17 | if (err) { 18 | reject(err); 19 | } 20 | 21 | if (typeof set[command] !== 'function') { 22 | reject(new Error('Command is not a function')); 23 | } 24 | 25 | set[command]((err) => { 26 | if (err) reject(err); 27 | resolve(); 28 | }); 29 | } 30 | ); 31 | }) 32 | .then(() => { 33 | console.log(`migrations "${command}" successfully ran`); 34 | // eslint-disable-next-line unicorn/no-process-exit 35 | process.exit(0); 36 | }) 37 | .catch((error) => { 38 | console.error(error.stack); 39 | // eslint-disable-next-line unicorn/no-process-exit 40 | process.exit(1); 41 | }); 42 | -------------------------------------------------------------------------------- /bin/start.js: -------------------------------------------------------------------------------- 1 | const Server = require('../server'); 2 | 3 | Server.start(process.env.PORT); 4 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | SESSION_SECRET: process.env.SESSION_SECRET || 'super-secret' 3 | }; 4 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | version: "3" 3 | services: 4 | app: 5 | build: . 6 | depends_on: 7 | - postgres 8 | environment: 9 | DATABASE_URL: postgres://user:pass@postgres:5432/db 10 | NODE_ENV: development 11 | PORT: 3000 12 | ports: 13 | - "3000:3000" 14 | command: npm run dev 15 | volumes: 16 | - .:/app/ 17 | - /app/node_modules 18 | 19 | postgres: 20 | image: postgres:10.4 21 | ports: 22 | - "35432:5432" 23 | environment: 24 | POSTGRES_USER: user 25 | POSTGRES_PASSWORD: pass 26 | POSTGRES_DB: db 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-postgres-starter", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Starter project using Express and Postgres", 6 | "main": "config.js", 7 | "scripts": { 8 | "test": "xo", 9 | "lint": "xo", 10 | "format": "xo --fix", 11 | "start": "node ./bin/start.js", 12 | "dev": "nodemon ./bin/start.js", 13 | "migrate": "node ./bin/migrate.js", 14 | "migrate:create": "migrate create --migrations-dir='./src/migrations'" 15 | }, 16 | "keywords": [ 17 | "express", 18 | "postgres" 19 | ], 20 | "author": "Hugo Di Francesco", 21 | "license": "MIT", 22 | "dependencies": { 23 | "bcrypt": "^5.0.1", 24 | "client-sessions": "^0.8.0", 25 | "express": "^4.18.2", 26 | "helmet": "^4.6.0", 27 | "migrate": "^1.7.0", 28 | "morgan": "^1.10.0", 29 | "pg": "^8.7.1", 30 | "sql-template-strings": "^2.2.2", 31 | "uuid": "^8.3.2" 32 | }, 33 | "devDependencies": { 34 | "nodemon": "^2.0.12", 35 | "xo": "^0.36.1" 36 | }, 37 | "xo": { 38 | "prettier": true, 39 | "space": true 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/HugoDF/express-postgres-starter.git" 44 | }, 45 | "bugs": { 46 | "url": "https://github.com/HugoDF/express-postgres-starter/issues" 47 | }, 48 | "homepage": "https://github.com/HugoDF/express-postgres-starter#readme" 49 | } 50 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const morgan = require('morgan'); 4 | const clientSession = require('client-sessions'); 5 | const helmet = require('helmet'); 6 | 7 | const {SESSION_SECRET} = require('./config'); 8 | 9 | const app = express(); 10 | const api = require('./src/api'); 11 | 12 | app.get('/', (request, response) => response.sendStatus(200)); 13 | app.get('/health', (request, response) => response.sendStatus(200)); 14 | 15 | app.use(morgan('short')); 16 | app.use(express.json()); 17 | app.use( 18 | clientSession({ 19 | cookieName: 'session', 20 | secret: SESSION_SECRET, 21 | duration: 24 * 60 * 60 * 1000 22 | }) 23 | ); 24 | app.use(helmet()); 25 | 26 | app.use(api); 27 | 28 | let server; 29 | module.exports = { 30 | start(port) { 31 | server = app.listen(port, () => { 32 | console.log(`App started on port ${port}`); 33 | }); 34 | return app; 35 | }, 36 | stop() { 37 | server.close(); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const {Router} = express; 4 | const router = new Router(); 5 | 6 | const user = require('./user'); 7 | const session = require('./session'); 8 | 9 | router.use('/api/users', user); 10 | router.use('/api/sessions', session); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /src/api/session.js: -------------------------------------------------------------------------------- 1 | const {Router} = require('express'); 2 | const bcrypt = require('bcrypt'); 3 | 4 | const User = require('../persistence/users'); 5 | const Session = require('../persistence/sessions'); 6 | 7 | const sessionMiddleware = require('../middleware/session-middleware'); 8 | 9 | const router = new Router(); 10 | 11 | router.post('/', async (request, response) => { 12 | try { 13 | const {email, password} = request.body; 14 | // eslint-disable-next-line unicorn/no-fn-reference-in-iterator 15 | const user = await User.find(email); 16 | if (!user || !(await bcrypt.compare(password, user.password))) { 17 | return response.status(403).json({}); 18 | } 19 | 20 | const sessionId = await Session.create(user.id); 21 | request.session.id = sessionId; 22 | response.status(201).json(); 23 | } catch (error) { 24 | console.error( 25 | `POST session ({ email: ${request.body.email} }) >> ${error.stack})` 26 | ); 27 | response.status(500).json(); 28 | } 29 | }); 30 | 31 | router.get('/', sessionMiddleware, (request, response) => { 32 | response.json({userId: request.userId}); 33 | }); 34 | 35 | router.delete('/', async (request, response) => { 36 | try { 37 | if (request.session.id) { 38 | await Session.delete(request.session.id); 39 | } 40 | 41 | request.session.id = null; 42 | response.status(200).json(); 43 | } catch (error) { 44 | console.error(`DELETE session >> ${error.stack}`); 45 | response.status(500).json(); 46 | } 47 | }); 48 | 49 | module.exports = router; 50 | -------------------------------------------------------------------------------- /src/api/user.js: -------------------------------------------------------------------------------- 1 | const {Router} = require('express'); 2 | const User = require('../persistence/users'); 3 | 4 | const router = new Router(); 5 | 6 | router.post('/', async (request, response) => { 7 | try { 8 | const {email, password} = request.body; 9 | if (!email || !password) { 10 | return response 11 | .status(400) 12 | .json({message: 'email and password must be provided'}); 13 | } 14 | 15 | const user = await User.create(email, password); 16 | if (!user) { 17 | return response.status(400).json({message: 'User already exists'}); 18 | } 19 | 20 | return response.status(200).json(user); 21 | } catch (error) { 22 | console.error( 23 | `createUser({ email: ${request.body.email} }) >> Error: ${error.stack}` 24 | ); 25 | response.status(500).json(); 26 | } 27 | }); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /src/middleware/session-middleware.js: -------------------------------------------------------------------------------- 1 | const Session = require('../persistence/sessions'); 2 | 3 | const sessionMiddleware = async (request, response, next) => { 4 | if (!request.session.id) { 5 | return response.sendStatus(401); 6 | } 7 | 8 | try { 9 | // eslint-disable-next-line unicorn/no-fn-reference-in-iterator 10 | const session = await Session.find(request.session.id); 11 | if (!session) { 12 | request.session.id = null; 13 | return response.sendStatus(401); 14 | } 15 | 16 | request.userId = session.userId; 17 | next(); 18 | } catch (error) { 19 | console.error( 20 | `SessionMiddleware(${request.session.id}) >> Error: ${error.stack}` 21 | ); 22 | return response.sendStatus(500); 23 | } 24 | }; 25 | 26 | module.exports = sessionMiddleware; 27 | -------------------------------------------------------------------------------- /src/migrations/1550969025172-authentication.js: -------------------------------------------------------------------------------- 1 | const db = require('../persistence/db'); 2 | 3 | module.exports.up = async function (next) { 4 | const client = await db.connect(); 5 | 6 | await client.query(` 7 | CREATE TABLE IF NOT EXISTS users ( 8 | id uuid PRIMARY KEY, 9 | email text UNIQUE, 10 | password text 11 | ); 12 | 13 | CREATE TABLE IF NOT EXISTS sessions ( 14 | id uuid PRIMARY KEY, 15 | user_id uuid REFERENCES users (id) ON DELETE CASCADE 16 | ); 17 | `); 18 | 19 | await client.query(` 20 | CREATE INDEX users_email on users (email); 21 | 22 | CREATE INDEX sessions_user on sessions (user_id); 23 | `); 24 | 25 | await client.release(true); 26 | next(); 27 | }; 28 | 29 | module.exports.down = async function (next) { 30 | const client = await db.connect(); 31 | 32 | await client.query(` 33 | DROP TABLE sessions; 34 | DROP TABLE users; 35 | `); 36 | 37 | await client.release(true); 38 | next(); 39 | }; 40 | -------------------------------------------------------------------------------- /src/modules/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HugoDF/express-postgres-starter/35738ddc7c31bf20e963ec9b322a1dbd52861249/src/modules/.gitkeep -------------------------------------------------------------------------------- /src/persistence/db.js: -------------------------------------------------------------------------------- 1 | const {Pool} = require('pg'); 2 | 3 | module.exports = new Pool({ 4 | max: 10, 5 | connectionString: process.env.DATABASE_URL 6 | }); 7 | -------------------------------------------------------------------------------- /src/persistence/postgres-state-storage.js: -------------------------------------------------------------------------------- 1 | const sql = require('sql-template-strings'); 2 | const db = require('./db'); 3 | 4 | const ensureMigrationsTable = (db) => 5 | db.query( 6 | 'CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY, data jsonb NOT NULL)' 7 | ); 8 | 9 | const postgresStateStorage = { 10 | async load(fn) { 11 | await db.connect(); 12 | 13 | await ensureMigrationsTable(db); 14 | // Load the single row of migration data from the database 15 | const {rows} = await db.query('SELECT data FROM migrations'); 16 | 17 | if (rows.length !== 1) { 18 | console.log( 19 | 'Cannot read migrations from database. If this is the first time you run migrations, then this is normal.' 20 | ); 21 | 22 | return fn(null, {}); 23 | } 24 | 25 | // Call callback with new migration data object 26 | fn(null, rows[0].data); 27 | }, 28 | 29 | async save(set, fn) { 30 | await db.connect(); 31 | 32 | // Check if table 'migrations' exists and if not, create it. 33 | await ensureMigrationsTable(db); 34 | 35 | const migrationMetaData = { 36 | lastRun: set.lastRun, 37 | migrations: set.migrations 38 | }; 39 | 40 | await db.query(sql` 41 | INSERT INTO migrations (id, data) 42 | VALUES (1, ${migrationMetaData}) 43 | ON CONFLICT (id) DO UPDATE SET data = ${migrationMetaData} 44 | `); 45 | 46 | fn(); 47 | } 48 | }; 49 | 50 | module.exports = Object.assign(() => { 51 | return postgresStateStorage; 52 | }, postgresStateStorage); 53 | -------------------------------------------------------------------------------- /src/persistence/sessions.js: -------------------------------------------------------------------------------- 1 | const sql = require('sql-template-strings'); 2 | const {v4: uuidv4} = require('uuid'); 3 | const db = require('./db'); 4 | 5 | module.exports = { 6 | async create(userId) { 7 | const id = uuidv4(); 8 | await db.query(sql` 9 | INSERT INTO sessions (id, user_id) 10 | VALUES (${id}, ${userId}); 11 | `); 12 | return id; 13 | }, 14 | async find(id) { 15 | const {rows} = await db.query(sql` 16 | SELECT user_id FROM sessions WHERE id = ${id} LIMIT 1; 17 | `); 18 | if (rows.length !== 1) { 19 | return null; 20 | } 21 | 22 | const {user_id: userId} = rows[0]; 23 | return {userId}; 24 | }, 25 | async delete(id) { 26 | await db.query(sql` 27 | DELETE FROM sessions WHERE id = ${id}; 28 | `); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/persistence/users.js: -------------------------------------------------------------------------------- 1 | const sql = require('sql-template-strings'); 2 | const {v4: uuidv4} = require('uuid'); 3 | const bcrypt = require('bcrypt'); 4 | const db = require('./db'); 5 | 6 | module.exports = { 7 | async create(email, password) { 8 | try { 9 | const hashedPassword = await bcrypt.hash(password, 10); 10 | 11 | const {rows} = await db.query(sql` 12 | INSERT INTO users (id, email, password) 13 | VALUES (${uuidv4()}, ${email}, ${hashedPassword}) 14 | RETURNING id, email; 15 | `); 16 | 17 | const [user] = rows; 18 | return user; 19 | } catch (error) { 20 | if (error.constraint === 'users_email_key') { 21 | return null; 22 | } 23 | 24 | throw error; 25 | } 26 | }, 27 | async find(email) { 28 | const {rows} = await db.query(sql` 29 | SELECT * FROM users WHERE email=${email} LIMIT 1; 30 | `); 31 | return rows[0]; 32 | } 33 | }; 34 | --------------------------------------------------------------------------------