many updates

master
Jonatan Nilsson 2019-02-20 16:10:37 +00:00
parent beb65dcc07
commit 18c7c25eed
32 changed files with 887 additions and 26 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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)
}

View File

@ -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) }
}
}

View File

@ -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() {

56
api/media/model.mjs Normal file
View File

@ -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

15
api/media/multer.mjs Normal file
View File

@ -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)
})
})
}

38
api/media/resize.mjs Normal file
View File

@ -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)
}
}

43
api/media/routes.mjs Normal file
View File

@ -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,
})
}
}

73
api/media/upload.mjs Normal file
View File

@ -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
}

View File

@ -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

17
app/_common.scss Normal file
View File

@ -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;

41
app/admin/admin.scss Normal file
View File

@ -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'

27
app/admin/editcat.js Normal file
View File

@ -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

0
app/admin/editcat.scss Normal file
View File

View File

@ -1,4 +0,0 @@
body
margin: 0
padding: 0

112
app/app.scss Normal file
View File

@ -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';

56
app/authentication.js Normal file
View File

@ -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

View File

@ -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'),
]),
])
}
}

View File

@ -1 +1,21 @@
console.log('Success')
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)

83
app/login/login.js Normal file
View File

@ -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

33
app/login/login.scss Normal file
View File

@ -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;
}
}

28
app/login/logout.js Normal file
View File

@ -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

47
app/menu/menu.js Normal file
View File

@ -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

65
app/menu/menu.scss Normal file
View File

@ -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;
}
}
}
}

0
app/widgets/common.scss Normal file
View File

26
app/widgets/fileupload.js Normal file
View File

@ -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

View File

@ -32,6 +32,7 @@
"expiresIn": 604800
}
},
"googleid": "1076074914074-3no1difo1jq3dfug3glfb25pn1t8idud.apps.googleusercontent.com",
"sessionsecret": "this-is-session-secret-lol",
"bcrypt": 5,
"fileSize": 524288000,

3
nodemon.json Normal file
View File

@ -0,0 +1,3 @@
{
"ignore": ["app/**", "public/**"]
}

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/assets/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,6 +1,19 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>NFP Moe</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/assets/img/favicon.png">
<link rel="Stylesheet" href="/assets/app.css" type="text/css" />
<meta name="google-signin-client_id" content="1076074914074-3no1difo1jq3dfug3glfb25pn1t8idud.apps.googleusercontent.com">
</head>
<body>
Works
<div class="container">
<div id="nav"></div>
<main id="main"></main>
</div>
<script type="text/javascript" src="/assets/app.js?v=1"></script>
</body>
</html>
</html>