Day 1 - BYU-CS260-Winter-2018/redbird GitHub Wiki
For Day 1, we will setup a basic application that includes:
- registering for an account
- logging in to an account
- posting tweets
- viewing a feed of tweets (just your own for now)
This will include a front end, back end, and database. We are building only a basic app that does not yet include following people. So all we can include in the twitter feed is the list of your own tweets. We'll do more later!
When we store a user account, we need to store their username and a password. However, we will not directly store a user's password in the database. That is a really bad idea! If anyone stole our database, they would have all of our users' passwords, and people often use the same password at many sites.
Instead, we will store a hash of the password and a salt (additional random data). For a good explanation of why we salt and hash passwords, see: Salted Password Hashing - Doing it Right.
We need to setup a Node project. We'll initialize the project using the Vue webpack template:
vue init webpack redbird
cd redbird
Be sure to say "yes" to Vue Router, but "no" to everything after that for code linting.
Install some packages we'll use:
npm install express body-parser mariasql knex bcrypt axios moment vuex
You can use mysql instead if you prefer.
We will use bcrypt to hash and salt passwords. We will use VueX for front end state. We will use moment to help with dates and times.
Be sure to modify config/index.js
so it reads as follows:
module.exports = {
dev: {
// Paths
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/api': {
target: 'http://localhost:3000',
secure: false
}
},
This tells the webpack development server that you will be using a server on port 3000 to handle
all requests starting with /api
.
We need to create a database for our app to use. We'll call it redbird
.
Use the mysql command line client:
mysql -u root -p
MariaDB [(none)]> create database redbird;
MariaDB [(none)]> quit;
Use mysql, as above, if you are using a command line environment. Alternatively, you may use HeidiSQL.
We'll use knex to create our database migrations. First, initialize knex:
npx knex init
Now edit knexfile.js
so it has the details needed for your database connection. You should
copy whatever you have had working from before, but change the database:
development: {
client: 'mariasql',
connection: {
host : '127.0.0.1',
user : 'root',
password : '',
db : 'redbird',
charset : 'utf8'
}
},
Put in the password you chose when you setup MariaDB.
You can use mysql instead if you prefer.
If you used the Unix authentication setup (what we called Linux Option 2 previously), copy your config from before.
Make a migration to create a table to store the users:
npx knex migrate:make users
This creates a file in the migrations
directory whose name is based on today's date. Mine is
called 20180320113643_users.js
.
Edit your migration so it contains:
exports.up = function(knex, Promise) {
return Promise.all([
knex.schema.createTable('users', function(table) {
table.increments('id').primary();
table.string('email');
table.string('hash');
table.string('username');
table.string('name');
table.string('role');
}),
]);
};
exports.down = function(knex, Promise) {
return Promise.all([
knex.schema.dropTable('users'),
]);
};
This creates a table of users, and each user has an email, a hashed password, a username (Twitter handle), real name, and a role (e.g. "user", "admin"). We won't use roles in this assignment, but will get to this later.
We also want a migration for a table to hold the tweets:
npx knex migrate:make tweets
This creates a file in the migrations
directory whose name is based on today's date. Mine is
called 20180320114412_tweets.js
.
Edit your migration so it contains:
exports.up = function(knex, Promise) {
return Promise.all([
knex.schema.createTable('tweets', function(table) {
table.increments('id').primary();
table.string('tweet');
table.dateTime('created');
table.integer('user_id').unsigned().notNullable().references('id').inTable('users');
}),
]);
};
exports.down = function(knex, Promise) {
return Promise.all([
knex.schema.dropTable('tweets'),
]);
};
Run the migrations:
npx knex migrate:latest
For the server, start with putting the following in server.js
:
// Express Setup
const express = require('express');
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(express.static('public'));
// Knex Setup
const env = process.env.NODE_ENV || 'development';
const config = require('./knexfile')[env];
const knex = require('knex')(config);
// bcrypt setup
let bcrypt = require('bcrypt');
const saltRounds = 10;
app.listen(3000, () => console.log('Server listening on port 3000!'));
This does all the usual setup for Express and Knex plus setup for bcrypt.
Now add the following route to allow a user to login:
app.post('/api/login', (req, res) => {
if (!req.body.email || !req.body.password)
return res.status(400).send();
knex('users').where('email',req.body.email).first().then(user => {
if (user === undefined) {
res.status(403).send("Invalid credentials");
throw new Error('abort');
}
return [bcrypt.compare(req.body.password, user.hash),user];
}).spread((result,user) => {
if (result)
res.status(200).json({user:{username:user.username,name:user.name,id:user.id}});
else
res.status(403).send("Invalid credentials");
return;
}).catch(error => {
if (error.message !== 'abort') {
console.log(error);
res.status(500).json({ error });
}
});
});
This is just the start of user authentication but it won't be completed until a later assignment.
The steps here include:
-
Check that the request includes all the necessary information, and return an error if not.
-
Look in the database for a user with the same email address as the one being requested. If no such user exists, return a 403 error code.
-
Compare the hash in the user's database entry with the hash of the password they supplied. If they match, return 200. Otherwise, return 403.
-
If any other error occurs, return 500.
Notice how we return both the results of the bcrypt comparison, and the user entry we looked up from
the table, using an array. Then in the following line we use spread
to feed these to the next
part of the promise chain. This is how we pass along two values to a subsequent promise chain.
Notice also that we return a 403 error code, regardless of whether the username doesn't exist or the password is wrong. This prevents someone from guessing which users have registered email addresses with our server and is considered a good practice.
Finally, notice that when we want to return an error code early, we throw an error code so that we break out of the promise chain.
Now add the following route to register a user:
app.post('/api/users', (req, res) => {
if (!req.body.email || !req.body.password || !req.body.username || !req.body.name)
return res.status(400).send();
knex('users').where('email',req.body.email).first().then(user => {
if (user !== undefined) {
res.status(403).send("Email address already exists");
throw new Error('abort');
}
return knex('users').where('username',req.body.username).first();
}).then(user => {
if (user !== undefined) {
res.status(409).send("User name already exists");
throw new Error('abort');
}
return bcrypt.hash(req.body.password, saltRounds);
}).then(hash => {
return knex('users').insert({email: req.body.email, hash: hash, username:req.body.username,
name:req.body.name, role: 'user'});
}).then(ids => {
return knex('users').where('id',ids[0]).first().select('username','name','id');
}).then(user => {
res.status(200).json({user:user});
return;
}).catch(error => {
if (error.message !== 'abort') {
console.log(error);
res.status(500).json({ error });
}
});
});
The steps here include:
-
Check that the request includes all the necessary information, and return an error if not.
-
Check if a user already exists with the given email address, and return an error if so.
-
Check if a user already exists with the given username, and return an error if so.
-
Hash the user's password.
-
Insert a record for the new user in the database.
-
Find the record of the new user in the database.
-
Return 200.
Add the following route to get the list of tweets for a user:
app.get('/api/users/:id/tweets', (req, res) => {
let id = parseInt(req.params.id);
knex('users').join('tweets','users.id','tweets.user_id')
.where('users.id',id)
.orderBy('created','desc')
.select('tweet','username','name','created').then(tweets => {
res.status(200).json({tweets:tweets});
}).catch(error => {
res.status(500).json({ error });
});
});
This is a relatively straightforward route, and the primary complexity is in the database query. We
use knex to join the users
table with the tweets
table, matching the user ID that is in both
tables, restrict the query to show only tweets for the user ID specified in the URL, order the
tweets by descending order of the datetime they were created, and select only the relevant columns
to return. We don't want to return the user's password hash, for example.
We could do some additional error checking here, and return a 404 error if the user does not exist. Right now that will return a 500 error.
Notice that the route we use here is /api/users/:id/tweets
, so we are retrieving the tweets of
a particular person. We'll need a different route later to retrieve the tweets of all the people
a user follows.
Add the following route for posting a new tweet:
app.post('/api/users/:id/tweets', (req, res) => {
let id = parseInt(req.params.id);
knex('users').where('id',id).first().then(user => {
return knex('tweets').insert({user_id: id, tweet:req.body.tweet, created: new Date()});
}).then(ids => {
return knex('tweets').where('id',ids[0]).first();
}).then(tweet => {
res.status(200).json({tweet:tweet});
return;
}).catch(error => {
console.log(error);
res.status(500).json({ error });
});
});
The steps here include:
-
Look up the user account associated with the ID supplied in the URL.
-
Insert the new tweet into the database.
-
Find the tweet that was just inserted.
-
Return 200.
We could do some additional error checking here, and return a 404 error if the user does not exist. Right now that will return a 500 error. Notice we are not checking whether the client is authorized to create a tweet for this user ID. We'll add that later.
We'll now write a client that uses Vue.
You need to edit src/main.js
to add the following:
import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store';
Vue.config.productionTip = false
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
store,
render: h => h(App)
})
While you're editing code, you can start the back end:
node server.js
and the front end:
npm run dev
We'll first create the store to handle all our data. Put this in src/store.js
:
import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
}
});
This is the template for creating a store. Recall that we have state to store the local copy of the data our app uses, getters to allow components to read this state, mutations to allow components to change the state, and actions to allow the components to call asynchronous functions. All of our mutations will be called from actions.
Let's add the state:
state: {
user: {},
loggedIn: false,
loginError: '',
registerError: '',
feed: [],
},
We're going to keep track of a data structure for the logged in user, a boolean indicating whether the user is logged in, some error messages, and the feed of tweets to display on the home page.
Now lets add getters for these:
getters: {
user: state => state.user,
loggedIn: state => state.loggedIn,
loginError: state => state.loginError,
registerError: state => state.registerError,
feed: state => state.feed,
},
These are all functions that take one parameter (state) and return a value (e.g., state.user). Recall that we are using arrow functions here. You can read JavaScript: Arrow Functions for Beginners for background.
Now we'll add mutations:
mutations: {
setUser (state, user) {
state.user = user;
},
setLogin (state, status) {
state.loggedIn = status;
},
setLoginError (state, message) {
state.loginError = message;
},
setRegisterError (state, message) {
state.registerError = message;
},
setFeed (state, feed) {
state.feed = feed;
},
},
These are all functions that take the state
variable and a value to set.
Our first action will deal with user registration. Place this in the actions
section:
// Registration, Login //
register(context,user) {
axios.post("/api/users",user).then(response => {
context.commit('setUser', response.data.user);
context.commit('setLogin',true);
context.commit('setRegisterError',"");
context.commit('setLoginError',"");
}).catch(error => {
context.commit('setLoginError',"");
context.commit('setLogin',false);
if (error.response) {
if (error.response.status === 403)
context.commit('setRegisterError',"That email address already has an account.");
else if (error.response.status === 409)
context.commit('setRegisterError',"That user name is already taken.");
return;
}
context.commit('setRegisterError',"Sorry, your request failed. We will look into it.");
});
},
This sends a post request to the server to create a new user, then sets appropriate state on success. If there is an error, it sets the registration error message.
Here is a similar action for logging in:
login(context,user) {
axios.post("/api/login",user).then(response => {
context.commit('setUser', response.data.user);
context.commit('setLogin',true);
context.commit('setRegisterError',"");
context.commit('setLoginError',"");
}).catch(error => {
context.commit('setRegisterError',"");
if (error.response) {
if (error.response.status === 403 || error.response.status === 400)
context.commit('setLoginError',"Invalid login.");
context.commit('setRegisterError',"");
return;
}
context.commit('setLoginError',"Sorry, your request failed. We will look into it.");
});
},
This also send a post request to the server, sets appropriate state on success, and a login error message on failure.
Here is the logout action:
logout(context,user) {
context.commit('setUser', {});
context.commit('setLogin',false);
},
All the logout function needs to do is set some state.
Now we'll write some actions for tweeting. Here is the action for geting the user's feed of tweets. For now this is only getting tweets from this user, since we don't yet have any functionality for following other users.
// Tweeting //
getFeed(context) {
axios.get("/api/users/" + context.state.user.id + "/tweets").then(response => {
context.commit('setFeed',response.data.tweets);
}).catch(err => {
console.log("getFeed failed:",err);
});
},
To get the feed, we make a call to the server, then set the feed to contain the returned tweets.
Here is the action for adding a new tweet:
addTweet(context,tweet) {
axios.post("/api/users/" + context.state.user.id + "/tweets",tweet).then(response => {
return context.dispatch('getFeed');
}).catch(err => {
console.log("addTweet failed:",err);
});
}
}
To create a tweet, we make a call to the server, then dispatch a call to get the new feed.
Use the following to set the title of the site:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Red Bird</title>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
Put the following in src/components/HomePage.vue
:
<template>
<div>
<div v-if="loggedIn">
<user-feed/>
</div>
<div v-else>
<welcome-page/>
</div>
</div>
</template>
<script>
import WelcomePage from './WelcomePage';
import UserFeed from './UserFeed';
export default {
name: 'HomePage',
components: {WelcomePage,UserFeed},
computed: {
loggedIn: function() {
return this.$store.getters.loggedIn;
},
},
}
</script>
<style scoped>
</style>
The home page will show the user's feed if they are logged in, or a welcome page if they are not. We put each of these into separate components. We use a computed property to get the current status on whether the user is logged in, and this comes from the store.
Put the following in src/components/WelcomePage.vue
. First, the template:
<template>
<div class="column">
<img src="/static/images/red-bird.png"/>
<h1>Follow the Red Bird. Or anyone else.</h1>
<h2>Make friends in high places.</h2>
<form v-on:submit.prevent="register">
<p>1. Choose a user name (this is how you will be known by others on Red Bird).</p>
<input class="narrow" v-model="username" placeholder="User Name">
<p>2. Create an account.</p>
<input class="wide" v-model="name" placeholder="First and Last Name"><br/>
<input class="narrow" v-model="email" placeholder="Email Address">
<input class="narrow" type="password" v-model="password" placeholder="Password">
<button class="alternate" type="submit">Register</button>
</form>
<p class="error">{{registerError}}</p>
</div>
</template>
This shows an advertising message, and then a form to register an account. You will need to get the red bird image from OpenClipArt.
Here is the script portion:
<script>
export default {
name: 'WelcomePage',
data () {
return {
username: '',
email: '',
password: '',
name: '',
}
},
computed: {
registerError: function() {
return this.$store.getters.registerError;
},
},
methods: {
register: function() {
this.$store.dispatch('register',{
username: this.username,
email: this.email,
password: this.password,
name: this.name,
});
}
}
}
</script>
We use a computed property to get the current registration error message, and this comes from the store. We also have a registration function that calls the store's registration action.
And here is the CSS portion:
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
img {
width: 100px;
}
h1 {
margin-bottom: 0px;
}
h2 {
margin-top: 0px;
font-size: 1.2em;
font-weight: normal;
margin-bottom: 50px;
}
.narrow {
width: 170px;
}
.wide {
width: 370px;
}
</style>
Put the following in src/components/UserFeed.vue
. First, the template:
<template>
<div class="feed">
<div>
<form v-on:submit.prevent="tweet" class="tweetForm">
<textarea v-model="text" placeholder=""/><br/>
<div class="buttonWrap">
<button class="primary" type="submit">Tweet</button>
</div>
</form>
</div>
<div v-for="item in feed" class="item">
<p class="idline"><span class="user">{{item.name}}</span><span class="handle">@{{item.username}}</span><span class="time">{{item.created | since}}</span></p>
<p class="tweet">{{item.tweet}}</p>
</div>
</div>
</template>
This shows a form to create a new tweet, then a feed that consists of all the tweets that were retrieved.
Here is the script portion:
<script>
import moment from 'moment';
export default {
name: 'UserFeed',
data () {
return {
text: '',
}
},
created: function() {
this.$store.dispatch('getFeed');
},
filters: {
since: function(datetime) {
moment.locale('en', {
relativeTime: {
future: 'in %s',
past: '%s',
s: 'seconds',
ss: '%ss',
m: '1m',
mm: '%dm',
h: 'h',
hh: '%dh',
d: 'd',
dd: '%dd',
M: ' month',
MM: '%dM',
y: 'a year',
yy: '%dY'
}
});
return moment(datetime).fromNow();
},
},
computed: {
feed: function() {
return this.$store.getters.feed;
},
},
methods: {
tweet: function() {
this.$store.dispatch('addTweet',{
tweet: this.text,
}).then(tweet => {
this.text = "";
});
},
}
}
</script>
A created
function gets the feed from the store. A filter
is used to show the time since the tweet
was created in a user friendly format. The addTweet method is called when the form is submitted; it calls
the store's action for adding a tweet.
Finally, here is the CSS:
<style scoped>
.feed {
width: 600px;
}
.tweetForm {
background: #eee;
padding: 10px;
margin-bottom: 10px;
}
.buttonWrap {
width: 100%;
display: flex;
}
button {
margin-left: auto;
height: 2em;
font-size: 0.9em;
}
textarea {
width: 100%;
height: 5em;
padding: 2px;
margin-bottom: 5px;
resize: none;
box-sizing: border-box;
}
.item {
border-bottom: 1px solid #ddd;
padding: 10px;
}
.tweet {
margin-top: 0px;
}
.idline {
margin-bottom: 0px;
}
.user {
font-weight: bold;
margin-right: 10px;
}
.handle {
margin-right: 10px;
color: #666;
}
.time {
float: right;
color: #666;
}
</style>
Put the following in src/components/AppHeader.vue
. First, the template:
<template>
<nav>
<ul id="menu">
<li><img src="/static/images/red-bird.png"/></li>
<li><router-link to="/">Home</router-link></li>
<li class="right" v-if="loggedIn"><a @click="logout" href="#">Logout</a></li>
<li class="right" v-if="loggedIn">{{user.username}}</li>
<form v-else class="right" v-on:submit.prevent="login">
<input v-model="email" placeholder="Email Address">
<input v-model="password" placeholder="Password">
<button class="primary" type="submit">Login</button>
</form>
</ul>
<div class="flexWrapper errorPlace">
<p v-if="loginError" class="flexRight error">{{loginError}}</p>
</div>
</nav>
</template>
This displays a menu, with a login form to the right if the user is not logged in. Once the user is logged in, the form is removed and the header instead shows the user's username and a logout link.
Here is the script portion:
<script>
export default {
name: 'AppHeader',
data () {
return {
email: '',
password: '',
}
},
computed: {
user: function() {
return this.$store.getters.user;
},
loggedIn: function() {
return this.$store.getters.loggedIn;
},
loginError: function() {
return this.$store.getters.loginError;
},
},
methods: {
login: function() {
this.$store.dispatch('login',{
email: this.email,
password: this.password,
}).then(user => {
this.email = '';
this.password = '';
});
},
logout: function() {
this.$store.dispatch('logout');
}
}
}
</script>
There are three computed properties, to get data from the store. The login and logout methods use the relevant store actions.
Here is the CSS:
<style scoped>
/* Strip the ul of padding and list styling */
nav {
display: grid;
margin-bottom: 20px;
}
ul {
list-style-type:none;
margin:0;
padding:0;
}
/* Create a horizontal list with spacing */
li {
display:inline-block;
float: left;
margin-right: 20px;
height: 50px;
text-align: center;
line-height: 50px;
color: #666;
}
/*Active color*/
li a.active {
}
/*Hover state for top level links*/
li:hover a {
}
.right {
float: right;
}
.errorPlace {
height: 20px;
}
img {
width: 50px;
}
</style>
Put the following in src/App.vue
:
<template>
<div id="app">
<app-header/>
<router-view/>
</div>
</template>
<script>
import AppHeader from './components/AppHeader';
export default {
name: 'App',
components: { AppHeader }
}
</script>
<style>
/* https://color.adobe.com/CS04-color-theme-1994456/?showPublished=true */
body {
font-size: 18px;
padding: 20px 100px 0px 100px;
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button {
color: #fff;
min-width: 100px;
line-height: 1em;
border: none;
border-radius: 5px;
color: white;
padding: 10px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 1.2rem;
margin: 4px 2px;
}
button.primary {
background-color: #F35537;
}
button.alternate {
background-color: #77C4D3;
}
button:focus {
outline: none;
}
button:hover {
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19);
}
button:active {
transform: translateY(4px);
}
input {
height: 20px;
padding: 10px;
}
.flexWrapper {
display:flex;
}
.flexRight {
margin-left: auto;
}
.error {
color: #F35537;
}
.column {
width: 800px;
}
</style>
Put the following in src/router/index.js
:
import Vue from 'vue'
import Router from 'vue-router'
import HomePage from '@/components/HomePage'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'HomePage',
component: HomePage
},
]
})
This is how the Vue Router loads the home page using the HomePage component.
We have built a front end that includes:
- User registration
- User login
- Creating tweets
- Showing a feed of the tweets you created
Here are some screenshots: