diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32e6245 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ + +.DS_Store +package-lock.json +*.db +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..aff0037 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ + +# Single Page Application Template + +This is a simple template for a [single-page application](https://medium.com/@NeotericEU/single-page-application-vs-multiple-page-application-2591588efe58). It is written in pure JavaScript and uses NodeJS and Koa for its backend, both as the [RESTful API](https://www.smashingmagazine.com/2018/01/understanding-using-rest-api/) and to serve the client code. + +It uses SQLite for data persistence. diff --git a/chromium_page_load.png b/chromium_page_load.png new file mode 100644 index 0000000..faf15b8 Binary files /dev/null and b/chromium_page_load.png differ diff --git a/index.js b/index.js new file mode 100644 index 0000000..375a15b --- /dev/null +++ b/index.js @@ -0,0 +1,101 @@ + +const Koa = require('koa') +const Router = require('koa-router') +const staticDir = require('koa-static') +const send = require('koa-send') +const koaBody = require('koa-body')({multipart: true, uploadDir: '.'}) + +const app = new Koa() +const router = new Router() + +const defaultPort = 8080 +const port = process.env.PORT || defaultPort +const dbName = 'website.db' +const User = require('./modules/user') +const List = require('./modules/list') + +app.use(staticDir('public')) + +router.get('/', async ctx => { + console.log('---------------------------------------------------------------') + console.log('GET /') + try { + await send(ctx, 'public/index.html') + } catch(err) { + console.log(err.message) + await send(ctx, 'public/index.html') + } +}) + +router.post('/register', koaBody, async ctx => { + console.log('---------------------------------------------------------------') + console.log('POST /register') + const user = await new User(dbName) + try { + const body = typeof ctx.request.body === 'string' ? JSON.parse(ctx.request.body) : ctx.request.body + await user.register(body.user, body.pass, body.email) + ctx.status = 201 + ctx.body = {status: 'success', msg: 'account created'} + } catch(err) { + ctx.status = 422 + ctx.body = {status: "error", msg: err.message} + } finally { + user.tearDown() + } +}) + +router.get('/login', async ctx => { + console.log('---------------------------------------------------------------') + console.log('GET /login') + const user = await new User(dbName) + try { + const header = ctx.request.headers.authorization + const hash = header.split(' ')[1] + const status = await user.login(hash) + ctx.status = 200 + ctx.body = {status: 'success', msg: 'valid credentials'} + } catch(err) { + ctx.status = 401 + ctx.body = {status: "error", msg: err.message} + } finally { + user.tearDown() + } +}) + +router.post('/lists', koaBody, async ctx => { + console.log('---------------------------------------------------------------') + console.log('POST /lists') + const list = await new List(dbName) + try { + const body = typeof ctx.request.body === 'string' ? JSON.parse(ctx.request.body) : ctx.request.body + console.log(body) + const header = ctx.request.headers.authorization + console.log(header) + body.id = await list.add(header, body.listname, body.description) + ctx.status = 201 + ctx.body = {status: 'success', msg: 'list added', data: ctx.body} + } catch(err) { + ctx.status = 422 + ctx.body = {status: "error", msg: err.message} + } finally { + list.tearDown() + } +}) + +router.get('/lists', async ctx => { + console.log('---------------------------------------------------------------') + console.log('GET /lists') + const list = await new List(dbName) + try { + const lists = await list.getLists(ctx.request.headers.authorization) + console.log(lists) + ctx.status = 200 + ctx.body = {status: 'success', msg: 'lists found', data: lists} + } catch(err) { + ctx.status = 422 + ctx.body = {status: "error", msg: err.message} + } +}) + +app.use(router.routes()) +module.exports = app.listen(port, () => console.log(`listening on port ${port}`)) diff --git a/modules/list.js b/modules/list.js new file mode 100644 index 0000000..b3e6408 --- /dev/null +++ b/modules/list.js @@ -0,0 +1,61 @@ + +const sqlite = require('sqlite-async') +const atob = require('atob') + +module.exports = class List { + + constructor(dbName = ':memory:') { + return (async() => { + this.db = await sqlite.open(dbName) + // we need this table to store the user accounts + const sql = 'CREATE TABLE IF NOT EXISTS lists\ + (id INTEGER PRIMARY KEY AUTOINCREMENT,\ + userid TEXT, listname TEXT, description TEXT,\ + date TEXT DEFAULT CURRENT_TIMESTAMP);' + await this.db.run(sql) + return this + })() + } + + async add(token, listname, description) { + try { + console.log(token) + const hash = token.substring(6) //removes the Basic_ bit + console.log(hash) + const str = atob(hash) + console.log(str) + const username = str.split(':')[0] + console.log(username) + const sql = `INSERT INTO lists(userid, listname, description)\ + VALUES("${username}", "${listname}", "${description}")` + console.log(sql) + const recordID = await this.db.run(sql) + console.log(recordID.lastID) + return recordID.lastID + // return 42 + } catch(err) { + console.log(err.message) + throw err + } + } + + async getLists(token) { + try { + const hash = token.substring(6) //removes the Basic_ bit + const str = atob(hash) + const username = str.split(':')[0] + const sql = `SELECT * FROM lists WHERE userid="${username}" ORDER BY date DESC;` + console.log(sql) + const lists = await this.db.all(sql) + console.log(lists) + return lists + } catch(err) { + console.log(err.message) + throw err + } + } + + async tearDown() { + await this.db.close() + } +} diff --git a/modules/user.js b/modules/user.js new file mode 100644 index 0000000..93aed09 --- /dev/null +++ b/modules/user.js @@ -0,0 +1,69 @@ + +const crypto = require('crypto') +const bcrypt = require('bcrypt-promise') +const sqlite = require('sqlite-async') +const atob = require('atob') + +const saltRounds = 10 + +module.exports = class User { + + constructor(dbName = ':memory:') { + return (async() => { + this.db = await sqlite.open(dbName) + // we need this table to store the user accounts + const sql = 'CREATE TABLE IF NOT EXISTS users\ + (id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, pass TEXT, email TEXT);' + await this.db.run(sql) + return this + })() + } + + /** + * registers a new user + * @param {String} user the chosen username + * @param {String} pass the chosen password + * @returns {Boolean} returns true if the new user has been added + */ + async register(user, pass, email) { + try { + Array.from(arguments).forEach( val => { + if(val.length === 0) throw new Error('missing field') + }) + console.log(`user: ${user}, pass: ${pass}, email: ${email}`) + let sql = `SELECT COUNT(id) as records FROM users WHERE user="${user}";` + const data = await this.db.get(sql) + if(data.records !== 0) throw new Error(`username \'${user}\' already in use`) + sql = `SELECT COUNT(id) as records FROM users WHERE email="${email}";` + const emails = await this.db.get(sql) + if(emails.records !== 0) throw new Error(`email address \'${email}\' is already in use`) + pass = await bcrypt.hash(pass, saltRounds) + sql = `INSERT INTO users(user, pass, email) VALUES("${user}", "${pass}", "${email}")` + await this.db.run(sql) + return true + } catch(err) { + console.log('ERROR IN USER.REGISTER') + console.log(err.message) + throw err + } + } + + /** + * checks to see if a set of login credentials are valid + * @param {String} hash the base64-encoded string user:pass + * @returns {Boolean} returns true if credentials are valid + */ + async login(hash) { + const data = atob(hash).split(':') + let sql = `SELECT user, pass FROM users WHERE email="${data[0]}";` + const record = await this.db.get(sql) + if(!record) throw new Error(`email \'${data[0]}\' not found`) + const valid = await bcrypt.compare(data[1], record.pass) + if(!valid) throw new Error(`invalid password for account \'${data[0]}\'`) + return true + } + + async tearDown() { + await this.db.close() + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef7c40d --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "todo", + "version": "1.0.0", + "engines": { + "node": "12.14.x" + }, + "description": "Simple todo app as an SPA", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Mark J Tyers", + "license": "ISC", + "dependencies": { + "atob": "^2.1.2", + "bcrypt": "^4.0.1", + "bcrypt-promise": "^2.0.0", + "koa": "^2.11.0", + "koa-body": "^4.1.1", + "koa-router": "^8.0.8", + "koa-send": "^5.0.0", + "koa-static": "^5.0.0", + "sqlite-async": "^1.0.12" + } +} diff --git a/public/.DS_Store b/public/.DS_Store new file mode 100644 index 0000000..61820f0 Binary files /dev/null and b/public/.DS_Store differ diff --git a/public/core.js b/public/core.js new file mode 100644 index 0000000..62161fa --- /dev/null +++ b/public/core.js @@ -0,0 +1,59 @@ + +export function generateToken(user, pass) { + const token = `${user}:${pass}` + const hash = btoa(token) + return `Basic ${hash}` +} + +export async function login() { + if(!getCookie('authorization')) throw new Error('cookie not found') + const options = { headers: { Authorization: getCookie('authorization') } } + const response = await fetch('/login',options) + const status = response.status + console.log(`HTTP status code: ${status}`) + if(response.status === 401) throw new Error('status 401 NOT AUTHORIZED') +} + +// from plainjs.com +export function setCookie(name, value, days) { + const d = new Date + d.setTime(d.getTime() + 24*60*60*1000*days) + document.cookie = `${name}=${value};path=/;expires=${d.toGMTString()}` +} + +export function getCookie(name) { + const v = document.cookie.match(`(^|;) ?${name}=([^;]*)(;|$)`) + return v ? v[2] : null +} + +export function deleteCookie(name) { + setCookie(name, '', -1) +} + +export function onlineStatus() { + if(navigator.onLine) { + return true + } else { + return false + } +} + +export function showMessage(message) { + console.log(message) + document.querySelector('aside p').innerText = message + document.querySelector('aside').classList.remove('hidden') + setTimeout( () => document.querySelector('aside').classList.add('hidden'), 2000) +} + +export function getLocation() { + if(navigator.geolocation) { + console.log('location supported') + navigator.geolocation.getCurrentPosition( position => { + const pos = position.coords + const locString = `lat: ${pos.latitude}, lon: ${pos.longitude}` + document.getElementById('location').innerHTML = `${locString}
 ` + }) + } else { + console.log('geolocation not supported') + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..d8b7779 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/images/amber.png b/public/images/amber.png new file mode 100644 index 0000000..3f49208 Binary files /dev/null and b/public/images/amber.png differ diff --git a/public/images/desktop.png b/public/images/desktop.png new file mode 100644 index 0000000..f615779 Binary files /dev/null and b/public/images/desktop.png differ diff --git a/public/images/green.png b/public/images/green.png new file mode 100644 index 0000000..499f166 Binary files /dev/null and b/public/images/green.png differ diff --git a/public/images/offline.png b/public/images/offline.png new file mode 100644 index 0000000..755791e Binary files /dev/null and b/public/images/offline.png differ diff --git a/public/images/online.png b/public/images/online.png new file mode 100644 index 0000000..5218fd3 Binary files /dev/null and b/public/images/online.png differ diff --git a/public/images/red.png b/public/images/red.png new file mode 100644 index 0000000..89496c7 Binary files /dev/null and b/public/images/red.png differ diff --git a/public/images/tablet.png b/public/images/tablet.png new file mode 100644 index 0000000..8394aca Binary files /dev/null and b/public/images/tablet.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1b4553b --- /dev/null +++ b/public/index.html @@ -0,0 +1,41 @@ + + + + + + + Simple SPA + + + + + + + + +
+

+

You are currently online

+
+ + +
+ +
+ + + diff --git a/public/modules/404.js b/public/modules/404.js new file mode 100644 index 0000000..cfa9ddf --- /dev/null +++ b/public/modules/404.js @@ -0,0 +1,4 @@ + +export async function setup() { + document.querySelector('h1').innerText = '404 PAGE NOT FOUND!' +} diff --git a/public/modules/home.js b/public/modules/home.js new file mode 100644 index 0000000..f95953d --- /dev/null +++ b/public/modules/home.js @@ -0,0 +1,38 @@ + +import { getCookie, login, showMessage } from '../core.js' + +export async function setup() { + try { + console.log('MAIN SCRIPT') + await login() + displayLists() + document.querySelector('h1').innerText = 'My Lists' + document.querySelector('main > button').addEventListener('click', event => { + console.log('adding a new note') + // await displayLists() + }) + } catch(err) { + showMessage(err.message) + window.location.href = '/#login' + } +} + +async function displayLists() { + console.log('DISPLAY LISTS') + // const request= new Request('/lists') + // request.url = '/lists' + const options = { headers: { Authorization: getCookie('authorization') } } + const response = await fetch('/lists',options) + // const head = new Headers({authorization: getCookie('authorization')}) + // const options = { method: 'post', headers: head } + // request.headers = head + const json = await response.json() + // console.log(await response.json()) + const page = document.querySelector('main') + for(const list of json.data) { + console.log(list) + const section = document.createElement('article') + section.innerHTML = `

${list.listname}

${list.description}

` + page.appendChild(section) + } +} diff --git a/public/modules/login.js b/public/modules/login.js new file mode 100644 index 0000000..b349e93 --- /dev/null +++ b/public/modules/login.js @@ -0,0 +1,38 @@ + +import { generateToken, getCookie, setCookie, showMessage, getLocation } from '../core.js' + +export function setup() { + const cookie = getCookie('authorization') + if(getCookie('authorization')) { + console.log('authorised') + window.location.href = '/#home' + } + document.querySelector('h1').innerText = 'Log In' + document.querySelector('form').addEventListener('submit', async event => await login(event)) +} + +async function login() { + try { + event.preventDefault() + const elements = [...document.forms['login'].elements] + const data = {} + elements.forEach( el => { + if(el.name) data[el.name] = el.value + }) + const token = generateToken(data.email, data.pass) + console.log(token) + const options = { headers: { Authorization: token } } + const response = await fetch('/login',options) + const json = await response.json() + console.log(json) + const status = response.status + console.log(`HTTP status code: ${response.status}`) + if(response.status === 401) throw new Error(json.msg) + if(response.status === 200) { + setCookie('authorization', token, 1) + window.location.href = '#home' + } + } catch(err) { + showMessage(err.message) + } +} \ No newline at end of file diff --git a/public/modules/logout.js b/public/modules/logout.js new file mode 100644 index 0000000..80c9c01 --- /dev/null +++ b/public/modules/logout.js @@ -0,0 +1,9 @@ + +import { deleteCookie, getCookie } from '../core.js' + +export async function setup() { + navigator.geolocation.clearWatch(getCookie('geoID')) + deleteCookie('authorization') + deleteCookie('geoID') + window.location.href = '/#login' +} diff --git a/public/modules/register.js b/public/modules/register.js new file mode 100644 index 0000000..d3a6df7 --- /dev/null +++ b/public/modules/register.js @@ -0,0 +1,24 @@ + +import { showMessage } from '../core.js' + +export async function setup() { + document.querySelector('h1').innerText = 'Register a New Account' + document.querySelector('form').addEventListener('submit', await registerAccount) +} + +async function registerAccount(event) { + event.preventDefault() + try { + const elements = [...document.forms['register'].elements] + const data = {} + elements.forEach( el => { if(el.name) data[el.name] = el.value }) + console.log(data) + const options = { method: 'post', body: JSON.stringify(data) } + const response = await fetch('/register',options) + const json = await response.json() + if(response.status === 422) throw new Error(`422 Unprocessable Entity: ${json.msg}`) + window.location.href = '/#login' + } catch(err) { + showMessage(err.message) + } +} diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..154d954 --- /dev/null +++ b/public/script.js @@ -0,0 +1,64 @@ + +/** + * ROUTER + * This module needs to be imported into your top-level html file. + * It checks the URL fragment/hash and dynamically loads the correct view + * and module. + * There needs to be an html view file and a module file for each fragment. + */ + +import { getCookie, getLocation, onlineStatus } from '../core.js' + +let geoID + +// event triggered when the page first loads, triggers the 'hashchange' event +window.addEventListener('DOMContentLoaded', async event => { + geoID = await navigator.geolocation.watchPosition(getLocation) + loadPage() +}) + +// event gets triggered every time the URL fragment (hash) changes in the address bar +window.addEventListener('hashchange', async event => await loadPage()) + +window.addEventListener('online', toggleOnlineIndicator) +window.addEventListener('offline', toggleOnlineIndicator) + +function toggleOnlineIndicator(event) { + const label = document.querySelector('header strong') + if(onlineStatus()) { + label.classList.remove('offline') + label.innerText = 'online' + document.querySelector('footer > p#connectivity').style.backgroundImage = 'url(images/online.png)' + } else { + label.classList.add('offline') + label.innerText = 'offline' + document.querySelector('footer > p#connectivity').style.backgroundImage = 'url(images/offline.png)' + } +} + +async function loadPage() { + try { + getLocation() + // the 'name' of the page is the name in the fragment without the # character + // if there is no fragment/hash, assume we want to load the home page (ternary operator) + console.log(typeof location.hash) + const pageName = location.hash ? location.hash.replace('#', '') : 'home' + console.log('location updated') + // load the html page that matches the fragment and inject into the page DOM + document.querySelector('main').innerHTML = await (await fetch(`./views/${pageName}.html`)).text() + // dynamically importing the code module who's name matches the page name + const module = await import(`./modules/${pageName}.js`) + // run the setup function in whatever module has been loaded + console.log('---------------------------------------------------------------') + console.log(`${pageName.toUpperCase()}: ${window.location.protocol}//${window.location.host}/${window.location.hash}`) + // window.location contains the contents of the browser address-bar (FYI) + // console.log(`${window.location.protocol}//${window.location.host}/${window.location.hash}`) + if(getCookie('authorization')) console.log(`Authorization: "${getCookie('authorization')}"`) + module.setup() + } catch(err) { + // errors are triggered if script can't find matching html fragment or script + console.log(`error: ${err.message}, redirecting to 404 page`) + console.log(err) + window.location.href = '/#404' + } +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..e699187 --- /dev/null +++ b/public/style.css @@ -0,0 +1,271 @@ + +body, p, h1, h2, h3, ul { + margin: 0; + padding: 0; + font-family: Arial, Helvetica, sans-serif; +} + +p { + margin-bottom: 0.6em; + font-size: 1em; +} + +h1 { + margin-bottom: 0.5em; + font-size: 1.6em; + color: #555; +} + +header { + width: 100%; + background-color: #CCC; + padding: 1em; + padding-bottom: 0.5em; +} + +header p { + font-size: 0.9em; + color: #555; + margin: 0.2em; +} + +header strong { + color: green; +} + +.offline { + color: red; +} + +main { + padding: 1em; +} + +footer { + position: absolute; + bottom: 0; + background-color: #DDD; + width: 100%; + padding: 0; +} + +input { + width: 200px; + font-size: 1.2em; +} + +ul { + padding: 0; + margin: 0; + list-style-type: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + background-color: #CCCC; + /* height: 3em; */ + justify-content: flex-start; +} + +li { + padding: 0; + margin: 0; + display: inline-block; + flex-basis: 8em; + /* border: 1px solid grey; */ + height: 2em; + text-align: center; + text-transform: uppercase; + padding-top: 1em; + font-size: 0.8em; +} + +li > a { + text-decoration: none; + color: black; +} + +li:hover a { + color: white; +} + +li:hover { + background-color: grey; +} + +aside { + border: 0.1em solid red; + padding: 1em; + margin: 1em; + color: red; + font-size: 1em; + border-radius: 0.5em; +} + +.hidden { + display: none; +} + +button { + background-color: grey; + border: 2px solid grey; + color: white; + padding: 0.5em 1em; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 0.8em; + margin-top: 0.5em; +} + +button:hover { + color: grey; + background-color: white; +} + +input[type="text"], input[type="email"], input[type="password"], textarea, select { + font-size: 0.8em; + width: 300px; + border: 1px solid #CCC; + padding: 0.5em; + margin-top: 0.3em; +} +main > button { + position: fixed; + z-index: 2; + bottom: 0.5em; + right: 0.5em; +} + +main > section { + display: absolute; + top: 15em; + width: 315px; + border: 2px solid #CCC; + padding: 1em; + padding-bottom: 3em; + margin: auto; +} + +main > section h2 { + margin-bottom: 0.5em; + color: #555; +} + +main > section button { + float: right; +} + +article { + background-color: #CCCC; + margin-bottom: 0.5em; + padding: 1em; + padding-bottom: 3em; +} + +article > h2 { + margin: 0; + padding: 0; + /* display: inline-block; */ + font-size: 1.2em; + /* background-color: blue; */ + height: 2em; + flex-grow: 1; +} + +article > p { + margin: 0; + padding: 0; + /* display: inline-block; */ + /* background-color: yellow; */ + height: 2em; + flex-grow: 2; +} + +article > button { + margin: 0; + display: block; + height: 3em; + flex-grow: 1; + float: right; +} + +footer { + position: fixed; + bottom: 0; + left: 0; + background-color: #DDDD; + width: 100%; + padding: 0; + margin: 0; + padding-left: 0.3em; + padding-top: 0.5em; +} + +footer > p#device, footer > p#resolution, footer > p#connectivity { + display: inline-block; + height: 1.5em; + width: 1.5em; + margin: 0; + margin-top: 0.3em; + margin-bottom: 0.3em; + background-size: contain; +} + +footer > p#connectivity { + background-image: url(images/online.png); +} + +footer > p#location { + display: inline-block; + margin: 0; + height: 0.7em; + margin-left: 0.3em; + font-size: 0.8em; + color: gray; +} + +/* ==================== DETECTS IF SCREEN SUPPORTS TOUCH ==================== */ + +/* Detects a touch-screen device */ +@media (hover: none) and (pointer: coarse) { + footer > p#device { + background-image: url(images/tablet.png); + background-size: contain; + } + +} + +/* Detects a non-touch screen with a mouse or trackpad */ +@media (hover: hover) and (pointer: fine) { + footer > p#device { + background-image: url(images/desktop.png); + background-size: contain; + } +} + +/* ======================== DETECTS THE SCREEN WIDTH ======================== */ + +/* Narrow screen */ +@media (max-width: 400px) { + footer > p#resolution { + background-image: url(images/red.png); + background-size: contain; + } +} + +/* Medium width screen */ +@media (min-width: 401px) and (max-width: 800px) { + footer > p#resolution { + background-image: url(images/amber.png); + background-size: contain; + } +} + +/* Wide screen */ +@media (min-width: 801px) { + footer > p#resolution { + background-image: url(images/green.png); + background-size: contain; + } +} diff --git a/public/views/404.html b/public/views/404.html new file mode 100644 index 0000000..8e6f1ef --- /dev/null +++ b/public/views/404.html @@ -0,0 +1,2 @@ + +

404 PAGE NOT FOUND!

diff --git a/public/views/home.html b/public/views/home.html new file mode 100644 index 0000000..36966fb --- /dev/null +++ b/public/views/home.html @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/public/views/login.html b/public/views/login.html new file mode 100644 index 0000000..f1fab4a --- /dev/null +++ b/public/views/login.html @@ -0,0 +1,6 @@ + +
+


+


+

+
diff --git a/public/views/logout.html b/public/views/logout.html new file mode 100644 index 0000000..b3d5339 --- /dev/null +++ b/public/views/logout.html @@ -0,0 +1,2 @@ + +

Log Out

diff --git a/public/views/register.html b/public/views/register.html new file mode 100644 index 0000000..992e4ad --- /dev/null +++ b/public/views/register.html @@ -0,0 +1,7 @@ + +
+


+


+


+

+
diff --git a/public/website.appcache.old b/public/website.appcache.old new file mode 100644 index 0000000..39c1607 --- /dev/null +++ b/public/website.appcache.old @@ -0,0 +1,15 @@ +CACHE MANIFEST +style.css +script.js +core.js +favicon.ico +modules/404.js +modules/home.js +modules/login.js +modules/logout.js +modules/register.js +views/404.html +views/home.html +views/login.html +views/logout.html +views/register.html \ No newline at end of file