├── app ├── client │ ├── styles │ │ ├── login.less │ │ ├── style.less │ │ └── base.less │ ├── public │ │ └── img │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon-96x96.png │ │ │ └── favicon-192x192.png │ ├── js │ │ ├── index.js │ │ ├── lib │ │ │ └── jquery.js │ │ ├── account │ │ │ ├── index.js │ │ │ └── pw-change.js │ │ ├── user.js │ │ └── login.js │ ├── login.html │ └── index.html ├── README.md ├── server │ ├── db.js │ ├── redis.js │ ├── config.js │ ├── index.js │ └── user.js ├── server.config.js ├── package.json └── webpack.config.js ├── .gitignore ├── preview.png ├── docker ├── node │ └── Dockerfile └── db │ ├── Dockerfile │ ├── data │ └── structure.sql │ └── my.cnf ├── .env ├── docker-compose.yml └── README.md /app/client/styles/login.less: -------------------------------------------------------------------------------- 1 | @import 'base.less'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /db/my.cnf 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/docker-node-app/HEAD/preview.png -------------------------------------------------------------------------------- /app/client/public/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/docker-node-app/HEAD/app/client/public/img/favicon.ico -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | Node App 2 | =================== 3 | 4 | Documentation needed (how server/client work together, what /public is for, etc) -------------------------------------------------------------------------------- /app/client/public/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/docker-node-app/HEAD/app/client/public/img/favicon-16x16.png -------------------------------------------------------------------------------- /app/client/public/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/docker-node-app/HEAD/app/client/public/img/favicon-32x32.png -------------------------------------------------------------------------------- /app/client/public/img/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/docker-node-app/HEAD/app/client/public/img/favicon-96x96.png -------------------------------------------------------------------------------- /app/client/public/img/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/docker-node-app/HEAD/app/client/public/img/favicon-192x192.png -------------------------------------------------------------------------------- /docker/node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | ENV NPM_CONFIG_LOGLEVEL info 4 | 5 | EXPOSE 80 6 | 7 | WORKDIR /home/app 8 | 9 | RUN npm install pm2 -g 10 | 11 | CMD ["npm", "start"] 12 | -------------------------------------------------------------------------------- /docker/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mysql/mysql-server:5.7 2 | 3 | # install `pv` (Pipe Viewer) for the sync script 4 | RUN yum install -y http://www.ivarch.com/programs/rpms/pv-1.6.6-1.x86_64.rpm 5 | 6 | WORKDIR /home -------------------------------------------------------------------------------- /app/server/db.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const DB = require('bui-server/db') 3 | const Model = require('bui-server/model') 4 | 5 | const db = new DB(config.mysql) 6 | 7 | Model.setDB(db) 8 | 9 | module.exports = db -------------------------------------------------------------------------------- /app/client/styles/style.less: -------------------------------------------------------------------------------- 1 | @import 'base.less'; 2 | 3 | body { 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | 10 | h1 { 11 | margin-bottom: 0em; 12 | } -------------------------------------------------------------------------------- /app/server/redis.js: -------------------------------------------------------------------------------- 1 | const redis = require("redis") 2 | 3 | let client = redis.createClient({ 4 | host: process.env.REDIS_HOST, 5 | port: 6379 6 | }) 7 | 8 | client.unref() 9 | client.on('error', console.log) 10 | 11 | module.exports = client -------------------------------------------------------------------------------- /app/server/config.js: -------------------------------------------------------------------------------- 1 | let env = process.env 2 | 3 | module.exports = { 4 | 5 | redis: { 6 | port: env.REDIS_PORT, 7 | host: env.REDIS_HOST, 8 | }, 9 | 10 | mysql: { 11 | host : env.MYSQL_HOST, 12 | user : env.MYSQL_USER, 13 | password : env.MYSQL_PW, 14 | database : env.MYSQL_DATABASE 15 | } 16 | } -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Change the port(s) if it conflicts with one of your existing services 2 | APP_PORT=8080 3 | DEBUG_PORT=9229 4 | MYSQL_PORT=3310 5 | SESSION_SECRET=alw2h8h42iuS&34nj2SE23rfa 6 | 7 | # You shouldn't need to change any of these, they are used inside the docker containers 8 | MYSQL_HOST=db 9 | MYSQL_USER=root 10 | MYSQL_PW=password 11 | MYSQL_DATABASE=nodeapp 12 | 13 | MYSQL_ROOT_HOST=% 14 | MYSQL_ROOT_PASSWORD=password 15 | 16 | REDIS_HOST=redis 17 | REDIS_PORT=6379 18 | 19 | USER=docker -------------------------------------------------------------------------------- /app/client/js/index.js: -------------------------------------------------------------------------------- 1 | import '../styles/style.less' 2 | 3 | import user from './user' 4 | import router from 'bui/router' 5 | import 'bui/helpers/backbone' 6 | import 'bui/helpers/lit-element' 7 | import 'form/backbone-ext' 8 | import 'bui/elements/btn' 9 | import './account' 10 | 11 | globalThis.goTo = (path,props)=>{ 12 | router.goTo(path, props) 13 | } 14 | 15 | globalThis.logout = ()=>{ 16 | user.logout() 17 | } 18 | 19 | router.start() 20 | 21 | window.addEventListener('DOMContentLoaded', e=>{ 22 | document.body.classList.add('show') 23 | }) -------------------------------------------------------------------------------- /app/server.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps : [{ 3 | name: "server", 4 | script: "./server/index.js", 5 | instances: 1, 6 | exec_mode: 'fork', 7 | watch: [ 8 | './server' 9 | ], 10 | ignore_watch: [ 11 | "./**/*.git", 12 | "./**/*.md" 13 | ], 14 | autorestart: true, 15 | restart_delay: 1000, 16 | node_args: '--inspect=0.0.0.0', 17 | env: { 18 | "NODE_ENV": "development", 19 | }, 20 | env_production : { 21 | "NODE_ENV": "production" 22 | } 23 | }] 24 | } -------------------------------------------------------------------------------- /docker/db/data/structure.sql: -------------------------------------------------------------------------------- 1 | 2 | USE nodeapp; 3 | 4 | SET @PREVIOUS_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS; 5 | SET FOREIGN_KEY_CHECKS = 0; 6 | 7 | DROP TABLE IF EXISTS `users`; 8 | 9 | CREATE TABLE `users` ( 10 | `id` int(11) NOT NULL AUTO_INCREMENT, 11 | `name` varchar(128) DEFAULT NULL, 12 | `email` varchar(128) DEFAULT NULL, 13 | `password` varchar(128) DEFAULT NULL, 14 | PRIMARY KEY (`id`) 15 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 16 | 17 | -- insert an example user 18 | INSERT INTO users (`name`, `email`, `password`) VALUES 19 | ('John Doe', 'test@example.com', '$2b$10$raorHGAz4mAFzZHqjchwJueVg6uu0P8w0aPveIH/LnGmugf862Qlu'); 20 | 21 | SET FOREIGN_KEY_CHECKS = @PREVIOUS_FOREIGN_KEY_CHECKS; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | container_name: nodeapp_db 6 | build: docker/db 7 | ports: 8 | - '${MYSQL_PORT}:3306' 9 | volumes: 10 | - ./docker/db/data:/docker-entrypoint-initdb.d 11 | - db_nodeapp_data:/var/lib/mysql 12 | - ./docker/db/my.cnf:/etc/my.cnf 13 | env_file: 14 | - ./.env 15 | node: 16 | container_name: nodeapp 17 | build: docker/node 18 | tty: true 19 | volumes: 20 | - ./app:/home/app 21 | env_file: 22 | - ./.env 23 | ports: 24 | - '${APP_PORT}:80' 25 | - '${DEBUG_PORT}:9229' 26 | redis: 27 | container_name: nodeapp_redis 28 | image: 'redis:latest' 29 | ports: 30 | - '${REDIS_PORT}:6379' 31 | volumes: 32 | db_nodeapp_data: -------------------------------------------------------------------------------- /app/client/js/lib/jquery.js: -------------------------------------------------------------------------------- 1 | /* 2 | Backbone imports jQuery and Webpack complains if it can't import 3 | jQuery. Here we are faking out the jQuery import (see alias in 4 | webpack.config.js) and then only implementing the absolute 5 | minimum that Backbone requires 6 | */ 7 | module.exports = { 8 | 9 | // Backbone.ajax calls $.ajax – create our own ajax 10 | ajax(opts) { 11 | fetch(opts.url, { 12 | method: opts.type, 13 | headers: { 14 | 'Content-Type': 'application/json' 15 | }, 16 | body: opts.data 17 | }).then(r=>r.json()) 18 | .then(resp=>{ 19 | opts.success(resp) 20 | }, err=>{ 21 | opts.error(null, err.message, err) 22 | }) 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /app/client/styles/base.less: -------------------------------------------------------------------------------- 1 | @import '~bui/helpers/colors.less'; 2 | 3 | :root { 4 | --theme: var(--blue); 5 | --formControlTheme: var(--theme); 6 | } 7 | 8 | html, body { 9 | min-height: 100%; 10 | background: var(--dark); 11 | } 12 | 13 | body { 14 | min-height: 100vh; 15 | margin: 0; 16 | padding: 0; 17 | font-family: Roboto, sans-serif; 18 | background: var(--gray-100); 19 | color: var(--gray-900); 20 | opacity: 0; 21 | visibility: hidden; 22 | transition: opacity 800ms cubic-bezier(0.4, 0, 0.2, 1); 23 | 24 | &.show { 25 | opacity: 1; 26 | visibility: visible; 27 | } 28 | 29 | -webkit-font-smoothing: antialiased; 30 | -moz-osx-font-smoothing: grayscale; 31 | font-smoothing: antialiased; 32 | text-rendering: optimizeLegibility; 33 | } -------------------------------------------------------------------------------- /app/client/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Node App 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docker/db/my.cnf: -------------------------------------------------------------------------------- 1 | # For advice on how to change settings please see 2 | # http://dev.mysql.com/doc/refman/5.7/en/server-configuration-defaults.html 3 | 4 | [mysqld] 5 | # 6 | # Remove leading # and set to the amount of RAM for the most important data 7 | # cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%. 8 | # innodb_buffer_pool_size = 128M 9 | # 10 | # Remove leading # to turn on a very important data integrity option: logging 11 | # changes to the binary log between backups. 12 | # log_bin 13 | # 14 | # Remove leading # to set options mainly useful for reporting servers. 15 | # The server defaults are faster for transactions and fast SELECTs. 16 | # Adjust sizes as needed, experiment to find the optimal values. 17 | # join_buffer_size = 128M 18 | # sort_buffer_size = 2M 19 | # read_rnd_buffer_size = 2M 20 | skip-host-cache 21 | skip-name-resolve 22 | datadir=/var/lib/mysql 23 | socket=/var/lib/mysql/mysql.sock 24 | secure-file-priv=/var/lib/mysql-files 25 | user=mysql 26 | 27 | # Disabling symbolic-links is recommended to prevent assorted security risks 28 | symbolic-links=0 29 | 30 | log-error=/var/log/mysqld.log 31 | pid-file=/var/run/mysqld/mysqld.pid 32 | sql-mode='NO_ENGINE_SUBSTITUTION' -------------------------------------------------------------------------------- /app/client/js/account/index.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element' 2 | import Panel from 'panel' 3 | import user from '../user' 4 | import PasswordChange from './pw-change' 5 | 6 | Panel.register('account', 'a-user-account', { 7 | title: 'Account', 8 | width: '500px', 9 | }) 10 | 11 | customElements.define('a-user-account', class extends LitElement{ 12 | 13 | static get styles(){return css` 14 | :host { 15 | display: grid; 16 | grid-template-rows: auto 1fr; 17 | overflow: hidden; 18 | position:relative; 19 | } 20 | 21 | main { 22 | overflow: auto; 23 | padding: 1em; 24 | } 25 | `} 26 | 27 | constructor(){ 28 | super() 29 | this.model = user 30 | } 31 | 32 | render(){return html` 33 | 34 |
35 | 36 |

${this.model.get('email')}

37 | 38 | Change Password 39 |
40 | `} 41 | 42 | changePassword(){ 43 | PasswordChange.open(this.model) 44 | } 45 | 46 | }) 47 | 48 | export default customElements.get('a-user-account') -------------------------------------------------------------------------------- /app/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Node App 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |

Your node app

22 |

Begin coding your app in: /client/js/index.js

23 | 24 |
25 | 26 |
27 | User Account 28 | Sign out 29 |
30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/client/js/user.js: -------------------------------------------------------------------------------- 1 | import store from 'bui/util/store' 2 | import {Model} from 'backbone' 3 | 4 | export class User extends Model { 5 | 6 | urlRoot(){ return '/api/user' } 7 | 8 | constructor(){ 9 | super() 10 | 11 | let cached = store('user') 12 | if( cached ) 13 | this.set(cached, {silent: true}) 14 | 15 | window.user = this // TEMP 16 | } 17 | 18 | get name(){ return this.get('name') } 19 | get email(){ return this.get('email') } 20 | 21 | get initials(){ 22 | return user.name.split(' ').map(s=>s[0]).slice(0,2).join('').toUpperCase() 23 | } 24 | 25 | logout(){ 26 | store('user', null) 27 | window.location = '/logout' 28 | } 29 | 30 | async login(password, email){ 31 | 32 | email = email || this.get('email') 33 | 34 | let formData = new FormData(); 35 | formData.append('email', email); 36 | formData.append('password', password); 37 | 38 | return fetch('/login', { 39 | method: 'post', 40 | credentials: 'include', 41 | body: formData 42 | }).then(r=>r.json()).then(resp=>{ 43 | 44 | if( resp && !resp.error ){ 45 | store('user', resp) 46 | } 47 | 48 | return resp 49 | }) 50 | } 51 | 52 | } 53 | 54 | // singleton logged in user 55 | export default new User() -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodeapp", 3 | "version": "1.0.0", 4 | "description": "Node App Template", 5 | "main": "client/index.js", 6 | "scripts": { 7 | "start": "npm install --quiet && npm run client-build && npm run server", 8 | "server": "pm2-runtime start server.config.js", 9 | "cd-server": "docker exec -it nodeapp bash", 10 | "install": "docker exec -it nodeapp npm install", 11 | "dev": "docker exec -it nodeapp npm run client", 12 | "client": "webpack --env dev --display errors-only --display-entrypoints --display-used-exports", 13 | "client-build": "webpack --env prod --display minimal" 14 | }, 15 | "author": "Kevin Jantzer", 16 | "license": "ISC", 17 | "dependencies": { 18 | "backbone": "^1.4.0", 19 | "bcrypt": "^3.0.6", 20 | "blackstone-ui": "git+https://github.com/kjantzer/bui.git", 21 | "bui-server": "git+https://github.com/kjantzer/bui-server.git", 22 | "body-parser": "^1.18.2", 23 | "connect-redis": "^4.0.3", 24 | "express": "^4.9.7", 25 | "express-fileupload": "^1.1.6-alpha.5", 26 | "express-session": "^1.17.0", 27 | "lit-element": "^2.2.1", 28 | "mysql": "^2.17.1", 29 | "passport": "^0.4.0", 30 | "passport-local": "^1.0.0", 31 | "redis": "^2.8.0" 32 | }, 33 | "devDependencies": { 34 | "css-loader": "^3.2.0", 35 | "less": "^3.10.3", 36 | "less-loader": "^5.0.0", 37 | "raw-loader": "^0.5.1", 38 | "style-loader": "^1.0.0", 39 | "webpack": "^4.20.2", 40 | "webpack-bundle-analyzer": "^3.5.2", 41 | "webpack-cli": "^3.1.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Node App 2 | ================ 3 | 4 | A starting point for node.js based apps. Uses Docker to setup a node.js, mysql, and redis server. Webpack is used to bundle the client JS. 5 | 6 | [![preview](./preview.png)](https://i.imgur.com/YajROul.mp4) 7 | 8 | 11 | 12 | ## Getting Started on Development 13 | 14 | ### Prerequisite 15 | You need [docker installed](https://docs.docker.com/compose/install/) 16 | 17 | ### 1.) Check the settings in `.env` 18 | You should change the `SESSION_SECRET` and update the app and mysql ports if they are going to conflict with any of your existing services 19 | 20 | ### 2.) Start the app containers 21 | A node.js server, mysql database, and redis service will be started. The node server will auto restart after crashing or when changes are made to `/app/server` 22 | 23 | ```bash 24 | $ cd /this/dir 25 | $ docker-compose up 26 | ``` 27 | 28 | If all goes as planned, you should see this message: 29 | 30 | ``` 31 | App running: localhost:8080 32 | ``` 33 | 34 | > port may be different if you canged it in `.env` 35 | 36 | ### 3.) Start the developing 37 | [Webpack](https://webpack.js.org/) will be used to watch the files in `/app/client` and rebuild them. 38 | 39 | ```bash 40 | $ cd app 41 | $ npm run dev 42 | ``` 43 | 44 | > The webpack bundler should be run from within the docker container. 45 | > The `dev` script is setup to do that 46 | 47 | ## Commands 48 | Some commands for developing 49 | 50 | - `docker-compose up` - start the app 51 | - `docker-compose down` - end the app 52 | - `npm run dev` - watch for client changes and rebuild 53 | - `npm run install` - install missing/new JS dependencies 54 | - `npm run cd-server` - "change directory" to the node server 55 | 56 | ## Dependency notes 57 | `jQuery` is imported by Backbone.js even though its not a hard dependency. Because of this, 58 | webpack complains if we dont have jQuery. To get around this, `client/jquery.js` was created 59 | that only includes the absolute minimum 60 | 61 | ## Code Report 62 | Keeping the client code slim and fast is important. An analyzer has been setup as part 63 | of the initial webpack build so we can analyze what code oversized. 64 | 65 | It can be accessed from the root: `localhost:8080/bundle-report.html` 66 | 67 | -------------------------------------------------------------------------------- /app/server/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const http = require('http') 3 | const express = require('express') 4 | const passport = require('passport') 5 | const LocalStrategy = require('passport-local').Strategy; 6 | 7 | const User = require('./user') 8 | const API = require('bui-server/api') 9 | 10 | const PORT = 80 11 | global.ROOT_PATH = path.join(__dirname, '..'); 12 | global.DIST_PATH = path.join(__dirname, '..', 'dist'); 13 | global.CLIENT_PATH = path.join(__dirname, '..', 'client'); 14 | global.SERVER_PATH = path.join(__dirname, '..', 'server'); 15 | 16 | const app = express(); 17 | 18 | passport.use(new LocalStrategy({ 19 | usernameField: 'email', 20 | }, async (email, password, done)=>{ 21 | User.login(email, password).then(user=>{ 22 | done(null, user) 23 | }, err=>{ 24 | done(err) 25 | }) 26 | })); 27 | 28 | passport.serializeUser(function(user, done) { 29 | done(null, user.id); 30 | }); 31 | 32 | passport.deserializeUser(async (id, done)=>{ 33 | User.deserializeUser(id).then(user=>{ 34 | done(null, user) 35 | }, err=>{ 36 | done(err) 37 | }) 38 | }); 39 | 40 | 41 | // serve up static files from /dist and /client/public directories 42 | app.use(express.static(DIST_PATH, {index: false})) 43 | app.use(express.static(CLIENT_PATH+'/public', {index: false})) 44 | 45 | // setup parsers for incoming data 46 | app.use(require('body-parser').json({limit: '50mb'})) 47 | app.use(require('express-fileupload')()); 48 | 49 | const session = require('express-session') 50 | let RedisStore = require('connect-redis')(session) 51 | let store = new RedisStore({client: require('./redis')}) 52 | 53 | app.use(session({ 54 | store: store, 55 | secret: process.env.SESSION_SECRET, 56 | resave: true, 57 | saveUninitialized: true 58 | })) 59 | 60 | app.use(passport.initialize()); 61 | app.use(passport.session()); 62 | 63 | // App Page 64 | app.get('/', (req, res)=>{ 65 | 66 | if( !req.isAuthenticated() ) 67 | return res.redirect('/login') 68 | 69 | res.sendFile(CLIENT_PATH+'/index.html') 70 | }) 71 | 72 | // Login Page 73 | app.get('/login', (req, res)=>{ 74 | 75 | if( req.isAuthenticated() ){ 76 | return res.redirect('/') 77 | } 78 | 79 | res.sendFile(CLIENT_PATH+'/login.html') 80 | }) 81 | 82 | // Logout Page, then redirect to login 83 | app.get('/logout', (req, res)=>{ 84 | req.logout() 85 | res.redirect('/login'); 86 | }) 87 | 88 | // AJAX Login 89 | app.post('/login', (req, res, next)=>{ 90 | passport.authenticate('local', (err, user, info)=>{ 91 | 92 | if(err) { 93 | return res.status(401).send({error:err.message, trace: err.trace}); 94 | } 95 | 96 | req.login(user, err=>{ 97 | if( err ) 98 | return res.status(500).send({error:err.message, trace: err.trace}); 99 | 100 | res.json(user); 101 | }) 102 | 103 | })(req, res, next); 104 | }); 105 | 106 | app.get('/hash-pw/:pw', async (req, res)=>{ 107 | let hash = await User.hashPassword(req.params.pw) 108 | res.send(hash) 109 | }) 110 | 111 | new API(app, [ 112 | User 113 | ], {root: '/api'}) 114 | 115 | // start up node server 116 | const server = http.Server(app); 117 | server.listen(PORT, function(){console.log('\nApp running: localhost:'+process.env.APP_PORT);}); 118 | -------------------------------------------------------------------------------- /app/server/user.js: -------------------------------------------------------------------------------- 1 | const db = require('./db') 2 | const bcrypt = require('bcrypt') 3 | var crypto = require("crypto"); 4 | 5 | const MIN_PW_LEN = 8 6 | const serializedUsers = new Map() 7 | 8 | module.exports = class User { 9 | 10 | static get api(){return { 11 | routes: [ 12 | // ['put', '/user/:id?', 'update'], 13 | // ['patch', '/user/:id?', 'update'], 14 | ['put', '/user/:id/change-password', 'changePassword'] 15 | ] 16 | }} 17 | 18 | constructor(attrs={}){ 19 | this.attrs = attrs 20 | 21 | this.attrs.email_hash = null 22 | 23 | if( attrs.email ){ 24 | this.attrs.email_hash = crypto.createHash('md5').update(attrs.email).digest("hex"); 25 | } 26 | } 27 | 28 | get id(){ return this.attrs.id } 29 | get email(){ return this.attrs.email } 30 | get name(){ return this.attrs.name } 31 | 32 | toJSON(){ 33 | let data = Object.assign({}, this.attrs) 34 | delete data.password 35 | return data 36 | } 37 | 38 | toString(){ 39 | return JSON.stringify(this.toJSON()) 40 | } 41 | 42 | async update(req){ 43 | 44 | let results = await db.q(`UPDATE users SET ? WHERE ?`, [ 45 | req.body, 46 | {id: req.user.id} 47 | ]) 48 | 49 | // TODO: improve how attrs are updated? 50 | req.user.attrs = Object.assign(req.user.attrs, req.body) 51 | 52 | return req.body 53 | } 54 | 55 | async verifyPassword(pw){ 56 | return bcrypt.compare(pw, this.attrs.password) 57 | } 58 | 59 | async changePassword(req){ 60 | 61 | let user = await User.findByID(req.user.id) 62 | let {currentPW, newPW} = req.body 63 | 64 | if( currentPW == newPW ) 65 | throw new Error('same password') 66 | 67 | if( !newPW || newPW.length < MIN_PW_LEN ) 68 | throw new Error('too short') 69 | 70 | if( !await user.verifyPassword(currentPW) ) 71 | throw new Error('invalid current password') 72 | 73 | newPW = await User.hashPassword(newPW) 74 | 75 | user.update({ 76 | body: {password: newPW}, 77 | user: user 78 | }) 79 | 80 | return true 81 | } 82 | 83 | static async encryptPassword(pw){ 84 | return bcrypt.hash(pw, 10) 85 | } 86 | 87 | static async findByID(id){ 88 | let resp = await db.q('SELECT * FROM users WHERE id = ?', id) 89 | return resp && new User(resp[0]) 90 | } 91 | 92 | static async findByEmail(email){ 93 | 94 | let resp = await db.q('SELECT * FROM users WHERE email = ?', email) 95 | 96 | if( !resp || resp.length == 0 ) 97 | throw Error('email not found') 98 | 99 | return new User(resp[0]) 100 | } 101 | 102 | static async login(email, password){ 103 | let user = await User.findByEmail(email) 104 | 105 | if( !await user.verifyPassword(password) ){ 106 | throw Error('password does not match') 107 | } 108 | 109 | return user 110 | } 111 | 112 | static deserializeUser(id){ 113 | if( !serializedUsers.get(id) ){ 114 | let user = User.findByID(id) 115 | if( user ) 116 | serializedUsers.set(id, user) 117 | } 118 | 119 | return serializedUsers.get(id) 120 | } 121 | 122 | static hashPassword(pw){ 123 | return pw ? bcrypt.hash(pw, 10) : null; 124 | } 125 | 126 | } -------------------------------------------------------------------------------- /app/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | Webpack Config 3 | 4 | This file tells webpack how to bundle our assets 5 | */ 6 | const path = require('path'); 7 | // const webpack = require('webpack'); 8 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 9 | 10 | /* 11 | NOTE: we may want to include a plugin to make the css build to a file separate from the JS 12 | */ 13 | 14 | module.exports = env => { const DEV = env == 'dev'; return { 15 | 16 | plugins: [ 17 | // to reduce JS size, leave out moment locales...we're not international...yet 18 | // new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 19 | 20 | // generate a report of the size of JS modules 21 | new BundleAnalyzerPlugin({ 22 | analyzerMode: DEV ? 'static' : 'disabled', 23 | reportFilename: 'bundle-report.html' 24 | }) 25 | ], 26 | 27 | // not sure if this matters yet, but webpack requires mode to be set 28 | mode: env=='prod' ? 'production' : 'development', 29 | 30 | // rebuild the files when they change 31 | watch: DEV, 32 | 33 | // create a source map on build 34 | devtool: 'source-map', 35 | 36 | // two entry points will be built, one for the main app and one for login 37 | entry: { 38 | index: './client/js/index.js', 39 | login: './client/js/login.js', 40 | }, 41 | 42 | // put the built files in /dist 43 | output: { 44 | filename: '[name].js', 45 | path: path.resolve(__dirname, 'dist'), 46 | }, 47 | 48 | // adjust how imports are resolved 49 | resolve: { 50 | 51 | // limit where modules should be loaded from 52 | modules: [ 53 | // prefer our node modules (and not nested node modules in /catalog) 54 | // needed to keep from duplicate lit-elements imported 55 | path.resolve(__dirname, 'node_modules'), 56 | "node_modules", 57 | ], 58 | 59 | // make aliases for modules so we can type less 60 | alias: { 61 | 'bui': 'blackstone-ui', 62 | 'form': 'bui/presenters/form-control', 63 | 'panel': 'bui/presenters/panel', 64 | 'tabs': 'bui/presenters/tabs', 65 | 'popover': 'bui/presenters/popover', 66 | 'dialog': 'bui/presenters/dialog', 67 | 'menu': 'bui/presenters/menu', 68 | 69 | // backbone requires jquery...use our own version 70 | 'jquery': path.resolve(__dirname, 'client/js/lib/jquery') 71 | } 72 | }, 73 | 74 | // inform webpack how to handle certain types of modules 75 | module: { 76 | 77 | rules: [ 78 | // load HTML/txt/svgs modules as raw text 79 | { 80 | test: /\.html$|\.txt$|\.svg$|\.md$|\.css$/, 81 | use: 'raw-loader' 82 | }, 83 | 84 | // load less files, compile to css, and append to DOM 85 | { 86 | test: /\.(less)$/, 87 | use: [ 88 | { 89 | loader: 'style-loader', // creates style nodes from JS strings 90 | }, 91 | { 92 | loader: 'css-loader', // translates CSS into CommonJS 93 | options: { 94 | sourceMap: true, 95 | }, 96 | }, 97 | { 98 | loader: 'less-loader', // compiles Less to CSS 99 | options: { 100 | sourceMap: true, 101 | }, 102 | }, 103 | ] 104 | }] 105 | } 106 | 107 | }} -------------------------------------------------------------------------------- /app/client/js/login.js: -------------------------------------------------------------------------------- 1 | import '../styles/login.less' 2 | 3 | import { LitElement, html, css } from 'lit-element' 4 | import 'bui/elements/paper' 5 | import 'bui/elements/btn' 6 | import 'form/form-control' 7 | import user from './user' 8 | 9 | customElements.define('a-login', class extends LitElement{ 10 | 11 | static get styles(){return css` 12 | :host { 13 | background: var(--dark-black); 14 | display: block; 15 | position:relative; 16 | height: 100vh; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | } 21 | 22 | b-paper { 23 | width: 400px; 24 | max-width: 96%; 25 | display: grid; 26 | position:relative; 27 | gap: 1em; 28 | } 29 | 30 | b-paper > img { 31 | height: 140px; 32 | margin: 0 auto -.5em; 33 | right: -20px; 34 | position: relative; 35 | } 36 | 37 | form-control { 38 | display: block; 39 | } 40 | 41 | h1 { 42 | text-align: center; 43 | margin: 0; 44 | } 45 | 46 | .error:empty { 47 | display: none; 48 | } 49 | 50 | .error { 51 | margin: 0; 52 | color: var(--red); 53 | } 54 | `} 55 | 56 | firstUpdated(){ 57 | document.body.classList.add('show') 58 | } 59 | 60 | render(){return html` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Sign In 73 |

74 | 75 |
76 | `} 77 | 78 | toggleSeePW(e){ 79 | let input = e.currentTarget.previousElementSibling 80 | input.type = input.type == 'text' ? 'password' : 'text' 81 | e.currentTarget.name = input.type == 'text' ? 'eye-1' : 'eye-off' 82 | } 83 | 84 | onKeydown(e){ 85 | if( e.key == 'Enter' ) 86 | e.currentTarget.parentElement.nextElementSibling.click() 87 | } 88 | 89 | login(e){ 90 | 91 | let btn = e.currentTarget 92 | 93 | // already logging in 94 | if( btn.spin ) return 95 | 96 | let email = this.shadowRoot.querySelector('#email') 97 | let pw = this.shadowRoot.querySelector('#password') 98 | 99 | if( email.isInvalid || !email.value || !pw.value ) 100 | return console.log('missing creds') 101 | 102 | btn.spin = true 103 | 104 | user.login(pw.value, email.value).then(resp=>{ 105 | 106 | btn.spin = false 107 | 108 | if( resp.error ){ 109 | btn.nextElementSibling.innerHTML = resp.error 110 | return 111 | } 112 | 113 | window.location = '/'+location.hash 114 | 115 | }, err=>{ 116 | btn.spin = false 117 | btn.nextElementSibling.innerHTML = 'Problem signing in' 118 | }) 119 | 120 | } 121 | 122 | }) 123 | 124 | export default customElements.get('a-login') -------------------------------------------------------------------------------- /app/client/js/account/pw-change.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element' 2 | import Panel from 'panel' 3 | import Dialog from 'dialog' 4 | import 'bui/elements/spinner-overlay' 5 | import 'form/form-handler' 6 | import 'form/form-control' 7 | 8 | const MIN_LEN = 8 9 | let View 10 | 11 | customElements.define('a-password-change', class extends LitElement{ 12 | 13 | static open(model){ 14 | 15 | // reuse the same view 16 | if( !View ) 17 | View = document.createElement('a-password-change') 18 | 19 | if( !model ) return; 20 | 21 | View.model = model 22 | 23 | new Panel(View, { 24 | title: 'Password', 25 | width: '360px', 26 | height: '420px', 27 | anchor: 'center', 28 | onClose: View.onClose.bind(View) 29 | }).open() 30 | } 31 | 32 | static get styles(){return css` 33 | :host { 34 | display: block; 35 | } 36 | 37 | form-handler { 38 | display: grid; 39 | position:relative; 40 | padding: 1em; 41 | /* gap: 1em; */ 42 | } 43 | `} 44 | 45 | render(){return html` 46 | 47 | 48 | Cancel 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Use at least ${MIN_LEN} characters 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 | Change Password 70 | 71 |
72 | `} 73 | 74 | changePassword(){ 75 | let currentPW = this.formHandler.get('pw_current').value 76 | let newPW = this.formHandler.get('pw_new').value 77 | let confirmPW = this.formHandler.get('pw_new_confirm').value 78 | 79 | if( currentPW != newPW 80 | && newPW.length >= MIN_LEN 81 | && newPW === confirmPW ){ 82 | this.spinner.show = true 83 | console.log('update pw', currentPW, newPW); 84 | 85 | fetch(this.model.url()+'/change-password', { 86 | method: 'PUT', 87 | headers: { 88 | 'Content-Type': 'application/json' 89 | }, 90 | body: JSON.stringify({currentPW, newPW}) 91 | }).then(r=>r.json()) 92 | .then(resp=>{ 93 | 94 | this.spinner.show = false 95 | 96 | if( resp.error ){ 97 | Dialog.error({title: 'Error', msg: resp.error}).modal() 98 | return 99 | } 100 | 101 | this.close() 102 | 103 | Dialog.success({msg: 'Password Changed'}).modal() 104 | }) 105 | } 106 | } 107 | 108 | validateConfirm(val, el){ 109 | if( !val )return true; 110 | let key = el.parentElement.key 111 | 112 | el.popover&&el.popover.close() 113 | 114 | let currentPW = this.formHandler.get('pw_current') 115 | let newPW = this.formHandler.get('pw_new') 116 | let confirmPW = this.formHandler.get('pw_new_confirm') 117 | 118 | if( key == 'pw_new' && val.length < MIN_LEN ){ 119 | // Dialog.error({msg: 'Too short', btns: false}).popover(el) 120 | return false 121 | } 122 | 123 | if( key == 'pw_new' && currentPW.value == newPW.value ){ 124 | // Dialog.error({msg: 'Same password as your current', btns: false}).popover(el) 125 | return false 126 | } 127 | 128 | if( key == 'pw_new_confirm' && newPW.value != confirmPW.value ){ 129 | // Dialog.error({msg: 'Not the same', btns: false}).popover(el) 130 | return false 131 | } 132 | } 133 | 134 | onClose(){ 135 | this.formHandler.editors.forEach(el=>el.value='') 136 | } 137 | 138 | close(){ 139 | this.model = null 140 | this.panel&&this.panel.close() 141 | } 142 | 143 | }) 144 | 145 | export default customElements.get('a-password-change') --------------------------------------------------------------------------------