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 @@
+
+
+
+
+
You are currently online
+${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 @@ + + + +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