More updates, implemented basic login and article view

This commit is contained in:
Jonatan Nilsson 2019-09-15 01:53:38 +00:00
parent 6d7b63eb47
commit 4e0ed81b55
29 changed files with 824 additions and 19 deletions

View file

@ -7,7 +7,8 @@
}
},
"globals": {
"FroalaEditor": "readonly"
"FroalaEditor": "readonly",
"gapi": "readonly"
},
"extends": "eslint:recommended",
"env": {

View file

@ -13,7 +13,7 @@ export default class ArticleRoutes {
async getAllArticles(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes)
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-id')
}
/** GET: /api/pages/:pageId/articles */

View file

@ -0,0 +1,35 @@
import Staff from '../staff/model.mjs'
import Jwt from '../jwt.mjs'
export default class AuthHelper {
constructor(opts = {}) {
Object.assign(this, {
Staff: opts.Staff || Staff,
jwt: opts.jwt || new Jwt(),
})
}
async loginStaff(ctx) {
let staff
try {
staff = await this.Staff
.query(qb => {
qb.where({ email: ctx.request.body.username })
qb.select('*')
})
.fetch({ require: true })
console.log(ctx.request.body.password, staff.get('password'))
await this.Staff.compare(ctx.request.body.password, staff.get('password'))
} catch (err) {
if (err.message === 'EmptyResponse' || err.message === 'PasswordMismatch') {
ctx.throw(422, 'The email or password did not match')
}
throw err
}
return this.jwt.createToken(staff.get('email'), staff.get('level'))
}
}

View file

@ -1,13 +1,17 @@
import Staff from '../staff/model.mjs'
import Jwt from '../jwt.mjs'
import * as google from './google.mjs'
import * as security from './security.mjs'
import AuthHelper from './helper.mjs'
export default class AuthRoutes {
constructor(opts = {}) {
Object.assign(this, {
helper: opts.helper || new AuthHelper(),
Staff: opts.Staff || Staff,
jwt: opts.jwt || new Jwt(),
google: opts.google || google,
security: opts.security || security,
})
}
@ -36,4 +40,21 @@ export default class AuthRoutes {
ctx.body = { token: this.jwt.createToken(output.email, level) }
}
/*
* POST /api/login/user - Authenticate a user using password login
*
* @body {string} username - Username
* @body {string} password - Password
* @returns
*
* { token: 'Authentication token' }
*/
async loginUser(ctx) {
this.security.isValidLogin(ctx, ctx.request.body)
let token = await this.helper.loginStaff(ctx)
ctx.body = { token }
}
}

View file

@ -0,0 +1,18 @@
export function isValidLogin(ctx, body) {
if (!body.username) {
ctx.throw(422, 'Body was missing property username')
}
if (!body.password) {
ctx.throw(422, 'Body was missing property password')
}
if (typeof body.password !== 'string') {
ctx.throw(422, 'Property password must be a string')
}
if (typeof body.username !== 'string') {
ctx.throw(422, 'Property username must be a string')
}
}

View file

@ -7,6 +7,7 @@ import MediaRoutes from './media/routes.mjs'
import FileRoutes from './file/routes.mjs'
import PageRoutes from './page/routes.mjs'
import ArticleRoutes from './article/routes.mjs'
import StaffRoutes from './staff/routes.mjs'
import { restrict } from './access/middleware.mjs'
const router = new Router()
@ -14,6 +15,7 @@ const router = new Router()
// API Authentication
const authentication = new AuthRoutes()
router.post('/api/login', authentication.login.bind(authentication))
router.post('/api/login/user', authentication.loginUser.bind(authentication))
// API Media
const media = new MediaRoutes()
@ -42,4 +44,11 @@ router.post('/api/articles', restrict(access.Manager), article.createArticle.bin
router.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article))
router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article))
const staff = new StaffRoutes()
router.get('/api/staff', restrict(access.Admin), staff.getAllStaff.bind(staff))
router.get('/api/staff/:id', restrict(access.Admin), staff.getSingleStaff.bind(staff))
router.post('/api/staff', restrict(access.Admin), staff.createStaff.bind(staff))
router.put('/api/staff/:id', restrict(access.Admin), staff.updateStaff.bind(staff))
router.del('/api/staff/:id', restrict(access.Admin), staff.removeStaff.bind(staff))
export default router

View file

@ -1,4 +1,6 @@
import bookshelf from '../bookshelf.mjs'
import bcrypt from 'bcrypt'
import config from '../config.mjs'
/* Staff model:
{
@ -16,13 +18,57 @@ import bookshelf from '../bookshelf.mjs'
const Staff = bookshelf.createModel({
tableName: 'staff',
privateFields: bookshelf.safeColumns([
'fullname',
'email',
'level',
]),
}, {
// Hide password from any relations and include requests.
publicFields: bookshelf.safeColumns([
'username',
'fullname',
'level',
]),
hash(password) {
return new Promise((resolve, reject) =>
bcrypt.hash(password, config.get('bcrypt'), (err, hashed) => {
if (err) return reject(err)
resolve(hashed)
})
)
},
compare(password, hashed) {
return new Promise((resolve, reject) =>
bcrypt.compare(password, hashed, (err, res) => {
if (err || !res) return reject(new Error('PasswordMismatch'))
resolve()
})
)
},
getAll(ctx, where = {}, withRelated = [], orderBy = 'id') {
return this.query(qb => {
this.baseQueryAll(ctx, qb, where, orderBy)
qb.select(bookshelf.safeColumns([
'fullname',
'email',
'level',
]))
})
.fetchPage({
pageSize: ctx.state.pagination.perPage,
page: ctx.state.pagination.page,
withRelated,
ctx: ctx,
})
.then(result => {
ctx.state.pagination.total = result.pagination.rowCount
return result
})
},
})
export default Staff

52
api/staff/routes.mjs Normal file
View file

@ -0,0 +1,52 @@
import Staff from './model.mjs'
import * as security from './security.mjs'
export default class StaffRoutes {
constructor(opts = {}) {
Object.assign(this, {
Staff: opts.Staff || Staff,
security: opts.security || security,
})
}
/** GET: /api/articles */
async getAllStaff(ctx) {
ctx.body = await this.Staff.getAll(ctx, { }, [])
}
/** GET: /api/articles/:id */
async getSingleStaff(ctx) {
ctx.body = await this.Staff.getSingle(ctx.params.id, [], true, ctx)
}
/** POST: /api/articles */
async createStaff(ctx) {
await this.security.validUpdate(ctx)
ctx.body = await this.Staff.create(ctx.request.body)
}
/** PUT: /api/articles/:id */
async updateStaff(ctx) {
await this.security.validUpdate(ctx)
let page = await this.Staff.getSingle(ctx.params.id)
page.set(ctx.request.body)
await page.save()
ctx.body = page
}
/** DELETE: /api/articles/:id */
async removeStaff(ctx) {
let page = await this.Staff.getSingle(ctx.params.id)
page.set({ is_deleted: true })
await page.save()
ctx.status = 204
}
}

21
api/staff/security.mjs Normal file
View file

@ -0,0 +1,21 @@
import filter from '../filter.mjs'
import Staff from './model.mjs'
const validFields = [
'fullname',
'email',
'password',
'level',
]
export async function validUpdate(ctx) {
let out = filter(Object.keys(ctx.request.body), validFields)
if (out.length > 0) {
ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`)
}
if (ctx.request.body.password) {
ctx.request.body.password = await Staff.hash(ctx.request.body.password)
}
}

View file

@ -37,3 +37,4 @@
@import 'pages';
@import 'articles';
@import 'staff';

151
app/admin/editstaff.js Normal file
View file

@ -0,0 +1,151 @@
const m = require('mithril')
const { createStaff, updateStaff, getStaff } = require('../api/staff')
const EditStaff = {
oninit: function(vnode) {
this.fetchStaff(vnode)
},
onupdate: function(vnode) {
if (this.lastid !== m.route.param('id')) {
this.fetchStaff(vnode)
}
},
fetchStaff: function(vnode) {
this.lastid = m.route.param('id')
this.loading = this.lastid !== 'add'
this.creating = this.lastid === 'add'
this.error = ''
this.staff = {
fullname: '',
email: '',
password: '',
level: 10,
}
if (this.lastid !== 'add') {
getStaff(this.lastid)
.then(function(result) {
vnode.state.editedPath = true
vnode.state.staff = result
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
m.redraw()
})
}
},
updateValue: function(fullname, e) {
this.staff[fullname] = e.currentTarget.value
},
save: function(vnode, e) {
e.preventDefault()
if (!this.staff.fullname) {
this.error = 'Fullname is missing'
} else if (!this.staff.email) {
this.error = 'Email is missing'
} else {
this.error = ''
}
if (this.error) return
this.staff.description = vnode.state.froala && vnode.state.froala.html.get() || this.staff.description
this.loading = true
let promise
if (this.staff.id) {
promise = updateStaff(this.staff.id, {
fullname: this.staff.fullname,
email: this.staff.email,
level: this.staff.level,
password: this.staff.password,
})
} else {
promise = createStaff({
fullname: this.staff.fullname,
email: this.staff.email,
level: this.staff.level,
password: this.staff.password,
})
}
promise.then(function(res) {
m.route.set('/admin/staff')
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
m.redraw()
})
},
updateLevel: function(e) {
this.staff.level = Number(e.currentTarget.value)
},
view: function(vnode) {
const levels = [[10, 'Manager'], [100, 'Admin']]
return (
this.loading ?
m('div.loading-spinner')
: m('div.admin-wrapper', [
m('div.admin-actions', this.staff.id
? [
m('span', 'Actions:'),
m(m.route.Link, { href: '/admin/staff' }, 'Staff list'),
]
: null),
m('article.editstaff', [
m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.fullname || '(untitled)'))),
m('div.error', {
hidden: !this.error,
onclick: function() { vnode.state.error = '' },
}, this.error),
m('form.editstaff.content', {
onsubmit: this.save.bind(this, vnode),
}, [
m('label', 'Level'),
m('select', {
onchange: this.updateLevel.bind(this),
}, levels.map(function(level) { return m('option', { value: level[0], selected: level[0] === vnode.state.staff.level }, level[1]) })),
m('label', 'Fullname'),
m('input', {
type: 'text',
value: this.staff.fullname,
oninput: this.updateValue.bind(this, 'fullname'),
}),
m('label', 'Email'),
m('input', {
type: 'text',
value: this.staff.email,
oninput: this.updateValue.bind(this, 'email'),
}),
m('label', 'Password (optional)'),
m('input', {
type: 'text',
value: this.staff.password,
oninput: this.updateValue.bind(this, 'password'),
}),
m('input', {
type: 'submit',
value: 'Save',
}),
]),
]),
])
)
},
}
module.exports = EditStaff

49
app/admin/staff.scss Normal file
View file

@ -0,0 +1,49 @@
article.editstaff {
text-align: center;
background: white;
padding: 0 0 20px;
header {
padding: 10px;
background: $secondary-bg;
h1 {
color: $secondary-fg;
}
a {
font-size: 14px;
text-decoration: none;
margin-left: 10px;
color: $secondary-light-fg;
}
}
form {
padding: 0 40px 20px;
textarea {
height: 300px;
}
.loading-spinner {
height: 300px;
position: relative;
}
}
h5 {
margin-bottom: 20px;
}
& > .loading-spinner {
width: 240px;
height: 50px;
position: relative;
&.full {
width: 100%;
}
}
}

110
app/admin/stafflist.js Normal file
View file

@ -0,0 +1,110 @@
const m = require('mithril')
const { getAllStaff, removeStaff } = require('../api/staff')
const Dialogue = require('../widgets/dialogue')
const Pages = require('../widgets/pages')
const AdminStaffList = {
oninit: function(vnode) {
this.error = ''
this.lastpage = m.route.param('page') || '1'
this.staff = []
this.removeStaff = null
this.fetchStaffs(vnode)
},
fetchStaffs: function(vnode) {
this.loading = true
return getAllStaff()
.then(function(result) {
vnode.state.staff = result
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
m.redraw()
})
},
confirmRemoveStaff: function(vnode) {
let removingStaff = this.removeStaff
this.removeStaff = null
this.loading = true
removeStaff(removingStaff.id)
.then(this.oninit.bind(this, vnode))
.catch(function(err) {
vnode.state.error = err.message
vnode.state.loading = false
m.redraw()
})
},
getLevel: function(level) {
if (level === 100) {
return 'Admin'
}
return 'Manager'
},
view: function(vnode) {
return [
m('div.admin-wrapper', [
m('div.admin-actions', [
m('span', 'Actions:'),
m(m.route.Link, { href: '/admin/staff/add' }, 'Create new staff'),
]),
m('article.editarticle', [
m('header', m('h1', 'All staff')),
m('div.error', {
hidden: !this.error,
onclick: function() { vnode.state.error = '' },
}, this.error),
(this.loading
? m('div.loading-spinner.full')
: m('table', [
m('thead',
m('tr', [
m('th', 'Fullname'),
m('th', 'Email'),
m('th', 'Level'),
m('th.right', 'Updated'),
m('th.right', 'Actions'),
])
),
m('tbody', this.staff.map(function(item) {
return m('tr', [
m('td', m(m.route.Link, { href: '/admin/staff/' + item.id }, item.fullname)),
m('td', item.email),
m('td.right', AdminStaffList.getLevel(item.level)),
m('td.right', (item.updated_at || '---').replace('T', ' ').split('.')[0]),
m('td.right', m('button', { onclick: function() { vnode.state.removeStaff = item } }, 'Remove')),
])
})),
])
),
m(Pages, {
base: '/admin/staff',
links: this.links,
}),
]),
]),
m(Dialogue, {
hidden: vnode.state.removeStaff === null,
title: 'Delete ' + (vnode.state.removeStaff ? vnode.state.removeStaff.name : ''),
message: 'Are you sure you want to remove "' + (vnode.state.removeStaff ? vnode.state.removeStaff.fullname : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')',
yes: 'Remove',
yesclass: 'alert',
no: 'Cancel',
noclass: 'cancel',
onyes: this.confirmRemoveStaff.bind(this, vnode),
onno: function() { vnode.state.removeStaff = null },
}),
]
},
}
module.exports = AdminStaffList

View file

@ -25,7 +25,11 @@ exports.sendRequest = function(options, isPagination) {
data: JSON.parse(xhr.responseText),
}
} else {
if (xhr.responseText) {
out = JSON.parse(xhr.responseText)
} else {
out = {}
}
}
if (xhr.status >= 300) {
throw out

38
app/api/staff.js Normal file
View file

@ -0,0 +1,38 @@
const { sendRequest } = require('./common')
exports.createStaff = function(body) {
return sendRequest({
method: 'POST',
url: '/api/staff',
body: body,
})
}
exports.updateStaff = function(id, body) {
return sendRequest({
method: 'PUT',
url: '/api/staff/' + id,
body: body,
})
}
exports.getAllStaff = function() {
return sendRequest({
method: 'GET',
url: '/api/staff',
})
}
exports.getStaff = function(id) {
return sendRequest({
method: 'GET',
url: '/api/staff/' + id,
})
}
exports.removeStaff = function(id) {
return sendRequest({
method: 'DELETE',
url: '/api/staff/' + id,
})
}

View file

@ -244,4 +244,5 @@ table {
@import 'admin/admin';
@import 'widgets/common';
@import 'pages/page';
@import 'frontpage/frontpage'
@import 'article/article';
@import 'frontpage/frontpage';

78
app/article/article.js Normal file
View file

@ -0,0 +1,78 @@
const m = require('mithril')
const { getArticle } = require('../api/article')
const Authentication = require('../authentication')
const Fileinfo = require('../widgets/fileinfo')
const Article = {
oninit: function(vnode) {
this.error = ''
this.lastarticle = m.route.param('article') || '1'
this.loadingnews = false
this.fetchArticle(vnode)
},
fetchArticle: function(vnode) {
this.path = m.route.param('id')
this.news = []
this.newslinks = null
this.article = {
id: 0,
name: '',
path: '',
description: '',
media: null,
banner: null,
files: [],
}
this.loading = true
getArticle(this.path)
.then(function(result) {
vnode.state.article = result
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = vnode.state.loadingnews = false
m.redraw()
})
},
onupdate: function(vnode) {
if (this.path !== m.route.param('id')) {
this.fetchArticle(vnode)
}
},
view: function(vnode) {
return (
this.loading ?
m('div.loading-spinner')
: m('article.article', [
m('header', m('h1', this.article.name)),
m('.fr-view', [
this.article.media
? m('a.cover', {
href: this.article.media.url,
}, m('img', { src: this.article.media.medium_url }))
: null,
this.article.description ? m.trust(this.article.description) : null,
(this.article.files && this.article.files.length
? this.article.files.map(function(file) {
return m(Fileinfo, { file: file })
})
: null),
]),
Authentication.currentUser
? m('div.admin-actions', [
m('span', 'Admin controls:'),
m(m.route.Link, { href: '/admin/articles/' + this.article.id }, 'Edit article'),
])
: null,
])
)
},
}
module.exports = Article

43
app/article/article.scss Normal file
View file

@ -0,0 +1,43 @@
article.article {
background: white;
padding: 0 0 20px;
header {
text-align: center;
margin: 20px;
padding: 10px;
background: $secondary-bg;
width: 100%;
max-width: 1920px;
align-self: center;
h1 {
color: $secondary-fg;
}
}
.cover {
margin: 0 -10px 20px;
}
.admin-actions {
margin-bottom: 20px;
}
.fr-view {
margin: 0 20px;
padding: 0 20px;
width: calc(100% - 40px);
max-width: 800px;
flex: 2 0 0;
align-self: center;
a.cover img {
margin-bottom: 30px;
}
main {
padding: 0 5px;
}
}
}

View file

@ -4,7 +4,7 @@
background-repeat: no-repeat;
background-position: center;
height: 150px;
width: 100%;
width: calc(100% - 40px);
max-width: 1920px;
align-self: center;
flex: 0 0 150px;
@ -14,6 +14,8 @@
text-align: right;
font-size: 1.6em;
padding: 10px 20px;
text-decoration: none;
margin: 20px 0;
}
frontpage {
@ -29,4 +31,20 @@ frontpage {
.loading-spinner {
height: 100px;
}
newsitem {
margin-bottom: 30px;
}
}
@media screen and (max-width: 480px){
.frontpage-banner {
width: 100%;
}
frontpage {
padding: 0 10px;
margin: 0;
width: 100%;
}
}

View file

@ -8,9 +8,12 @@ const Login = require('./login/login')
const Logout = require('./login/logout')
const EditPage = require('./admin/editpage')
const Page = require('./pages/page')
const Article = require('./article/article')
const AdminPages = require('./admin/pages')
const AdminArticles = require('./admin/articles')
const EditArticle = require('./admin/editarticle')
const AdminStaffList = require('./admin/stafflist')
const EditStaff = require('./admin/editstaff')
const menuRoot = document.getElementById('nav')
const mainRoot = document.getElementById('main')
@ -20,9 +23,12 @@ m.route(mainRoot, '/', {
'/login': Login,
'/logout': Logout,
'/page/:id': Page,
'/article/:id': Article,
'/admin/pages': AdminPages,
'/admin/pages/:key': EditPage,
'/admin/articles': AdminArticles,
'/admin/articles/:id': EditArticle,
'/admin/staff': AdminStaffList,
'/admin/staff/:id': EditStaff,
})
m.mount(menuRoot, Menu)

View file

@ -1,5 +1,6 @@
const m = require('mithril')
const Authentication = require('../authentication')
const { sendRequest } = require('../api/common')
const Login = {
loadedGoogle: false,
@ -45,7 +46,6 @@ const Login = {
onGoogleFailure: function(error) {
if (error.error !== 'popup_closed_by_user' && error.error !== 'popup_blocked_by_browser') {
console.error(error)
Login.error = 'Error while logging into Google: ' + error.error
m.redraw()
}
@ -55,6 +55,9 @@ const Login = {
Login.redirect = vnode.attrs.redirect || ''
if (Authentication.currentUser) return m.route.set('/')
Login.error = ''
this.username = ''
this.password = ''
},
oncreate: function() {
@ -65,7 +68,42 @@ const Login = {
})
},
view: function() {
loginuser: function(vnode, e) {
e.preventDefault()
if (!this.username) {
Login.error = 'Email is missing'
} else if (!this.password) {
Login.error = 'Password is missing'
} else {
Login.error = ''
}
if (Login.error) return
Login.loading = true
sendRequest({
method: 'POST',
url: '/api/login/user',
body: {
username: this.username,
password: this.password,
},
})
.then(function(result) {
Authentication.updateToken(result.token)
m.route.set(Login.redirect || '/')
})
.catch(function(error) {
Login.error = 'Error while logging into NFP! ' + error.message
vnode.state.password = ''
})
.then(function () {
Login.loading = false
m.redraw()
})
},
view: function(vnode) {
return [
m('div.login-wrapper', [
m('article.login', [
@ -73,9 +111,31 @@ const Login = {
m('h1', 'NFP.moe login'),
]),
m('div.content', [
m('h5', 'Please login using google to access restricted area'),
m('h5', 'Please login to access restricted area'),
Login.error ? m('div.error', Login.error) : null,
Login.loading ? m('div.loading-spinner') : null,
m('form', {
hidden: Login.loading,
onsubmit: this.loginuser.bind(this, vnode),
}, [
m('label', 'Email'),
m('input', {
type: 'text',
value: this.username,
oninput: function(e) { vnode.state.username = e.currentTarget.value },
}),
m('label', 'Password'),
m('input', {
type: 'password',
value: this.password,
oninput: function(e) { vnode.state.password = e.currentTarget.value },
}),
m('input', {
type: 'submit',
value: 'Login',
}),
]),
m('h5', { hidden: Login.loading }, 'Alternative login'),
m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')),
]),
]),

View file

@ -30,4 +30,9 @@ article.login {
height: 50px;
position: relative;
}
form {
align-self: stretch;
margin-bottom: 20px;
}
}

View file

@ -45,12 +45,13 @@ const Menu = {
'Welcome ' + Authentication.currentUser.email,
m(m.route.Link, { href: '/logout' }, 'Logout'),
]),
(Authentication.currentUser.level >= 100
? [
m(m.route.Link, { href: '/admin/pages' }, 'Pages'),
m(m.route.Link, { href: '/admin/articles' }, 'Articles'),
(Authentication.currentUser.level >= 10
? m('div.adminlinks', [
m(m.route.Link, { href: '/admin/articles/add' }, 'Create article'),
]
m(m.route.Link, { href: '/admin/articles' }, 'Articles'),
m(m.route.Link, { href: '/admin/pages' }, 'Pages'),
m(m.route.Link, { hidden: Authentication.currentUser.level < 100, href: '/admin/staff' }, 'Staff'),
])
: null
),
] : [

View file

@ -49,6 +49,18 @@
line-height: 1.4em;
text-decoration: none;
}
.adminlinks {
display: flex;
justify-content: center;
max-width: 200px;
flex-wrap: wrap;
a {
padding: 3px 5px;
min-width: 100px;
}
}
}
}

View file

@ -319,3 +319,26 @@ pages {
cursor: pointer;
}
}
@media screen and (max-width: 1000px){
newsitem a.cover {
width: 300px;
}
}
@media screen and (max-width: 639px){
newsitem {
a.cover {
width: 100%;
margin-bottom: 20px;
img {
max-height: max-content;
}
}
.newsitemcontent {
flex-direction: column;
}
}
}

View file

@ -16,7 +16,7 @@ const Newsentry = {
return m('newsentry', [
vnode.attrs.media
? m('a.cover', {
href: vnode.attrs.media.large_url,
href: '/article/' + vnode.attrs.path,
}, m('img', { src: vnode.attrs.media.small_url }))
: m('a.cover.nobg'),
m('div.entrycontent', [

View file

@ -11,8 +11,8 @@ const Newsitem = {
m('div.newsitemcontent', [
vnode.attrs.media
? m('a.cover', {
href: vnode.attrs.media.large_url,
}, m('img', { src: vnode.attrs.media.small_url }))
href: '/article/' + vnode.attrs.path,
}, m('img', { src: vnode.attrs.media.medium_url }))
: m('a.cover.nobg'),
m('div.entrycontent', [
(vnode.attrs.description

View file

@ -6,6 +6,7 @@ exports.up = function up(knex, Promise) {
table.increments()
table.text('email')
table.text('fullname')
table.text('password')
table.boolean('is_deleted')
.notNullable()
.default(false)

View file

@ -44,6 +44,7 @@
"homepage": "https://github.com/nfp-projects/nfp_moe",
"dependencies": {
"@koa/cors": "^2.2.3",
"bcrypt": "^3.0.0",
"bookshelf": "^0.15.1",
"bunyan-lite": "^1.0.1",
"format-link-header": "^2.1.0",