diff --git a/.gitignore b/.gitignore index ca23d9b..1827ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,5 +60,7 @@ typings/ # Local development config file config/config.json package-lock.json -public/* +public/assets/app.js +public/assets/app.css +public/assets/app.css.map diff --git a/api/authentication/google.mjs b/api/authentication/google.mjs new file mode 100644 index 0000000..aa7404d --- /dev/null +++ b/api/authentication/google.mjs @@ -0,0 +1,14 @@ +import google from 'googleapis' +import googleauth from 'google-auth-library' +import config from '../config' + +const oauth2Client = new googleauth.OAuth2Client(config.get('googleid')) + +// This is hard to have always running as it requires a +// test access token which always expire. + +/* istanbul ignore next */ +export function getProfile(token) { + return oauth2Client.getTokenInfo(token) +} + \ No newline at end of file diff --git a/api/authentication/routes.mjs b/api/authentication/routes.mjs new file mode 100644 index 0000000..41ea90f --- /dev/null +++ b/api/authentication/routes.mjs @@ -0,0 +1,39 @@ +import Staff from '../staff/model' +import Jwt from '../jwt' +import * as google from './google' + +export default class AuthRoutes { + constructor(opts = {}) { + Object.assign(this, { + Staff: opts.Staff || Staff, + jwt: opts.jwt || new Jwt(), + google: opts.google || google, + }) + } + + /* + * POST /api/login - Authenticate a user using social login + * + * @body {string} token - The google token to authenticate + * @returns + * + * { token: 'Authentication token' } + */ + async login(ctx) { + let output = await google.getProfile(ctx.request.body.token) + + if (output.email_verified !== 'true') ctx.throw(422, 'Email was not verified with google') + if (!output.email) ctx.throw(422, 'Email was missing from google response') + + let level = 1 + let staff = await this.Staff + .query({ where: { email: output.email }}) + .fetch({ require: false }) + + if (staff && staff.id && staff.get('level')) { + level = staff.get('level') + } + + ctx.body = { token: this.jwt.createToken(output.email, level) } + } +} diff --git a/api/jwt.mjs b/api/jwt.mjs index e59fcf9..2fc61fb 100644 --- a/api/jwt.mjs +++ b/api/jwt.mjs @@ -43,16 +43,11 @@ export default class Jwt { return this.jwt.decode(token) } - createStaffToken(staff, opts) { + createToken(email, level, opts) { return this.sign({ - id: staff.id, - level: staff.get('level'), - }, staff.get('password'), opts) - } - - async getUserSecret(header, payload) { - let staff = await this.Staff.getSingle(payload.id) - return staff.id + email: email, + level: level, + }, email, opts) } static jwtMiddleware() { diff --git a/api/media/model.mjs b/api/media/model.mjs new file mode 100644 index 0000000..bb22e79 --- /dev/null +++ b/api/media/model.mjs @@ -0,0 +1,56 @@ +import path from 'path' +import bookshelf from '../bookshelf' + +/* + +Media model: +{ + filename, + filetype, + small_image, + medium_image, + large_image, + *small_url, + *medium_url, + *large_url, + size, + staff_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const Media = bookshelf.createModel({ + tableName: 'media', + + virtuals: { + small_url() { + return `${Media.baseUrl}${this.get('small_image')}` + }, + + medium_url() { + return `${Media.baseUrl}${this.get('medium_image')}` + }, + + large_url() { + return `${Media.baseUrl}${this.get('large_image')}` + }, + }, +}, { + baseUrl: 'https://cdn-nfp.global.ssl.fastly.net', + + getSubUrl(input, size) { + if (!input) return input + + let output = input + if (path.extname(input)) { + let ext = path.extname(input).toLowerCase() + output = input.slice(0, -ext.length) + } + return `${output}.${size}.jpg` + }, +}) + +export default Media diff --git a/api/media/multer.mjs b/api/media/multer.mjs new file mode 100644 index 0000000..43fc168 --- /dev/null +++ b/api/media/multer.mjs @@ -0,0 +1,15 @@ +import multer from 'multer' + +const storage = multer.diskStorage({ + filename: (req, file, cb) => cb(null, file.originalname), +}) +const upload = multer({ storage: storage }) + +export function processBody(ctx) { + return new Promise((res, rej) => { + upload.single('file')(ctx.req, ctx.res, (err) => { + if (err) return rej(err) + return res(ctx.req.file) + }) + }) +} diff --git a/api/media/resize.mjs b/api/media/resize.mjs new file mode 100644 index 0000000..2f3af0b --- /dev/null +++ b/api/media/resize.mjs @@ -0,0 +1,38 @@ +import sharp from 'sharp' +import Media from './model' + +export default class Resizer { + constructor(opts = {}) { + Object.assign(this, { + Media: opts.Media || Media, + sharp: opts.sharp || sharp, + }) + } + + createSmall(input) { + let output = this.Media.getSubUrl(input, 'small') + + return this.sharp(input) + .resize(300, 300) + .toFile(output) + .then(() => output) + } + + createMedium(input) { + let output = this.Media.getSubUrl(input, 'medium') + + return this.sharp(input) + .resize(700, 700) + .toFile(output) + .then(() => output) + } + + autoRotate(input) { + const output = `${input}_2.jpg` + + return this.sharp(input) + .rotate() + .toFile(output) + .then(() => output) + } +} diff --git a/api/media/routes.mjs b/api/media/routes.mjs new file mode 100644 index 0000000..b8602ee --- /dev/null +++ b/api/media/routes.mjs @@ -0,0 +1,43 @@ +import config from '../config' +import Media from './model' +import * as multer from './multer' +import Resizer from './resize' +import { uploadFile } from './upload' +import Jwt from '../jwt' + +export default class MediaRoutes { + constructor(opts = {}) { + Object.assign(this, { + Media: opts.Media || Media, + multer: opts.multer || multer, + resize: opts.resize || new Resizer(), + jwt: opts.jwt || new Jwt(), + uploadFile: opts.uploadFile || uploadFile, + }) + } + + async upload(ctx) { + let result = await this.multer.processBody(ctx) + + let smallPath = await this.resize.createSmall(result.path) + let mediumPath = await this.resize.createMedium(result.path) + + let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret')) + + let [large, small, medium] = await Promise.all([ + this.uploadFile(token, result.path), + this.uploadFile(token, smallPath), + this.uploadFile(token, mediumPath), + ]) + + ctx.body = await this.Media.create({ + filename: result.originalname, + filetype: result.mimetype, + small_image: small.path, + medium_image: medium.path, + large_image: large.path, + size: result.size, + staff_id: ctx.state.user.id, + }) + } +} diff --git a/api/media/upload.mjs b/api/media/upload.mjs new file mode 100644 index 0000000..9a257a0 --- /dev/null +++ b/api/media/upload.mjs @@ -0,0 +1,73 @@ +import http from 'http' +import path from 'path' +import fs from 'fs' + +let stub + +export function uploadFile(token, file) { + // For testing + if (stub) return stub(token, file) + + return new Promise((resolve, reject) => { + fs.readFile(file, (err, data) => { + if (err) return reject(err) + + const crlf = '\r\n' + const filename = path.basename(file) + const boundary = `--${Math.random().toString(16)}` + const headers = [ + `Content-Disposition: form-data; name="file"; filename="${filename}"` + crlf, + ] + const multipartBody = Buffer.concat([ + new Buffer( + `${crlf}--${boundary}${crlf}` + + headers.join('') + crlf + ), + data, + new Buffer( + `${crlf}--${boundary}--` + ), + ]) + + const options = { + port: 2111, + hostname: 'storage01.nfp.is', + method: 'POST', + path: '/media?token=' + token, + headers: { + 'Content-Type': 'multipart/form-data; boundary=' + boundary, + 'Content-Length': multipartBody.length, + }, + } + + const req = http.request(options) + + req.write(multipartBody) + req.end() + + req.on('error', reject) + + req.on('response', res => { + res.setEncoding('utf8') + let output = '' + + res.on('data', function (chunk) { + output += chunk.toString() + }) + + res.on('end', function () { + try { + output = JSON.parse(output) + } catch (e) { + // Do nothing + } + resolve(output) + }) + }) + }) + }) +} + +export function overrideStub(newStub) { + stub = newStub +} diff --git a/api/router.mjs b/api/router.mjs index aa89c79..9f235c1 100644 --- a/api/router.mjs +++ b/api/router.mjs @@ -1,14 +1,19 @@ /* eslint max-len: 0 */ import Router from 'koa-router' -// import access from './access' -// import AuthRoutes from './authentication/routes' -// import { restrict } from './access/middleware' +import access from './access' +import AuthRoutes from './authentication/routes' +import MediaRoutes from './media/routes' +import { restrict } from './access/middleware' const router = new Router() // API Authentication -// const authentication = new AuthRoutes() -// router.post('/api/login', authentication.login.bind(authentication)) +const authentication = new AuthRoutes() +router.post('/api/login', authentication.login.bind(authentication)) + +// API Media +const media = new MediaRoutes() +router.post('/api/media', restrict(access.Manager), media.upload.bind(media)) export default router diff --git a/app/_common.scss b/app/_common.scss new file mode 100644 index 0000000..1b94358 --- /dev/null +++ b/app/_common.scss @@ -0,0 +1,17 @@ +$primary-bg: #01579b; +$primary-fg: white; +$primary-light-bg: #4f83cc; +$primary-light-fg: white; +$primary-dark-bg: #002f6c; +$primary-dark-fg: white; + +$secondary-bg: #f57c00; +$secondary-fg: black; +$secondary-light-bg: #ffad42; +$secondary-light-fg: black; +$secondary-dark-bg: #bb4d00; +$secondary-dark-fg: white; + +$border: #ccc; +$title-fg: #555; +$meta-fg: #999; \ No newline at end of file diff --git a/app/admin/admin.scss b/app/admin/admin.scss new file mode 100644 index 0000000..19a6e18 --- /dev/null +++ b/app/admin/admin.scss @@ -0,0 +1,41 @@ + +.admin-wrapper { + flex-grow: 2; + display: flex; + flex-direction: column; + background: $primary-bg; + padding: 20px; +} + +article.editcat { + text-align: center; + background: white; + padding: 0 0 20px; + + header { + padding: 10px; + background: $secondary-bg; + + h1 { + color: $secondary-fg; + } + } + + form { + align-items: center; + align-self: center; + padding: 20px 40px; + } + + h5 { + margin-bottom: 20px; + } + + .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } +} + +@import 'editcat' diff --git a/app/admin/editcat.js b/app/admin/editcat.js new file mode 100644 index 0000000..85cd13b --- /dev/null +++ b/app/admin/editcat.js @@ -0,0 +1,27 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const EditCategory = { + loading: true, + + oninit: function(vnode) { + console.log(vnode.attrs) + EditCategory.loading = !!m.route.param('id') + }, + + view: function() { + return ( + EditCategory.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', + m('article.editcat', [ + m('header', m('h1', 'Edit category')), + m('form.editcat', [ + ]) + ]) + ) + ) + }, +} + +module.exports = EditCategory diff --git a/app/admin/editcat.scss b/app/admin/editcat.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/app.sass b/app/app.sass deleted file mode 100644 index 98cebb5..0000000 --- a/app/app.sass +++ /dev/null @@ -1,4 +0,0 @@ - -body - margin: 0 - padding: 0 diff --git a/app/app.scss b/app/app.scss new file mode 100644 index 0000000..b4ba63e --- /dev/null +++ b/app/app.scss @@ -0,0 +1,112 @@ +@import './_common'; + +html { + box-sizing: border-box; + font-size: 16px; + height: 100%; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +body, h1, h2, h3, h4, h5, h6, p { + margin: 0; + padding: 0; + font-weight: normal; +} + +body { + height: 100%; + font-family: Arial, Helvetica, sans-serif; +} + +ol, ul { + list-style: none; +} + +img { + max-width: 100%; + height: auto; +} + +@keyframes spinner-loader { + to {transform: rotate(360deg);} +} + +.loading-spinner:before { + content: ''; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin-top: -10px; + margin-left: -10px; + border-radius: 50%; + border: 2px solid #ccc; + border-top-color: #333; + animation: spinner-loader .6s linear infinite; +} + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +main { + display: flex; + flex-direction: column; + flex-grow: 2; +} + +.error { + font-size: 0.8em; + color: $secondary-dark-bg; + font-weight: bold; + padding-bottom: 20px; +} + +[hidden] { display: none !important; } + +article { + display: flex; + flex-direction: column; + flex-grow: 2; + padding: 20px; + + header { + display: flex; + flex-direction: column; + margin-bottom: 20px; + + h1 { + font-size: 1.4em; + color: $title-fg; + flex-grow: 2; + } + + span { + font-size: 0.8em; + color: $meta-fg; + } + } + + .content { + display: flex; + flex-direction: column; + + h5 { + font-size: 1.0em; + font-weight: bold; + color: $title-fg; + } + } +} + +@import 'menu/menu'; +@import 'login/login'; +@import 'admin/admin'; diff --git a/app/authentication.js b/app/authentication.js new file mode 100644 index 0000000..30f99e8 --- /dev/null +++ b/app/authentication.js @@ -0,0 +1,56 @@ +const m = require('mithril') +const jwt = require('jsonwebtoken') + +const storageName = 'logintoken' +const loadingListeners = [] + +window.googleLoaded = function() { + Authentication.loadedGoogle = true + while (Authentication.loadingListeners.length) { + Authentication.loadingListeners.pop()() + } +} + +const Authentication = { + currentUser: null, + loadedGoogle: false, + loadingGoogle: false, + loadingListeners: [], + + updateToken: function(token) { + if (!token) return Authentication.clearToken() + localStorage.setItem(storageName, token) + Authentication.currentUser = jwt.decode(token) + }, + + clearToken: function() { + Authentication.currentUser = null + localStorage.removeItem(storageName) + }, + + createGoogleScript: function() { + if (Authentication.loadedGoogle) return Promise.resolve() + return new Promise(function (res) { + if (Authentication.loadedGoogle) return res() + Authentication.loadingListeners.push(res) + + if (Authentication.loadingGoogle) return + Authentication.loadingGoogle = true + + let gscript = document.createElement('script') + gscript.type = 'text/javascript' + gscript.async = true + gscript.defer = true + gscript.src = 'https://apis.google.com/js/platform.js?onload=googleLoaded' + document.body.appendChild(gscript) + }) + } +} + +Authentication.updateToken(localStorage.getItem(storageName)) + +if (Authentication.currentUser) { + // Authentication.createGoogleScript() +} + +module.exports = Authentication diff --git a/app/frontpage/frontpage.js b/app/frontpage/frontpage.js new file mode 100644 index 0000000..6a3f49f --- /dev/null +++ b/app/frontpage/frontpage.js @@ -0,0 +1,12 @@ +const m = require('mithril') + +module.exports = { + view: function() { + return m('article', [ + m('header', [ + m('h1', 'Welcome to NFP Moe'), + m('span.meta', 'Last updated many years ago'), + ]), + ]) + } +} diff --git a/app/index.js b/app/index.js index a92ff52..8deae3f 100644 --- a/app/index.js +++ b/app/index.js @@ -1 +1,21 @@ -console.log('Success') \ No newline at end of file +const m = require('mithril') + +m.route.prefix('') + +const Authentication = require('./authentication') +const Menu = require('./menu/menu') +const Frontpage = require('./frontpage/frontpage') +const Login = require('./login/login') +const Logout = require('./login/logout') +const EditCategory = require('./admin/editcat') + +const menuRoot = document.getElementById('nav') +const mainRoot = document.getElementById('main') + +m.route(mainRoot, '/', { + '/': Frontpage, + '/login': Login, + '/logout': Logout, + '/admin/addcat': EditCategory, +}) +m.mount(menuRoot, Menu) diff --git a/app/login/login.js b/app/login/login.js new file mode 100644 index 0000000..eae01be --- /dev/null +++ b/app/login/login.js @@ -0,0 +1,83 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Login = { + loadedGoogle: false, + loading: false, + error: '', + + initGoogleButton: function() { + gapi.signin2.render('googlesignin', { + 'scope': 'email', + 'width': 240, + 'height': 50, + 'longtitle': true, + 'theme': 'dark', + 'onsuccess': Login.onGoogleSuccess, + 'onfailure': Login.onGoogleFailure, + }); + }, + + onGoogleSuccess: function(googleUser) { + Login.loading = true + m.redraw() + + m.request({ + method: 'POST', + url: '/api/login', + data: { token: googleUser.Zi.access_token }, + }) + .then(function(result) { + Authentication.updateToken(result.token) + m.route.set('/') + }) + .catch(function(error) { + Login.error = 'Error while logging into NFP! ' + error.code + ': ' + error.response.message + let auth2 = gapi.auth2.getAuthInstance(); + return auth2.signOut() + }) + .then(function () { + Login.loading = false + m.redraw() + }) + }, + + onGoogleFailure: function(error) { + Login.error = 'Error while logging into Google: ' + error + m.redraw() + Authentication.createGoogleScript() + }, + + oninit: function() { + if (Authentication.currentUser) return m.route.set('/') + Login.error = '' + }, + + oncreate: function() { + if (Authentication.currentUser) return + Authentication.createGoogleScript() + .then(function() { + Login.initGoogleButton() + }) + }, + + view: function() { + return [ + m('div.login-wrapper', [ + m('article.login', [ + m('header', [ + m('h1', 'NFP.moe login'), + ]), + m('div.content', [ + m('h5', 'Please login using google to access restricted area'), + Login.error ? m('div.error', Login.error) : null, + Login.loading ? m('div.loading-spinner') : null, + m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')), + ]) + ]), + ]), + ] + } +} + +module.exports = Login diff --git a/app/login/login.scss b/app/login/login.scss new file mode 100644 index 0000000..58fa542 --- /dev/null +++ b/app/login/login.scss @@ -0,0 +1,33 @@ +@import '../_common'; + +.login-wrapper { + flex-grow: 2; + display: flex; + flex-direction: column; + justify-content: center; + background: $border; +} + +article.login { + text-align: center; + flex-grow: 0; + border: 1px solid $title-fg; + background: white; + align-self: center; + + .content { + align-items: center; + align-self: center; + padding: 20px 40px; + } + + h5 { + margin-bottom: 20px; + } + + .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } +} diff --git a/app/login/logout.js b/app/login/logout.js new file mode 100644 index 0000000..2f0fca3 --- /dev/null +++ b/app/login/logout.js @@ -0,0 +1,28 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Logout = { + oninit: function() { + Authentication.createGoogleScript() + .then(function() { + return new Promise(function (res) { + gapi.load('auth2', res); + }) + }) + .then(function() { return gapi.auth2.init() }) + .then(function() { + let auth2 = gapi.auth2.getAuthInstance(); + return auth2.signOut() + }) + .then(function() { + Authentication.clearToken() + m.route.set('/') + }) + }, + + view: function() { + return m('div.loading-spinner') + }, +} + +module.exports = Logout diff --git a/app/menu/menu.js b/app/menu/menu.js new file mode 100644 index 0000000..682dd20 --- /dev/null +++ b/app/menu/menu.js @@ -0,0 +1,47 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Menu = { + currentActive: 'home', + + onbeforeupdate: function() { + let currentPath = m.route.get() + if (currentPath === '/') Menu.currentActive = 'home' + else if (currentPath === '/login') Menu.currentActive = 'login' + else Menu.currentActive = 'none' + }, + + oninit: function() { + Menu.onbeforeupdate() + }, + + view: function() { + return [ + m('div.top', [ + m('h2', 'NFP Moe'), + m('aside', Authentication.currentUser ? [ + m('p', 'Welcome ' + Authentication.currentUser.email), + (Authentication.currentUser.level >= 100 ? + m('a[href=/admin/addcat]', { oncreate: m.route.link }, 'Create category') + : null + ), + m('a[href=/logout]', { oncreate: m.route.link }, 'Logout') + ] : [ + m('a[href=/login]', { oncreate: m.route.link }, 'Login') + ]) + ]), + m('nav', [ + m('a[href=/]', { + class: Menu.currentActive === 'home' ? 'active' : '', + oncreate: m.route.link + }, 'Home'), + m('a[href=/articles]', { + class: Menu.currentActive === 'articles' ? 'active' : '', + oncreate: m.route.link + }, 'Articles'), + ]), + ] + } +} + +module.exports = Menu diff --git a/app/menu/menu.scss b/app/menu/menu.scss new file mode 100644 index 0000000..19c0beb --- /dev/null +++ b/app/menu/menu.scss @@ -0,0 +1,65 @@ +@import '../_common'; + +#nav { + display: flex; + flex-direction: column; + + .top { + background: url('./img/logo.png') 25px center no-repeat $primary-dark-bg; + color: $primary-dark-fg; + padding: 0 10px 0 120px; + height: 100px; + display: flex; + + h2 { + flex-grow: 2; + align-self: center; + font-size: 30px; + } + + aside { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0; + + p { + font-size: 0.8em; + color: $meta-fg; + padding-bottom: 5px; + } + + a, a:visited { + font-weight: bold; + text-align: center; + color: $secondary-light-bg; + font-size: 0.8em; + line-height: 1.4em; + text-decoration: none; + } + } + } + + nav { + display: flex; + background: $primary-light-bg; + color: $primary-light-fg; + + a, a:visited { + flex-grow: 2; + flex-basis: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: $primary-light-fg; + padding: 10px; + font-size: 0.9em; + text-decoration: none; + + &.active { + border-bottom: 3px solid $secondary-bg; + } + } + } +} diff --git a/app/widgets/common.scss b/app/widgets/common.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/widgets/fileupload.js b/app/widgets/fileupload.js new file mode 100644 index 0000000..6e0e098 --- /dev/null +++ b/app/widgets/fileupload.js @@ -0,0 +1,26 @@ +const m = require('mithril') + +const Login = { + oninit: function(vnode) { + vnode.state.media = null + vnode.state.error = '' + }, + + view: function(vnode) { + let media = vnode.state.media + + return m('fileupload', [ + (media ? + m('a', { + href: media.large_url, + style: { + 'background-image': 'url(' + media.medium_url + ')', + } + }) : + m('div.empty') + ), + ]) + } +} + +module.exports = Login diff --git a/config/config.default.json b/config/config.default.json index 88e743d..251fa2a 100644 --- a/config/config.default.json +++ b/config/config.default.json @@ -32,6 +32,7 @@ "expiresIn": 604800 } }, + "googleid": "1076074914074-3no1difo1jq3dfug3glfb25pn1t8idud.apps.googleusercontent.com", "sessionsecret": "this-is-session-secret-lol", "bcrypt": 5, "fileSize": 524288000, diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..075795d --- /dev/null +++ b/nodemon.json @@ -0,0 +1,3 @@ +{ + "ignore": ["app/**", "public/**"] +} \ No newline at end of file diff --git a/package.json b/package.json index 8531fa6..8bfa019 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,11 @@ "scripts": { "lint": "eslint .", "start": "node --experimental-modules index.mjs", - "build": "sass app/app.sass public/assets/app.css && browserify -d app/index.js -o public/assets/app.js", + "build": "sass app/app.scss public/assets/app.css && browserify -d app/index.js -o public/assets/app.js", "test": "echo \"Error: no test specified\" && exit 1", "watch:api": "nodemon --experimental-modules index.mjs | bunyan", - "watch:app": "watchify app/index.js -o public/assets/app.js", - "watch:sass": "sass --watch app/app.sass public/assets/app.css", + "watch:app": "watchify -d app/index.js -o public/assets/app.js", + "watch:sass": "sass --watch app/app.scss public/assets/app.css", "dev": "run-p watch:api watch:app watch:sass", "prod": "npm run build && npm start" }, @@ -32,6 +32,7 @@ "bookshelf": "^0.14.2", "bunyan-lite": "^1.0.1", "format-link-header": "^2.1.0", + "googleapis": "^37.2.0", "http-errors": "^1.7.2", "jsonwebtoken": "^8.4.0", "knex": "^0.16.3", diff --git a/public/assets/img/favicon.png b/public/assets/img/favicon.png new file mode 100644 index 0000000..667da1d Binary files /dev/null and b/public/assets/img/favicon.png differ diff --git a/public/assets/img/logo.png b/public/assets/img/logo.png new file mode 100644 index 0000000..48ed4d8 Binary files /dev/null and b/public/assets/img/logo.png differ diff --git a/public/index.html b/public/index.html index cf1254d..1ab927f 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,19 @@ - + + + + NFP Moe + + + + + + - Works +
+ +
+
+ - + \ No newline at end of file