├── bin ├── setup ├── deploy-heroku.sh ├── build-branch.sh └── mkapplink.js ├── public ├── favicon.ico └── index.html ├── .babelrc ├── app ├── reducers │ ├── index.jsx │ └── auth.jsx ├── components │ ├── WhoAmI.jsx │ ├── Login.jsx │ ├── NotFound.jsx │ ├── Jokes.test.jsx │ ├── WhoAmI.test.jsx │ ├── Login.test.jsx │ └── Jokes.jsx ├── store.jsx └── main.jsx ├── .gitignore ├── db ├── models │ ├── favorite.js │ ├── thing.js │ ├── user.test.js │ ├── user.js │ ├── index.js │ └── oauth.js ├── index.js └── seed.js ├── server ├── api.js ├── auth.filters.js ├── users.js ├── users.test.js ├── auth.test.js ├── start.js └── auth.js ├── LICENSE ├── webpack.config.js ├── .eslintrc.js ├── index.js ├── dev.js ├── README.md └── package.json /bin/setup: -------------------------------------------------------------------------------- 1 | mkapplink.js -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/queerviolet/bones/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015", 5 | "stage-2" 6 | ] 7 | } -------------------------------------------------------------------------------- /app/reducers/index.jsx: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | 3 | const rootReducer = combineReducers({ 4 | auth: require('./auth').default, 5 | }) 6 | 7 | export default rootReducer 8 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all node_modules 2 | node_modules/* 3 | 4 | # ...except the symlink to ourselves. 5 | !node_modules/APP 6 | 7 | # Compiled JS 8 | public/bundle.js 9 | public/bundle.js.map 10 | 11 | # NPM errors 12 | npm-debug.log 13 | -------------------------------------------------------------------------------- /db/models/favorite.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {STRING} = require('sequelize') 4 | 5 | module.exports = db => db.define('favorites') 6 | 7 | module.exports.associations = (Favorite, {Thing, User}) => { 8 | Favorite.belongsTo(Thing) 9 | Favorite.belongsTo(User) 10 | } 11 | -------------------------------------------------------------------------------- /db/models/thing.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {STRING} = require('sequelize') 4 | 5 | module.exports = db => db.define('things', { 6 | name: STRING, 7 | }) 8 | 9 | module.exports.associations = (Thing, {User, Favorite}) => { 10 | Thing.belongsToMany(User, {as: 'lovers', through: Favorite}) 11 | } 12 | -------------------------------------------------------------------------------- /server/api.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const api = module.exports = require('express').Router() 4 | 5 | api 6 | .get('/heartbeat', (req, res) => res.send({ok: true})) 7 | .use('/auth', require('./auth')) 8 | .use('/users', require('./users')) 9 | 10 | // No routes matched? 404. 11 | api.use((req, res) => res.status(404).end()) 12 | -------------------------------------------------------------------------------- /bin/deploy-heroku.sh: -------------------------------------------------------------------------------- 1 | # By default, we git push our build branch to heroku master. 2 | # You can specify DEPLOY_REMOTE and DEPLOY_BRANCH to configure 3 | # this. 4 | deploy_remote="${DEPLOY_REMOTE:-heroku}" 5 | deploy_branch="${DEPLOY_BRANCH:-master}" 6 | 7 | deploy() { 8 | git push -f "$deploy_remote" "$branch_name:$deploy_branch" 9 | } 10 | 11 | . "$(dirname $0)/build-branch.sh" 12 | -------------------------------------------------------------------------------- /app/components/WhoAmI.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const WhoAmI = ({ user, logout }) => ( 4 |
5 | {user && user.name} 6 | 7 |
8 | ) 9 | 10 | import {logout} from 'APP/app/reducers/auth' 11 | import {connect} from 'react-redux' 12 | 13 | export default connect( 14 | ({ auth }) => ({ user: auth }), 15 | {logout}, 16 | )(WhoAmI) 17 | -------------------------------------------------------------------------------- /app/components/Login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const Login = ({ login }) => ( 4 |
{ 5 | evt.preventDefault() 6 | login(evt.target.username.value, evt.target.password.value) 7 | } }> 8 | 9 | 10 | 11 |
12 | ) 13 | 14 | import {login} from 'APP/app/reducers/auth' 15 | import {connect} from 'react-redux' 16 | 17 | export default connect( 18 | state => ({}), 19 | {login}, 20 | )(Login) 21 | -------------------------------------------------------------------------------- /app/store.jsx: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import { composeWithDevTools } from 'redux-devtools-extension' 3 | import rootReducer from './reducers' 4 | import createLogger from 'redux-logger' 5 | import thunkMiddleware from 'redux-thunk' 6 | 7 | import {whoami} from './reducers/auth' 8 | 9 | const store = createStore( 10 | rootReducer, 11 | composeWithDevTools( 12 | applyMiddleware( 13 | createLogger({collapsed: true}), 14 | thunkMiddleware 15 | ) 16 | ) 17 | ) 18 | 19 | export default store 20 | 21 | // Set the auth info at start 22 | store.dispatch(whoami()) 23 | -------------------------------------------------------------------------------- /server/auth.filters.js: -------------------------------------------------------------------------------- 1 | const mustBeLoggedIn = (req, res, next) => { 2 | if (!req.user) { 3 | return res.status(401).send('You must be logged in') 4 | } 5 | next() 6 | } 7 | 8 | const selfOnly = action => (req, res, next) => { 9 | if (req.params.id !== req.user.id) { 10 | return res.status(403).send(`You can only ${action} yourself.`) 11 | } 12 | next() 13 | } 14 | 15 | const forbidden = message => (req, res) => { 16 | res.status(403).send(message) 17 | } 18 | 19 | // Feel free to add more filters here (suggested: something that keeps out non-admins) 20 | 21 | module.exports = {mustBeLoggedIn, selfOnly, forbidden} 22 | -------------------------------------------------------------------------------- /app/components/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | 4 | const NotFound = props => { 5 | const {pathname} = props.location || {pathname: '<< no path >>'} 6 | console.error('NotFound: %s not found (%o)', pathname, props) 7 | return ( 8 |
9 |

Sorry, I couldn't find
{pathname}

10 |

The router gave me these props:

11 |
12 |         {JSON.stringify(props, null, 2)}
13 |       
14 |

Lost? Here's a way home.

15 | ~ xoxo, bones. 16 |
17 | ) 18 | } 19 | 20 | export default NotFound 21 | -------------------------------------------------------------------------------- /db/models/user.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | , {User} = db 5 | , {expect} = require('chai') 6 | 7 | /* global describe it before afterEach */ 8 | 9 | describe('User', () => { 10 | before('Await database sync', () => db.didSync) 11 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 12 | 13 | describe('authenticate(plaintext: String) ~> Boolean', () => { 14 | it('resolves true if the password matches', () => 15 | User.create({ password: 'ok' }) 16 | .then(user => user.authenticate('ok')) 17 | .then(result => expect(result).to.be.true)) 18 | 19 | it("resolves false if the password doesn't match", () => 20 | User.create({ password: 'ok' }) 21 | .then(user => user.authenticate('not ok')) 22 | .then(result => expect(result).to.be.false)) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /app/main.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | import React from 'react' 3 | import {Router, Route, IndexRedirect, browserHistory} from 'react-router' 4 | import {render} from 'react-dom' 5 | import {connect, Provider} from 'react-redux' 6 | 7 | import store from './store' 8 | import Jokes from './components/Jokes' 9 | import Login from './components/Login' 10 | import WhoAmI from './components/WhoAmI' 11 | import NotFound from './components/NotFound' 12 | 13 | const ExampleApp = connect( 14 | ({ auth }) => ({ user: auth }) 15 | )( 16 | ({ user, children }) => 17 |
18 | 21 | {children} 22 |
23 | ) 24 | 25 | render( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | , 35 | document.getElementById('main') 36 | ) 37 | -------------------------------------------------------------------------------- /app/reducers/auth.jsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const reducer = (state=null, action) => { 4 | switch (action.type) { 5 | case AUTHENTICATED: 6 | return action.user 7 | } 8 | return state 9 | } 10 | 11 | const AUTHENTICATED = 'AUTHENTICATED' 12 | export const authenticated = user => ({ 13 | type: AUTHENTICATED, user 14 | }) 15 | 16 | export const login = (username, password) => 17 | dispatch => 18 | axios.post('/api/auth/login/local', 19 | {username, password}) 20 | .then(() => dispatch(whoami())) 21 | .catch(() => dispatch(whoami())) 22 | 23 | export const logout = () => 24 | dispatch => 25 | axios.post('/api/auth/logout') 26 | .then(() => dispatch(whoami())) 27 | .catch(() => dispatch(whoami())) 28 | 29 | export const whoami = () => 30 | dispatch => 31 | axios.get('/api/auth/whoami') 32 | .then(response => { 33 | const user = response.data 34 | dispatch(authenticated(user)) 35 | }) 36 | .catch(failed => dispatch(authenticated(null))) 37 | 38 | export default reducer 39 | -------------------------------------------------------------------------------- /server/users.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | const User = db.model('users') 5 | 6 | const {mustBeLoggedIn, forbidden} = require('./auth.filters') 7 | 8 | module.exports = require('express').Router() 9 | .get('/', 10 | // The forbidden middleware will fail *all* requests to list users. 11 | // Remove it if you want to allow anyone to list all users on the site. 12 | // 13 | // If you want to only let admins list all the users, then you'll 14 | // have to add a role column to the users table to support 15 | // the concept of admin users. 16 | forbidden('listing users is not allowed'), 17 | (req, res, next) => 18 | User.findAll() 19 | .then(users => res.json(users)) 20 | .catch(next)) 21 | .post('/', 22 | (req, res, next) => 23 | User.create(req.body) 24 | .then(user => res.status(201).json(user)) 25 | .catch(next)) 26 | .get('/:id', 27 | mustBeLoggedIn, 28 | (req, res, next) => 29 | User.findById(req.params.id) 30 | .then(user => res.json(user)) 31 | .catch(next)) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017 Fullstack Academy of Code 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const LiveReloadPlugin = require('webpack-livereload-plugin') 4 | , devMode = require('.').isDevelopment 5 | 6 | /** 7 | * Fast source maps rebuild quickly during development, but only give a link 8 | * to the line where the error occurred. The stack trace will show the bundled 9 | * code, not the original code. Keep this on `false` for slower builds but 10 | * usable stack traces. Set to `true` if you want to speed up development. 11 | */ 12 | 13 | , USE_FAST_SOURCE_MAPS = false 14 | 15 | module.exports = { 16 | entry: './app/main.jsx', 17 | output: { 18 | path: __dirname, 19 | filename: './public/bundle.js' 20 | }, 21 | context: __dirname, 22 | devtool: devMode && USE_FAST_SOURCE_MAPS 23 | ? 'cheap-module-eval-source-map' 24 | : 'source-map', 25 | resolve: { 26 | extensions: ['.js', '.jsx', '.json', '*'] 27 | }, 28 | module: { 29 | rules: [{ 30 | test: /jsx?$/, 31 | exclude: /(node_modules|bower_components)/, 32 | use: [{ 33 | loader: 'babel-loader', 34 | options: { 35 | presets: ['react', 'es2015', 'stage-2'] 36 | } 37 | }] 38 | }] 39 | }, 40 | plugins: devMode 41 | ? [new LiveReloadPlugin({appendScriptTag: true})] 42 | : [] 43 | } 44 | -------------------------------------------------------------------------------- /app/components/Jokes.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chai, {expect} from 'chai' 3 | chai.use(require('chai-enzyme')()) 4 | 5 | import {shallow} from 'enzyme' 6 | 7 | import Jokes from './Jokes' 8 | 9 | /* global describe it beforeEach */ 10 | describe('', () => { 11 | const joke = { 12 | q: 'Why did the skeleton write tests?', 13 | a: 'To see if she did anything bone-headed.', 14 | } 15 | 16 | let root 17 | beforeEach('render the root', () => 18 | root = shallow() 19 | ) 20 | 21 | it('shows a joke', () => { 22 | root.setState({ joke, answered: false }) 23 | expect(root.find('h1')).to.have.length(1) 24 | expect(root.find('h1').text()).equal(joke.q) 25 | }) 26 | 27 | it("doesn't show the answer when state.answered=false", () => { 28 | root.setState({ joke, answered: false }) 29 | expect(root.find('h2')).to.have.length(0) 30 | }) 31 | 32 | it('shows the answer when state.answered=true', () => { 33 | root.setState({ joke, answered: true }) 34 | expect(root.find('h2')).to.have.length(1) 35 | expect(root.find('h2').text()).to.equal(joke.a) 36 | }) 37 | 38 | it('when tapped, sets state.answered=true', () => { 39 | root.setState({ joke, answered: false }) 40 | root.simulate('click') 41 | expect(root.state().answered).to.be.true 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /server/users.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest') 2 | , {expect} = require('chai') 3 | , db = require('APP/db') 4 | , app = require('./start') 5 | 6 | /* global describe it before afterEach */ 7 | 8 | describe('/api/users', () => { 9 | before('Await database sync', () => db.didSync) 10 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 11 | 12 | describe('GET /:id', () => 13 | describe('when not logged in', () => 14 | it('fails with a 401 (Unauthorized)', () => 15 | request(app) 16 | .get(`/api/users/1`) 17 | .expect(401) 18 | ))) 19 | 20 | describe('POST', () => 21 | describe('when not logged in', () => { 22 | it('creates a user', () => 23 | request(app) 24 | .post('/api/users') 25 | .send({ 26 | email: 'beth@secrets.org', 27 | password: '12345' 28 | }) 29 | .expect(201)) 30 | 31 | it('redirects to the user it just made', () => 32 | request(app) 33 | .post('/api/users') 34 | .send({ 35 | email: 'eve@interloper.com', 36 | password: '23456', 37 | }) 38 | .redirects(1) 39 | .then(res => expect(res.body).to.contain({ 40 | email: 'eve@interloper.com' 41 | }))) 42 | })) 43 | }) 44 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "eslint-config-standard", 3 | root: true, 4 | parser: "babel-eslint", 5 | parserOptions: { 6 | sourceType: "module", 7 | ecmaVersion: 8 8 | }, 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | plugins: ['react'], 13 | rules: { 14 | "space-before-function-paren": ["error", "never"], 15 | "prefer-const": "warn", 16 | "comma-dangle": ["error", "only-multiline"], 17 | "space-infix-ops": "off", // Until eslint #7489 lands 18 | "new-cap": "off", 19 | "no-unused-vars": ["error", { "varsIgnorePattern": "^_" }], 20 | "no-return-assign": "off", 21 | "no-unused-expressions": "off", 22 | "one-var": "off", 23 | "new-parens": "off", 24 | "indent": ["error", 2, {SwitchCase: 0}], 25 | "arrow-body-style": ["warn", "as-needed"], 26 | 27 | "no-unused-vars": "off", 28 | "react/jsx-uses-react": "error", 29 | "react/jsx-uses-vars": "error", 30 | "react/react-in-jsx-scope": "error", 31 | 32 | "import/first": "off", 33 | 34 | // This rule enforces a comma-first style, such as 35 | // npm uses. I think it's great, but it can look a bit weird, 36 | // so we're leaving it off for now (although stock Bones passes 37 | // the linter with it on). If you decide you want to enforce 38 | // this rule, change "off" to "error". 39 | "comma-style": ["off", "first", { 40 | exceptions: { 41 | ArrayExpression: true, 42 | ObjectExpression: true, 43 | } 44 | }], 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /app/components/WhoAmI.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chai, {expect} from 'chai' 3 | chai.use(require('chai-enzyme')()) 4 | import {shallow} from 'enzyme' 5 | import {spy} from 'sinon' 6 | chai.use(require('sinon-chai')) 7 | import {createStore} from 'redux' 8 | 9 | import WhoAmIContainer, {WhoAmI} from './WhoAmI' 10 | 11 | /* global describe it beforeEach */ 12 | describe('', () => { 13 | const user = { 14 | name: 'Dr. Bones', 15 | } 16 | const logout = spy() 17 | let root 18 | beforeEach('render the root', () => 19 | root = shallow() 20 | ) 21 | 22 | it('greets the user', () => { 23 | expect(root.text()).to.contain(user.name) 24 | }) 25 | 26 | it('has a logout button', () => { 27 | expect(root.find('button.logout')).to.have.length(1) 28 | }) 29 | 30 | it('calls props.logout when logout is tapped', () => { 31 | root.find('button.logout').simulate('click') 32 | expect(logout).to.have.been.called 33 | }) 34 | }) 35 | 36 | describe("'s connection", () => { 37 | const state = { 38 | auth: {name: 'Dr. Bones'} 39 | } 40 | 41 | let root, store, dispatch 42 | beforeEach('create store and render the root', () => { 43 | store = createStore(state => state, state) 44 | dispatch = spy(store, 'dispatch') 45 | root = shallow() 46 | }) 47 | 48 | it('gets prop.user from state.auth', () => { 49 | expect(root.find(WhoAmI)).to.have.prop('user').eql(state.auth) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /db/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // bcrypt docs: https://www.npmjs.com/package/bcrypt 4 | const bcrypt = require('bcryptjs') 5 | , {STRING, VIRTUAL} = require('sequelize') 6 | 7 | module.exports = db => db.define('users', { 8 | name: STRING, 9 | email: { 10 | type: STRING, 11 | validate: { 12 | isEmail: true, 13 | notEmpty: true, 14 | } 15 | }, 16 | 17 | // We support oauth, so users may or may not have passwords. 18 | password_digest: STRING, // This column stores the hashed password in the DB, via the beforeCreate/beforeUpdate hooks 19 | password: VIRTUAL // Note that this is a virtual, and not actually stored in DB 20 | }, { 21 | indexes: [{fields: ['email'], unique: true}], 22 | hooks: { 23 | beforeCreate: setEmailAndPassword, 24 | beforeUpdate: setEmailAndPassword, 25 | }, 26 | defaultScope: { 27 | attributes: {exclude: ['password_digest']} 28 | }, 29 | instanceMethods: { 30 | // This method is a Promisified bcrypt.compare 31 | authenticate(plaintext) { 32 | return bcrypt.compare(plaintext, this.password_digest) 33 | } 34 | } 35 | }) 36 | 37 | module.exports.associations = (User, {OAuth, Thing, Favorite}) => { 38 | User.hasOne(OAuth) 39 | User.belongsToMany(Thing, {as: 'favorites', through: Favorite}) 40 | } 41 | 42 | function setEmailAndPassword(user) { 43 | user.email = user.email && user.email.toLowerCase() 44 | if (!user.password) return Promise.resolve(user) 45 | 46 | return bcrypt.hash(user.get('password'), 10) 47 | .then(hash => user.set('password_digest', hash)) 48 | } 49 | -------------------------------------------------------------------------------- /bin/build-branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Paths to add to the deployment branch. 4 | # 5 | # These paths will be added with git add -f, to include build artifacts 6 | # we normally ignore in the branch we push to heroku. 7 | build_paths="public" 8 | 9 | # colors 10 | red='\033[0;31m' 11 | blue='\033[0;34m' 12 | off='\033[0m' 13 | 14 | echoed() { 15 | echo "${blue}${*}${off}" 16 | $* 17 | } 18 | 19 | if [[ $(git status --porcelain 2> /dev/null | grep -v '$\?\?' | tail -n1) != "" ]]; then 20 | echo "${red}Uncommitted changes would be lost. Commit or stash these changes:${off}" 21 | git status 22 | exit 1 23 | fi 24 | 25 | # Our branch name is build/commit-sha-hash 26 | version="$(git log -n1 --pretty=format:%H)" 27 | branch_name="build/${version}" 28 | 29 | 30 | function create_build_branch() { 31 | git branch "${branch_name}" 32 | git checkout "${branch_name}" 33 | return 0 34 | } 35 | 36 | function commit_build_artifacts() { 37 | # Add our build paths. -f means "even if it's in .gitignore'". 38 | git add -f "${build_paths}" 39 | 40 | # Commit the build artifacts on the branch. 41 | git commit -a -m "Built ${version} on $(date)." 42 | 43 | # Always succeed. 44 | return 0 45 | } 46 | 47 | # We expect to be sourced by some file that defines a deploy 48 | # function. If deploy() isn't defined, define a stub function. 49 | if [[ -z $(type -t deploy) ]]; then 50 | function deploy() { 51 | echo '(No deployment step defined.)' 52 | return 0 53 | } 54 | fi 55 | 56 | ( 57 | create_build_branch && 58 | echoed yarn && 59 | echoed npm run build && 60 | commit_build_artifacts && 61 | deploy 62 | 63 | # Regardless of whether we succeeded or failed, go back to 64 | # the previous branch. 65 | git checkout - 66 | ) 67 | -------------------------------------------------------------------------------- /app/components/Login.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import chai, {expect} from 'chai' 3 | chai.use(require('chai-enzyme')()) 4 | import {shallow} from 'enzyme' 5 | import {spy} from 'sinon' 6 | chai.use(require('sinon-chai')) 7 | 8 | import {Login} from './Login' 9 | 10 | /* global describe it beforeEach */ 11 | describe('', () => { 12 | let root 13 | beforeEach('render the root', () => 14 | root = shallow() 15 | ) 16 | 17 | it('shows a login form', () => { 18 | expect(root.find('input[name="username"]')).to.have.length(1) 19 | expect(root.find('input[name="password"]')).to.have.length(1) 20 | }) 21 | 22 | it('shows a password field', () => { 23 | const pw = root.find('input[name="password"]') 24 | expect(pw).to.have.length(1) 25 | expect(pw.at(0)).to.have.attr('type').equals('password') 26 | }) 27 | 28 | it('has a login button', () => { 29 | const submit = root.find('input[type="submit"]') 30 | expect(submit).to.have.length(1) 31 | }) 32 | 33 | describe('when submitted', () => { 34 | const login = spy() 35 | const root = shallow() 36 | const submitEvent = { 37 | preventDefault: spy(), 38 | target: { 39 | username: {value: 'bones@example.com'}, 40 | password: {value: '12345'}, 41 | } 42 | } 43 | 44 | beforeEach('submit', () => { 45 | login.reset() 46 | submitEvent.preventDefault.reset() 47 | root.simulate('submit', submitEvent) 48 | }) 49 | 50 | it('calls props.login with credentials', () => { 51 | expect(login).to.have.been.calledWith( 52 | submitEvent.target.username.value, 53 | submitEvent.target.password.value, 54 | ) 55 | }) 56 | 57 | it('calls preventDefault', () => { 58 | expect(submitEvent.preventDefault).to.have.been.called 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {resolve} = require('path') 4 | , chalk = require('chalk') 5 | , pkg = require('./package.json') 6 | , debug = require('debug')(`${pkg.name}:boot`) 7 | 8 | , nameError = 9 | `******************************************************************* 10 | You need to give your app a proper name. 11 | 12 | The package name 13 | 14 | ${pkg.name} 15 | 16 | isn't valid. If you don't change it, things won't work right. 17 | 18 | Please change it in ${__dirname}/package.json 19 | ~ xoxo, bones 20 | ********************************************************************` 21 | 22 | const reasonableName = /^[a-z0-9\-_]+$/ 23 | // RegExp.test docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test 24 | if (!reasonableName.test(pkg.name)) { 25 | console.error(chalk.red(nameError)) 26 | } 27 | 28 | // This will load a secrets file from 29 | // 30 | // ~/.your_app_name.env.js 31 | // or ~/.your_app_name.env.json 32 | // 33 | // and add it to the environment. 34 | // Note that this needs to be in your home directory, not the project's root directory 35 | const env = process.env 36 | , secretsFile = resolve(require('homedir')(), `.${pkg.name}.env`) 37 | 38 | try { 39 | Object.assign(env, require(secretsFile)) 40 | } catch (error) { 41 | debug('%s: %s', secretsFile, error.message) 42 | debug('%s: env file not found or invalid, moving on', secretsFile) 43 | } 44 | 45 | module.exports = { 46 | get name() { return pkg.name }, 47 | get isTesting() { return !!global.it }, 48 | get isProduction() { 49 | return env.NODE_ENV === 'production' 50 | }, 51 | get isDevelopment() { 52 | return env.NODE_ENV === 'development' 53 | }, 54 | get baseUrl() { 55 | return env.BASE_URL || `http://localhost:${module.exports.port}` 56 | }, 57 | get port() { 58 | return env.PORT || 1337 59 | }, 60 | get root() { 61 | return __dirname 62 | }, 63 | package: pkg, 64 | env, 65 | } 66 | -------------------------------------------------------------------------------- /bin/mkapplink.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | // 'bin/setup' is a symlink pointing to this file, which makes a 6 | // symlink in your project's main node_modules folder that points to 7 | // the root of your project's directory. 8 | 9 | const chalk = require('chalk') 10 | , fs = require('fs') 11 | , {resolve} = require('path') 12 | 13 | , appLink = resolve(__dirname, '..', 'node_modules', 'APP') 14 | 15 | , symlinkError = error => 16 | `******************************************************************* 17 | ${appLink} must point to '..' 18 | 19 | This symlink lets you require('APP/some/path') rather than 20 | ../../../some/path 21 | 22 | I tried to create it, but got this error: 23 | ${error.message} 24 | 25 | You might try this: 26 | 27 | rm ${appLink} 28 | 29 | Then run me again. 30 | 31 | ~ xoxo, bones 32 | ********************************************************************` 33 | 34 | function makeAppSymlink() { 35 | console.log(`Linking '${appLink}' to '..'`) 36 | try { 37 | // fs.unlinkSync docs: https://nodejs.org/api/fs.html#fs_fs_unlinksync_path 38 | try { fs.unlinkSync(appLink) } catch (swallowed) { } 39 | // fs.symlinkSync docs: https://nodejs.org/api/fs.html#fs_fs_symlinksync_target_path_type 40 | const linkType = process.platform === 'win32' ? 'junction' : 'dir' 41 | fs.symlinkSync('..', appLink, linkType) 42 | } catch (error) { 43 | console.error(chalk.red(symlinkError(error))) 44 | // process.exit docs: https://nodejs.org/api/process.html#process_process_exit_code 45 | process.exit(1) 46 | } 47 | console.log(`Ok, created ${appLink}`) 48 | } 49 | 50 | function ensureAppSymlink() { 51 | try { 52 | // readlinkSync docs: https://nodejs.org/api/fs.html#fs_fs_readlinksync_path_options 53 | const currently = fs.readlinkSync(appLink) 54 | if (currently !== '..') { 55 | throw new Error(`${appLink} is pointing to '${currently}' rather than '..'`) 56 | } 57 | } catch (error) { 58 | makeAppSymlink() 59 | } 60 | } 61 | 62 | if (module === require.main) { 63 | ensureAppSymlink() 64 | } 65 | -------------------------------------------------------------------------------- /db/models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Require our models. Running each module registers the model into sequelize 4 | // so any other part of the application could call sequelize.model('User') 5 | // to get access to the User model. 6 | 7 | const app = require('APP') 8 | , debug = require('debug')(`${app.name}:models`) 9 | // Our model files export functions that take a database and return 10 | // a model. We call these functions "meta models" (they are models of 11 | // models). 12 | // 13 | // This lets us avoid cyclic dependencies, which can be hard to reason 14 | // about. 15 | , metaModels = { 16 | OAuth: require('./oauth'), 17 | User: require('./user'), 18 | Thing: require('./thing'), 19 | Favorite: require('./favorite'), 20 | // ---------- Add new models here ---------- 21 | } 22 | , {mapValues} = require('lodash') 23 | 24 | module.exports = db => { 25 | // Create actual model classes by calling each meta model with the 26 | // database. 27 | const models = mapValues(metaModels, defineModel => defineModel(db)) 28 | 29 | /* 30 | At this point, all our models have been created. We just need to 31 | create the associations between them. 32 | 33 | We pass the responsibility for this onto the models themselves: 34 | If they export an `associations` method, we'll call it, passing 35 | in all the models that have been defined. 36 | 37 | This lets us move the association logic to the model files, 38 | so all the knowledge about the structure of each model remains 39 | self-contained. 40 | 41 | The Sequelize docs suggest a similar setup: 42 | 43 | https://github.com/sequelize/express-example#sequelize-setup 44 | */ 45 | Object.keys(metaModels) 46 | .forEach(name => { 47 | const {associations} = metaModels[name] 48 | if (typeof associations === 'function') { 49 | debug('associating model %s', name) 50 | // Metamodel::associations(self: Model, others: {[name: String]: Model}) -> () 51 | // 52 | // Associate self with others. 53 | associations.call(metaModels[name], models[name], models) 54 | } 55 | }) 56 | 57 | return models 58 | } 59 | -------------------------------------------------------------------------------- /dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Concurrently run our various dev tasks. 3 | * 4 | * Usage: node dev 5 | **/ 6 | 7 | const app = require('.') 8 | , chalk = require('chalk'), {bold} = chalk 9 | , {red, green, blue, cyan, yellow} = bold 10 | , dev = module.exports = () => run({ 11 | server: task(app.package.scripts['start-watch'], {color: blue}), 12 | build: task(app.package.scripts['build-watch'], {color: green}), 13 | lint: task(app.package.scripts['lint-watch'], {color: cyan}), 14 | test: task(app.package.scripts['test-watch'], {color: yellow}) 15 | }) 16 | 17 | const taskEnvironment = (path=require('path')) => { 18 | const env = {} 19 | for (const key in process.env) { 20 | env[key] = process.env[key] 21 | } 22 | Object.assign(env, { 23 | NODE_ENV: 'development', 24 | PATH: [ path.join(app.root, 'node_modules', '.bin') 25 | , process.env.PATH ].join(path.delimiter) 26 | }) 27 | return env 28 | } 29 | 30 | function run(tasks) { 31 | Object.keys(tasks) 32 | .map(name => tasks[name](name)) 33 | } 34 | 35 | function task(command, { 36 | spawn=require('child_process').spawn, 37 | path=require('path'), 38 | color 39 | }={}) { 40 | return name => { 41 | const stdout = log({name, color}, process.stdout) 42 | , stderr = log({name, color, text: red}, process.stderr) 43 | , proc = spawn(command, { 44 | shell: true, 45 | stdio: 'pipe', 46 | env: taskEnvironment(), 47 | }).on('error', stderr) 48 | .on('exit', (code, signal) => { 49 | stderr(`Exited with code ${code}`) 50 | if (signal) stderr(`Exited with signal ${signal}`) 51 | }) 52 | proc.stdout.on('data', stdout) 53 | proc.stderr.on('data', stderr) 54 | } 55 | } 56 | 57 | function log({ 58 | name, 59 | ts=timestamp, 60 | color=none, 61 | text=none, 62 | }, out=process.stdout) { 63 | return data => data.toString() 64 | // Strip out screen-clearing control sequences, which really 65 | // muck up the output. 66 | .replace('\u001b[2J', '') 67 | .replace('\u001b[1;3H', '') 68 | .split('\n') 69 | .forEach(line => out.write(`${color(`${ts()} ${name} \t⎹ `)}${text(line)}\n`)) 70 | } 71 | 72 | const dateformat = require('dateformat') 73 | function timestamp() { 74 | return dateformat('yyyy-mm-dd HH:MM:ss (Z)') 75 | } 76 | 77 | function none(x) { return x } 78 | 79 | if (module === require.main) { dev() } 80 | -------------------------------------------------------------------------------- /server/auth.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest') 2 | const {expect} = require('chai') 3 | const db = require('APP/db'), {User} = db 4 | const app = require('./start') 5 | 6 | const alice = { 7 | username: 'alice@secrets.org', 8 | password: '12345' 9 | } 10 | 11 | /* global describe it before afterEach beforeEach */ 12 | describe('/api/auth', () => { 13 | before('Await database sync', () => db.didSync) 14 | afterEach('Clear the tables', () => db.truncate({ cascade: true })) 15 | 16 | beforeEach('create a user', () => 17 | User.create({ 18 | email: alice.username, 19 | password: alice.password 20 | }) 21 | ) 22 | 23 | describe('POST /login/local (username, password)', () => { 24 | it('succeeds with a valid username and password', () => 25 | request(app) 26 | .post('/api/auth/login/local') 27 | .send(alice) 28 | .expect(302) 29 | .expect('Set-Cookie', /session=.*/) 30 | .expect('Location', '/') 31 | ) 32 | 33 | it('fails with an invalid username and password', () => 34 | request(app) 35 | .post('/api/auth/login/local') 36 | .send({username: alice.username, password: 'wrong'}) 37 | .expect(401) 38 | ) 39 | }) 40 | 41 | describe('GET /whoami', () => { 42 | describe('when not logged in', () => 43 | it('responds with an empty object', () => 44 | request(app).get('/api/auth/whoami') 45 | .expect(200) 46 | .then(res => expect(res.body).to.eql({})) 47 | )) 48 | 49 | describe('when logged in', () => { 50 | // supertest agents persist cookies 51 | const agent = request.agent(app) 52 | 53 | beforeEach('log in', () => agent 54 | .post('/api/auth/login/local') 55 | .send(alice)) 56 | 57 | it('responds with the currently logged in user', () => 58 | agent.get('/api/auth/whoami') 59 | .set('Accept', 'application/json') 60 | .expect(200) 61 | .then(res => expect(res.body).to.contain({ 62 | email: alice.username 63 | })) 64 | ) 65 | }) 66 | }) 67 | 68 | describe('POST /logout', () => 69 | describe('when logged in', () => { 70 | const agent = request.agent(app) 71 | 72 | beforeEach('log in', () => agent 73 | .post('/api/auth/login/local') 74 | .send(alice)) 75 | 76 | it('logs you out and redirects to whoami', () => agent 77 | .post('/api/auth/logout') 78 | .expect(302) 79 | .expect('Location', '/api/auth/whoami') 80 | .then(() => 81 | agent.get('/api/auth/whoami') 82 | .expect(200) 83 | .then(rsp => expect(rsp.body).eql({})) 84 | ) 85 | ) 86 | }) 87 | ) 88 | }) 89 | -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const app = require('APP') 3 | , debug = require('debug')(`${app.name}:db`) // DEBUG=your_app_name:db 4 | , chalk = require('chalk') 5 | , Sequelize = require('sequelize') 6 | 7 | , name = (app.env.DATABASE_NAME || app.name) + 8 | (app.isTesting ? '_test' : '') 9 | , url = app.env.DATABASE_URL || `postgres://localhost:5432/${name}` 10 | 11 | debug(chalk.yellow(`Opening database connection to ${url}`)) 12 | const db = module.exports = new Sequelize(url, { 13 | logging: require('debug')('sql'), // export DEBUG=sql in the environment to 14 | // get SQL queries 15 | define: { 16 | underscored: true, // use snake_case rather than camelCase column names. 17 | // these are easier to work with in psql. 18 | freezeTableName: true, // don't change table names from the one specified 19 | timestamps: true, // automatically include timestamp columns 20 | } 21 | }) 22 | 23 | // Initialize all our models and assign them as properties 24 | // on the database object. 25 | // 26 | // This lets us use destructuring to get at them like so: 27 | // 28 | // const {User, Product} = require('APP/db') 29 | // 30 | Object.assign(db, require('./models')(db), 31 | // We'll also make createAndSync available. It's sometimes useful in tests. 32 | {createAndSync}) 33 | 34 | // After defining all the models, sync the database. 35 | // Notice that didSync *is* a Promise, rather than being a function that returns 36 | // a Promise. It holds the state of this initial sync. 37 | db.didSync = db.createAndSync() 38 | 39 | // sync the db, creating it if necessary 40 | function createAndSync(force=app.isTesting, retries=0, maxRetries=5) { 41 | return db.sync({force}) 42 | .then(() => debug(`Synced models to db ${url}`)) 43 | .catch(fail => { 44 | // Don't do this auto-create nonsense in prod, or 45 | // if we've retried too many times. 46 | if (app.isProduction || retries > maxRetries) { 47 | console.error(chalk.red(`********** database error ***********`)) 48 | console.error(chalk.red(` Couldn't connect to ${url}`)) 49 | console.error() 50 | console.error(chalk.red(fail)) 51 | console.error(chalk.red(`*************************************`)) 52 | return 53 | } 54 | // Otherwise, do this autocreate nonsense 55 | debug(`${retries ? `[retry ${retries}]` : ''} Creating database ${name}...`) 56 | return new Promise(resolve => 57 | // 'child_process.exec' docs: https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback 58 | require('child_process').exec(`createdb "${name}"`, resolve) 59 | ).then(() => createAndSync(true, retries + 1)) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /db/models/oauth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const app = require('APP') 4 | , debug = require('debug')(`${app.name}:oauth`) 5 | , {STRING, JSON} = require('sequelize') 6 | 7 | module.exports = db => { 8 | const OAuth = db.define('oauths', { 9 | uid: STRING, 10 | provider: STRING, 11 | 12 | // OAuth v2 fields 13 | accessToken: STRING, 14 | refreshToken: STRING, 15 | 16 | // OAuth v1 fields 17 | token: STRING, 18 | tokenSecret: STRING, 19 | 20 | // The whole profile as JSON 21 | profileJson: JSON, 22 | }, { 23 | // Further reading on indexes: 24 | // 1. Sequelize and indexes: http://docs.sequelizejs.com/en/2.0/docs/models-definition/#indexes 25 | // 2. Postgres documentation: https://www.postgresql.org/docs/9.1/static/indexes.html 26 | indexes: [{fields: ['uid'], unique: true}], 27 | }) 28 | 29 | // OAuth.V2 is a default argument for the OAuth.setupStrategy method - it's our callback function that will execute when the user has successfully logged in 30 | OAuth.V2 = (accessToken, refreshToken, profile, done) => 31 | OAuth.findOrCreate({ 32 | where: { 33 | provider: profile.provider, 34 | uid: profile.id, 35 | } 36 | }) 37 | .spread(oauth => { 38 | debug(profile) 39 | debug('provider:%s will log in user:{name=%s uid=%s}', 40 | profile.provider, 41 | profile.displayName, 42 | profile.id 43 | ) 44 | oauth.profileJson = profile 45 | oauth.accessToken = accessToken 46 | 47 | // db.Promise.props is a Bluebird.js method; basically like "all" but for an object whose properties might contain promises. 48 | // Docs: http://bluebirdjs.com/docs/api/promise.props.html 49 | return db.Promise.props({ 50 | oauth, 51 | user: oauth.getUser(), 52 | _saveProfile: oauth.save(), 53 | }) 54 | }) 55 | .then(({ oauth, user }) => user || 56 | OAuth.User.create({ 57 | name: profile.displayName, 58 | }) 59 | .then(user => db.Promise.props({ 60 | user, 61 | _setOauthUser: oauth.setUser(user) 62 | })) 63 | .then(({user}) => user) 64 | ) 65 | .then(user => done(null, user)) 66 | .catch(done) 67 | 68 | // setupStrategy is a wrapper around passport.use, and is called in authentication routes in server/auth.js 69 | OAuth.setupStrategy = 70 | ({ 71 | provider, 72 | strategy, 73 | config, 74 | oauth=OAuth.V2, 75 | passport 76 | }) => { 77 | const undefinedKeys = Object.keys(config) 78 | .map(k => config[k]) 79 | .filter(value => typeof value === 'undefined') 80 | if (undefinedKeys.length) { 81 | for (const key in config) { 82 | if (!config[key]) debug('provider:%s: needs environment var %s', provider, key) 83 | } 84 | debug('provider:%s will not initialize', provider) 85 | return 86 | } 87 | 88 | debug('initializing provider:%s', provider) 89 | 90 | passport.use(new strategy(config, oauth)) 91 | } 92 | 93 | return OAuth 94 | } 95 | 96 | module.exports.associations = (OAuth, {User}) => { 97 | // Create a static association between the OAuth and User models. 98 | // This lets us refer to OAuth.User above, when we need to create 99 | // a user. 100 | OAuth.User = User 101 | OAuth.belongsTo(User) 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hi, I'm bones 2 | 3 | I'm a happy little skeleton. You can clone me to use as a starter on your projects! 4 | I have React, Redux, Sequelize, and Express all just rattling around in here ready 5 | to go. 6 | 7 | ## I need node >= 6.7.0 8 | 9 | If you don't have it, I'll complain and tell you how to install it. 10 | 11 | ## 1. Make me into something! 12 | 13 | We recommend that you **clone**, not fork, this repo – unless your intention is 14 | to develop Bones proper instead of using Bones as the starting point for your 15 | own application. 16 | 17 | Start by doing either of the following: 18 | 19 | * Create a GitHub repo and clone it, or 20 | * `git init` in an empty directory on your machine. 21 | 22 | After you have a repo on your machine: 23 | 24 | ```sh 25 | git remote add bones https://github.com/FullstackAcademy/bones.git 26 | git fetch bones 27 | git merge bones/master 28 | ``` 29 | 30 | And then you'll have me! If I change – which I probably will – you can get the most recent 31 | version by doing this again: 32 | 33 | ```sh 34 | git fetch bones 35 | git merge bones/master 36 | ``` 37 | 38 | ## 2. I need a name. 39 | 40 | I don't have a name. I think I used to have one, but it turned to dust right along with my 41 | heart and liver and pituitary gland and all that stuff. 42 | 43 | Anyway, I'll need one. Give me a name in `package.json`. 44 | 45 | ## 3. Start my dusty heart 46 | 47 | Short and sweet: 48 | 49 | ```sh 50 | npm install 51 | npm run dev 52 | ``` 53 | 54 | The `dev` script sets `NODE_ENV` to "development", runs the build script in watch mode, and 55 | starts the server with `nodemon`. Build vs server logs are separated by a prefix. If you prefer 56 | to run the server and build processes separately, you can instead do: 57 | 58 | ```sh 59 | npm run start-dev 60 | ``` 61 | 62 | ```sh 63 | npm run build-dev 64 | ``` 65 | 66 | In two separate terminals. The vanilla `npm start` is for production — you won't use it in development! 67 | 68 | ## My anatomy 69 | 70 | `/app` has the React/Redux setup. `main.jsx` is the entry point. 71 | 72 | `/db` has the Sequelize models and database setup. It'll create the database for you if it doesn't exist, 73 | assuming you're using postgres. 74 | 75 | `/server` has the Express server and routes. `start.js` is the entry point. 76 | 77 | `/bin` has scripts. (Right now it has *one* script that creates a useful symlink.) 78 | 79 | ## Conventions 80 | 81 | I use `require` and `module.exports` in `.js` files. 82 | 83 | I use `import` and `export` in `.jsx` files, unless `require` makes for cleaner code. 84 | 85 | I use two spaces, no semi-colons, and trailing commas where possible. I'll 86 | have a linter someday soon. 87 | 88 | ## Quick Heroku deployment 89 | 90 | 1. Set up the [Heroku command line tools](https://devcenter.heroku.com/articles/heroku-cli) and install [Yarn](https://yarnpkg.com/en/) if you haven't already (`npm install -g yarn`) 91 | 2. `heroku login` 92 | 3. Add a git remote for heroku: 93 | - **If you're creating a new app...** 94 | 1. `heroku create` or `heroku create your-app-name` if you have a name in mind. 95 | 2. `heroku addons:create heroku-postgresql:hobby-dev` to add postgres 96 | 3. `npm run deploy-heroku`. This will create a new branch and compile and commit your frontend JS to it, then push that branch to Heroku. 97 | 4. `heroku run npm run seed` to seed the database 98 | 99 | - **If you already have a Heroku app...** 100 | 1. `heroku git:remote your-app-name` You'll need to be a collaborator on the app. 101 | 102 | Afterwards, 103 | - *To deploy:* `npm run deploy-heroku` 104 | - *To re-seed:* `heroku run npm run seed` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__BONES_NEEDS_A_NAME__", 3 | "version": "0.0.1", 4 | "description": "A happy little skeleton.", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">= 7.0.0" 8 | }, 9 | "scripts": { 10 | "dev": "node dev", 11 | "validate": "check-node-version --node '>= 7.0.0'", 12 | "setup": "./bin/setup", 13 | "prep": "npm run validate && npm run setup", 14 | "postinstall": "npm run prep", 15 | "build": "webpack", 16 | "build-watch": "npm run build -- -w", 17 | "build-dev": "cross-env NODE_ENV=development npm run build-watch", 18 | "build-branch": "bin/build-branch.sh", 19 | "start": "node server/start.js", 20 | "start-watch": "nodemon server/start.js --watch server --watch db --watch index.js --watch package.json", 21 | "start-dev": "cross-env NODE_ENV=development npm run start-watch", 22 | "test": "mocha --compilers js:babel-register --watch-extensions js,jsx app/**/*.test.js app/**/*.test.jsx server/**/*.test.js db/**/*.test.js", 23 | "test-watch": "npm run test -- --watch --reporter=min", 24 | "seed": "node db/seed.js", 25 | "deploy-heroku": "bin/deploy-heroku.sh", 26 | "lint": "esw . --ignore-path .gitignore --ext '.js,.jsx'", 27 | "lint-watch": "npm run lint -- -w", 28 | "prepush": "npm run lint && npm run test" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/queerviolet/bones.git" 33 | }, 34 | "keywords": [ 35 | "react", 36 | "redux", 37 | "skeleton" 38 | ], 39 | "author": "Ashi Krishnan ", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/queerviolet/bones/issues" 43 | }, 44 | "homepage": "https://github.com/queerviolet/bones#readme", 45 | "dependencies": { 46 | "axios": "^0.15.2", 47 | "babel-preset-stage-2": "^6.18.0", 48 | "bcryptjs": "^2.4.0", 49 | "body-parser": "^1.15.2", 50 | "chai-enzyme": "^0.5.2", 51 | "chalk": "^1.1.3", 52 | "check-node-version": "^1.1.2", 53 | "concurrently": "^3.1.0", 54 | "cookie-session": "^2.0.0-alpha.1", 55 | "enzyme": "^2.5.1", 56 | "express": "^4.14.0", 57 | "finalhandler": "^1.0.0", 58 | "homedir": "^0.6.0", 59 | "passport": "^0.3.2", 60 | "passport-facebook": "^2.1.1", 61 | "passport-github2": "^0.1.10", 62 | "passport-google-oauth": "^1.0.0", 63 | "passport-local": "^1.0.0", 64 | "pg": "^6.1.0", 65 | "pretty-error": "^2.0.2", 66 | "react": "^15.3.2", 67 | "react-dom": "^15.3.2", 68 | "react-redux": "^4.4.5", 69 | "react-router": "^3.0.0", 70 | "redux": "^3.6.0", 71 | "redux-devtools-extension": "^2.13.0", 72 | "redux-logger": "^2.7.0", 73 | "redux-thunk": "^2.1.0", 74 | "sequelize": "^3.24.6", 75 | "sinon": "^1.17.6", 76 | "sinon-chai": "^2.8.0" 77 | }, 78 | "devDependencies": { 79 | "babel": "^6.5.2", 80 | "babel-core": "^6.18.0", 81 | "babel-eslint": "^7.2.2", 82 | "babel-loader": "^6.2.7", 83 | "babel-preset-es2015": "^6.18.0", 84 | "babel-preset-react": "^6.16.0", 85 | "chai": "^3.5.0", 86 | "cross-env": "^3.1.4", 87 | "dateformat": "^2.0.0", 88 | "eslint": "^3.19.0", 89 | "eslint-config-standard": "^10.2.1", 90 | "eslint-plugin-import": "^2.2.0", 91 | "eslint-plugin-node": "^4.2.2", 92 | "eslint-plugin-promise": "^3.5.0", 93 | "eslint-plugin-react": "^6.10.3", 94 | "eslint-plugin-standard": "^3.0.1", 95 | "eslint-watch": "^3.1.0", 96 | "husky": "^0.13.3", 97 | "mocha": "^3.1.2", 98 | "nodemon": "^1.11.0", 99 | "supertest": "^3.0.0", 100 | "volleyball": "^1.4.1", 101 | "webpack": "^2.2.1", 102 | "webpack-livereload-plugin": "^0.10.0" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /server/start.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const express = require('express') 5 | const bodyParser = require('body-parser') 6 | const {resolve} = require('path') 7 | const passport = require('passport') 8 | const PrettyError = require('pretty-error') 9 | const finalHandler = require('finalhandler') 10 | // PrettyError docs: https://www.npmjs.com/package/pretty-error 11 | 12 | // Bones has a symlink from node_modules/APP to the root of the app. 13 | // That means that we can require paths relative to the app root by 14 | // saying require('APP/whatever'). 15 | // 16 | // This next line requires our root index.js: 17 | const pkg = require('APP') 18 | 19 | const app = express() 20 | 21 | if (!pkg.isProduction && !pkg.isTesting) { 22 | // Logging middleware (dev only) 23 | app.use(require('volleyball')) 24 | } 25 | 26 | // Pretty error prints errors all pretty. 27 | const prettyError = new PrettyError 28 | 29 | // Skip events.js and http.js and similar core node files. 30 | prettyError.skipNodeFiles() 31 | 32 | // Skip all the trace lines about express' core and sub-modules. 33 | prettyError.skipPackage('express') 34 | 35 | module.exports = app 36 | // Session middleware - compared to express-session (which is what's used in the Auther workshop), cookie-session stores sessions in a cookie, rather than some other type of session store. 37 | // Cookie-session docs: https://www.npmjs.com/package/cookie-session 38 | .use(require('cookie-session')({ 39 | name: 'session', 40 | keys: [process.env.SESSION_SECRET || 'an insecure secret key'], 41 | })) 42 | 43 | // Body parsing middleware 44 | .use(bodyParser.urlencoded({ extended: true })) 45 | .use(bodyParser.json()) 46 | 47 | // Authentication middleware 48 | .use(passport.initialize()) 49 | .use(passport.session()) 50 | 51 | // Serve static files from ../public 52 | .use(express.static(resolve(__dirname, '..', 'public'))) 53 | 54 | // Serve our api - ./api also requires in ../db, which syncs with our database 55 | .use('/api', require('./api')) 56 | 57 | // any requests with an extension (.js, .css, etc.) turn into 404 58 | .use((req, res, next) => { 59 | if (path.extname(req.path).length) { 60 | const err = new Error('Not found') 61 | err.status = 404 62 | next(err) 63 | } else { 64 | next() 65 | } 66 | }) 67 | 68 | // Send index.html for anything else. 69 | .get('/*', (_, res) => res.sendFile(resolve(__dirname, '..', 'public', 'index.html'))) 70 | 71 | // Error middleware interceptor, delegates to same handler Express uses. 72 | // https://github.com/expressjs/express/blob/master/lib/application.js#L162 73 | // https://github.com/pillarjs/finalhandler/blob/master/index.js#L172 74 | .use((err, req, res, next) => { 75 | console.error(prettyError.render(err)) 76 | finalHandler(req, res)(err) 77 | }) 78 | 79 | if (module === require.main) { 80 | // Start listening only if we're the main module. 81 | // 82 | // https://nodejs.org/api/modules.html#modules_accessing_the_main_module 83 | const server = app.listen( 84 | pkg.port, 85 | () => { 86 | console.log(`--- Started HTTP Server for ${pkg.name} ---`) 87 | const { address, port } = server.address() 88 | const host = address === '::' ? 'localhost' : address 89 | const urlSafeHost = host.includes(':') ? `[${host}]` : host 90 | console.log(`Listening on http://${urlSafeHost}:${port}`) 91 | } 92 | ) 93 | } 94 | 95 | // This check on line 64 is only starting the server if this file is being run directly by Node, and not required by another file. 96 | // Bones does this for testing reasons. If we're running our app in development or production, we've run it directly from Node using 'npm start'. 97 | // If we're testing, then we don't actually want to start the server; 'module === require.main' will luckily be false in that case, because we would be requiring in this file in our tests rather than running it directly. 98 | -------------------------------------------------------------------------------- /db/seed.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('APP/db') 4 | , {User, Thing, Favorite, Promise} = db 5 | , {mapValues} = require('lodash') 6 | 7 | function seedEverything() { 8 | const seeded = { 9 | users: users(), 10 | things: things(), 11 | } 12 | 13 | seeded.favorites = favorites(seeded) 14 | 15 | return Promise.props(seeded) 16 | } 17 | 18 | const users = seed(User, { 19 | god: { 20 | email: 'god@example.com', 21 | name: 'So many names', 22 | password: '1234', 23 | }, 24 | barack: { 25 | name: 'Barack Obama', 26 | email: 'barack@example.gov', 27 | password: '1234' 28 | }, 29 | }) 30 | 31 | const things = seed(Thing, { 32 | surfing: {name: 'surfing'}, 33 | smiting: {name: 'smiting'}, 34 | puppies: {name: 'puppies'}, 35 | }) 36 | 37 | const favorites = seed(Favorite, 38 | // We're specifying a function here, rather than just a rows object. 39 | // Using a function lets us receive the previously-seeded rows (the seed 40 | // function does this wiring for us). 41 | // 42 | // This lets us reference previously-created rows in order to create the join 43 | // rows. We can reference them by the names we used above (which is why we used 44 | // Objects above, rather than just arrays). 45 | ({users, things}) => ({ 46 | // The easiest way to seed associations seems to be to just create rows 47 | // in the join table. 48 | 'obama loves surfing': { 49 | user_id: users.barack.id, // users.barack is an instance of the User model 50 | // that we created in the user seed above. 51 | // The seed function wires the promises so that it'll 52 | // have been created already. 53 | thing_id: things.surfing.id // Same thing for things. 54 | }, 55 | 'god is into smiting': { 56 | user_id: users.god.id, 57 | thing_id: things.smiting.id 58 | }, 59 | 'obama loves puppies': { 60 | user_id: users.barack.id, 61 | thing_id: things.puppies.id 62 | }, 63 | 'god loves puppies': { 64 | user_id: users.god.id, 65 | thing_id: things.puppies.id 66 | }, 67 | }) 68 | ) 69 | 70 | if (module === require.main) { 71 | db.didSync 72 | .then(() => db.sync({force: true})) 73 | .then(seedEverything) 74 | .finally(() => process.exit(0)) 75 | } 76 | 77 | class BadRow extends Error { 78 | constructor(key, row, error) { 79 | super(error) 80 | this.cause = error 81 | this.row = row 82 | this.key = key 83 | } 84 | 85 | toString() { 86 | return `[${this.key}] ${this.cause} while creating ${JSON.stringify(this.row, 0, 2)}` 87 | } 88 | } 89 | 90 | // seed(Model: Sequelize.Model, rows: Function|Object) -> 91 | // (others?: {...Function|Object}) -> Promise 92 | // 93 | // Takes a model and either an Object describing rows to insert, 94 | // or a function that when called, returns rows to insert. returns 95 | // a function that will seed the DB when called and resolve with 96 | // a Promise of the object of all seeded rows. 97 | // 98 | // The function form can be used to initialize rows that reference 99 | // other models. 100 | function seed(Model, rows) { 101 | return (others={}) => { 102 | if (typeof rows === 'function') { 103 | rows = Promise.props( 104 | mapValues(others, 105 | other => 106 | // Is other a function? If so, call it. Otherwise, leave it alone. 107 | typeof other === 'function' ? other() : other) 108 | ).then(rows) 109 | } 110 | 111 | return Promise.resolve(rows) 112 | .then(rows => Promise.props( 113 | Object.keys(rows) 114 | .map(key => { 115 | const row = rows[key] 116 | return { 117 | key, 118 | value: Promise.props(row) 119 | .then(row => Model.create(row) 120 | .catch(error => { throw new BadRow(key, row, error) }) 121 | ) 122 | } 123 | }).reduce( 124 | (all, one) => Object.assign({}, all, {[one.key]: one.value}), 125 | {} 126 | ) 127 | ) 128 | ) 129 | .then(seeded => { 130 | console.log(`Seeded ${Object.keys(seeded).length} ${Model.name} OK`) 131 | return seeded 132 | }).catch(error => { 133 | console.error(`Error seeding ${Model.name}: ${error} \n${error.stack}`) 134 | }) 135 | } 136 | } 137 | 138 | module.exports = Object.assign(seed, {users, things, favorites}) 139 | -------------------------------------------------------------------------------- /server/auth.js: -------------------------------------------------------------------------------- 1 | const app = require('APP'), {env} = app 2 | const debug = require('debug')(`${app.name}:auth`) 3 | const passport = require('passport') 4 | 5 | const {User, OAuth} = require('APP/db') 6 | const auth = require('express').Router() 7 | 8 | /************************* 9 | * Auth strategies 10 | * 11 | * The OAuth model knows how to configure Passport middleware. 12 | * To enable an auth strategy, ensure that the appropriate 13 | * environment variables are set. 14 | * 15 | * You can do it on the command line: 16 | * 17 | * FACEBOOK_CLIENT_ID=abcd FACEBOOK_CLIENT_SECRET=1234 npm run dev 18 | * 19 | * Or, better, you can create a ~/.$your_app_name.env.json file in 20 | * your home directory, and set them in there: 21 | * 22 | * { 23 | * FACEBOOK_CLIENT_ID: 'abcd', 24 | * FACEBOOK_CLIENT_SECRET: '1234', 25 | * } 26 | * 27 | * Concentrating your secrets this way will make it less likely that you 28 | * accidentally push them to Github, for example. 29 | * 30 | * When you deploy to production, you'll need to set up these environment 31 | * variables with your hosting provider. 32 | **/ 33 | 34 | // Facebook needs the FACEBOOK_CLIENT_ID and FACEBOOK_CLIENT_SECRET 35 | // environment variables. 36 | OAuth.setupStrategy({ 37 | provider: 'facebook', 38 | strategy: require('passport-facebook').Strategy, 39 | config: { 40 | clientID: env.FACEBOOK_CLIENT_ID, 41 | clientSecret: env.FACEBOOK_CLIENT_SECRET, 42 | callbackURL: `${app.baseUrl}/api/auth/login/facebook`, 43 | }, 44 | passport 45 | }) 46 | 47 | // Google needs the GOOGLE_CLIENT_SECRET AND GOOGLE_CLIENT_ID 48 | // environment variables. 49 | OAuth.setupStrategy({ 50 | provider: 'google', 51 | strategy: require('passport-google-oauth').OAuth2Strategy, 52 | config: { 53 | clientID: env.GOOGLE_CLIENT_ID, 54 | clientSecret: env.GOOGLE_CLIENT_SECRET, 55 | callbackURL: `${app.baseUrl}/api/auth/login/google`, 56 | }, 57 | passport 58 | }) 59 | 60 | // Github needs the GITHUB_CLIENT_ID AND GITHUB_CLIENT_SECRET 61 | // environment variables. 62 | OAuth.setupStrategy({ 63 | provider: 'github', 64 | strategy: require('passport-github2').Strategy, 65 | config: { 66 | clientID: env.GITHUB_CLIENT_ID, 67 | clientSecret: env.GITHUB_CLIENT_SECRET, 68 | callbackURL: `${app.baseUrl}/api/auth/login/github`, 69 | }, 70 | passport 71 | }) 72 | 73 | // Other passport configuration: 74 | // Passport review in the Week 6 Concept Review: 75 | // https://docs.google.com/document/d/1MHS7DzzXKZvR6MkL8VWdCxohFJHGgdms71XNLIET52Q/edit?usp=sharing 76 | passport.serializeUser((user, done) => { 77 | done(null, user.id) 78 | }) 79 | 80 | passport.deserializeUser( 81 | (id, done) => { 82 | debug('will deserialize user.id=%d', id) 83 | User.findById(id) 84 | .then(user => { 85 | if (!user) debug('deserialize retrieved null user for id=%d', id) 86 | else debug('deserialize did ok user.id=%d', id) 87 | done(null, user) 88 | }) 89 | .catch(err => { 90 | debug('deserialize did fail err=%s', err) 91 | done(err) 92 | }) 93 | } 94 | ) 95 | 96 | // require.('passport-local').Strategy => a function we can use as a constructor, that takes in a callback 97 | passport.use(new (require('passport-local').Strategy)( 98 | (email, password, done) => { 99 | debug('will authenticate user(email: "%s")', email) 100 | User.findOne({ 101 | where: {email}, 102 | attributes: {include: ['password_digest']} 103 | }) 104 | .then(user => { 105 | if (!user) { 106 | debug('authenticate user(email: "%s") did fail: no such user', email) 107 | return done(null, false, { message: 'Login incorrect' }) 108 | } 109 | return user.authenticate(password) 110 | .then(ok => { 111 | if (!ok) { 112 | debug('authenticate user(email: "%s") did fail: bad password') 113 | return done(null, false, { message: 'Login incorrect' }) 114 | } 115 | debug('authenticate user(email: "%s") did ok: user.id=%d', email, user.id) 116 | done(null, user) 117 | }) 118 | }) 119 | .catch(done) 120 | } 121 | )) 122 | 123 | auth.get('/whoami', (req, res) => res.send(req.user)) 124 | 125 | // POST requests for local login: 126 | auth.post('/login/local', passport.authenticate('local', {successRedirect: '/'})) 127 | 128 | // GET requests for OAuth login: 129 | // Register this route as a callback URL with OAuth provider 130 | auth.get('/login/:strategy', (req, res, next) => 131 | passport.authenticate(req.params.strategy, { 132 | scope: 'email', // You may want to ask for additional OAuth scopes. These are 133 | // provider specific, and let you access additional data (like 134 | // their friends or email), or perform actions on their behalf. 135 | successRedirect: '/', 136 | // Specify other config here 137 | })(req, res, next) 138 | ) 139 | 140 | auth.post('/logout', (req, res) => { 141 | req.logout() 142 | res.redirect('/api/auth/whoami') 143 | }) 144 | 145 | module.exports = auth 146 | -------------------------------------------------------------------------------- /app/components/Jokes.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | export default class BonesJokes extends Component { 4 | componentDidMount() { 5 | this.nextJoke() 6 | } 7 | 8 | nextJoke = () => 9 | this.setState({ 10 | joke: randomJoke(), 11 | answered: false, 12 | }) 13 | 14 | answer = () => 15 | this.setState({answered: true}) 16 | 17 | render() { 18 | if (!this.state) { return null } 19 | 20 | const {joke, answered} = this.state 21 | return ( 22 |
23 |

{joke.q}

24 | {answered &&

{joke.a}

} 25 | ~xoxo, bones 26 |
27 | ) 28 | } 29 | } 30 | 31 | function randomJoke() { 32 | return jokes[Math.floor(Math.random() * jokes.length)] 33 | } 34 | 35 | const jokes = `Q: Who won the skeleton beauty contest? 36 | A: No body 37 | Q: What do skeletons say before they begin dining? 38 | A: Bone appetit ! 39 | Q: When does a skeleton laugh? 40 | A: When something tickles his funny bone. 41 | Q: Why didn't the skeleton dance at the Halloween party? 42 | A: It had no body to dance with. 43 | Q: What type of art do skeletons like? 44 | A: Skull tures 45 | Q: What did the skeleton say when his brother told a lie? 46 | A: You can't fool me, I can see right through you. 47 | Q: What did the skeleton say while riding his Harley Davidson motorcycle? 48 | A: I'm bone to be wild! 49 | Q: Why didn't the skeleton dance at the party? 50 | A: He had no body to dance with. 51 | Q: What do you give a skeleton for valentine's day? 52 | A: Bone-bones in a heart shaped box. 53 | Q: Who was the most famous skeleton detective? 54 | A: Sherlock Bones. 55 | Q: Who was the most famous French skeleton? 56 | A: Napoleon bone-apart 57 | Q: What instrument do skeletons play? 58 | A: Trom-BONE. 59 | Q: What does a skeleton orders at a restaurant? 60 | A: Spare ribs!!! 61 | Q: When does a skeleton laugh? 62 | A: When something tickles his funny bone. 63 | Q: Why didn't the skeleton eat the cafeteria food? 64 | A: Because he didn't have the stomach for it! 65 | Q: Why couldn't the skeleton cross the road? 66 | A: He didn't have the guts. 67 | Q: Why are skeletons usually so calm ? 68 | A: Nothing gets under their skin ! 69 | Q: Why do skeletons hate winter? 70 | A: Beacuse the cold goes right through them ! 71 | Q: Why are graveyards so noisy ? 72 | A: Beacause of all the coffin ! 73 | Q: Why didn't the skeleton go to the party ? 74 | A: He had no body to go with ! 75 | Q: What happened when the skeletons rode pogo sticks ? 76 | A: They had a rattling good time ! 77 | Q: Why did the skeleton go to hospital ? 78 | A: To have his ghoul stones removed ! 79 | Q: How did the skeleton know it was going to rain ? 80 | A: He could feel it in his bones ! 81 | Q: What's a skeleton's favourite musical instrument ? 82 | A: A trom-bone ! 83 | Q: How do skeletons call their friends ? 84 | A: On the telebone ! 85 | Q: What do you call a skeleton who won't get up in the mornings ? 86 | A: Lazy bones ! 87 | Q: What do boney people use to get into their homes ? 88 | A: Skeleton keys ! 89 | Q: What do you call a skeleton who acts in Westerns ? 90 | A: Skint Eastwood ! 91 | Q: What happened to the boat that sank in the sea full of piranha fish ? 92 | A: It came back with a skeleton crew ! 93 | Q: What do you call a skeleton snake ? 94 | A: A rattler ! 95 | Q: What is a skeletons like to drink milk ? 96 | A: Milk - it's so good for the bones ! 97 | Q: Why did the skeleton stay out in the snow all night ? 98 | A: He was a numbskull ! 99 | Q: What do you call a stupid skeleton ? 100 | A: Bonehead ! 101 | Q: What happened to the skeleton who stayed by the fire too long ? 102 | A: He became bone dry ! 103 | Q: What happened to the lazy skeleton ? 104 | A: He was bone idle ! 105 | Q: Why did the skeleton pupil stay late at school ? 106 | A: He was boning up for his exams ! 107 | Q: What sort of soup do skeletons like ? 108 | A: One with plenty of body in it ! 109 | Q: Why did the skeleton run up a tree ? 110 | A: Because a dog was after his bones ! 111 | Q: What did the skeleton say to his girlfriend ? 112 | A: I love every bone in your body ! 113 | Q: Why wasn't the naughty skeleton afraid of the police ? 114 | A: Because he knew they couldn't pin anything on him ! 115 | Q: How do skeletons get their mail ? 116 | A: By bony express ! 117 | Q: Why don't skeletons play music in church ? 118 | A: They have no organs ! 119 | Q: What kind of plate does a skeleton eat off ? 120 | A: Bone china ! 121 | Q: Why do skeletons hate winter ? 122 | A: Because the wind just goes straight through them ! 123 | Q: What's a skeleton's favourite pop group ? 124 | A: Boney M ! 125 | Q: What do you do if you see a skeleton running across a road ? 126 | A: Jump out of your skin and join him ! 127 | Q: What did the old skeleton complain of ? 128 | A: Aching bones ! 129 | Q: What is a skeleton ? 130 | A: Somebody on a diet who forgot to say "when" ! 131 | Q: What happened to the skeleton that was attacked by a dog ? 132 | A: He ran off with some bones and didn't leave him with a leg to stand on ! 133 | Q: Why are skeletons so calm ? 134 | A: Because nothing gets under their skin ! 135 | Q: What do you call a skeleton that is always telling lies ? 136 | A: A boney phoney ! 137 | Q: Why didn't the skeleton want to play football ? 138 | A: Because his heart wasn't in it ! 139 | Q: What happened to the skeleton who went to a party ? 140 | A: All the others used him as a coat rack ! 141 | Q: What do you call a skeleton who presses the door bell ? 142 | A: A dead ringer ! 143 | Q: When does a skeleton laugh? 144 | A: When something tickles his funny bone. 145 | Q: How did skeletons send their letters in the old days? 146 | A: By bony express! 147 | Q: How do you make a skeleton laugh? 148 | A: Tickle his funny bone!` 149 | .split('\n') 150 | .reduce((all, row, i) => 151 | i % 2 === 0 152 | ? [...all, {q: row}] 153 | : [...all.slice(0, all.length - 1), Object.assign({a: row}, all[all.length - 1])], 154 | []) 155 | --------------------------------------------------------------------------------