Bootstrapped everything kinda

This commit is contained in:
Jonatan Nilsson 2019-02-19 11:34:52 +00:00
parent f17edde48f
commit e57af31b08
26 changed files with 1260 additions and 1 deletions

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
.circleci
node_modules
public

83
.eslintrc Normal file
View file

@ -0,0 +1,83 @@
{
"parserOptions": {
"ecmaVersion": 9,
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true
}
},
"extends": "eslint:recommended",
"plugins": [
"mocha"
],
"env": {
"mocha": true,
"node": true,
"es6": true
},
"rules": {
"mocha/no-exclusive-tests": 2,
"require-await": 0,
"array-callback-return": 2,
"block-scoped-var": 2,
"complexity": ["error", 20],
"eqeqeq": [2, "smart"],
"no-else-return": ["error", { "allowElseIf": false }],
"no-extra-bind": 2,
"no-implicit-coercion": 2,
"no-invalid-this": 2,
"no-loop-func": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-new": 2,
"no-param-reassign": [2, {"props": false}],
"no-return-assign": 2,
"no-return-await": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-throw-literal": 2,
"no-unmodified-loop-condition": 2,
"no-useless-call": 2,
"no-useless-concat": 2,
"no-useless-return": 2,
"no-void": 2,
"no-warning-comments": 2,
"prefer-promise-reject-errors": 2,
"no-catch-shadow": 2,
"no-shadow": 2,
"no-undef-init": 2,
"no-undefined": 0,
"no-use-before-define": 2,
"no-new-require": 2,
"no-sync": 2,
"array-bracket-newline": [2, "consistent"],
"block-spacing": [2, "always"],
"brace-style": [2, "1tbs"],
"comma-dangle": [2, "always-multiline"],
"comma-spacing": 2,
"comma-style": 2,
"computed-property-spacing": 2,
"eol-last": 2,
"func-call-spacing": 2,
"key-spacing": 2,
"keyword-spacing": 2,
"semi": [2, "never"],
"max-len": [1, 120],
"prefer-const": 0,
"consistent-return": 0,
"no-unused-vars": [
2,
{
"args": "after-used",
"argsIgnorePattern": "next|res|req"
}
],
"generator-star-spacing": 0,
"global-require": 0,
"import/prefer-default-export": 0,
"no-underscore-dangle": 0,
"strict": 0,
"require-yield": 0
}
}

63
.gitignore vendored Normal file
View file

@ -0,0 +1,63 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# Local development config file
config/config.json
package-lock.json

29
api/access/index.mjs Normal file
View file

@ -0,0 +1,29 @@
import _ from 'lodash'
const levels = {
Normal: 1,
Manager: 10,
Admin: 100,
}
levels.is = function is(ctx, level) {
if (!_.isInteger(level)) throw new Error('AccessLevelDenied')
if (!ctx.state.user || !ctx.state.user.level) return false
if (ctx.state.user.level !== level) return false
return true
}
levels.atLeast = function atLeast(ctx, level) {
if (!_.isInteger(level)) throw new Error('AccessLevelDenied')
if (!ctx.state.user || !ctx.state.user.level) return false
if (ctx.state.user.level < level) return false
return true
}
levels.ensure = function ensure(ctx, level) {
if (!levels.atLeast(ctx, level)) {
throw new Error('AccessLevelDenied')
}
}
export default levels

31
api/access/middleware.mjs Normal file
View file

@ -0,0 +1,31 @@
import orgAccess from './index'
export function accessChecks(opts = { }) {
const access = opts.access || orgAccess
return (ctx, next) => {
ctx.state.is = access.is.bind(access, ctx)
ctx.state.atLeast = access.atLeast.bind(access, ctx)
ctx.state.ensure = access.ensure.bind(access, ctx)
return next()
}
}
export function restrict(level = orgAccess.Normal) {
return async (ctx, next) => {
if (!ctx.headers.authorization) {
return ctx.throw(403, 'Authentication token was not found (did you forget to login?)')
}
if (!ctx.state.user || !ctx.state.user.id || !ctx.state.user.level) {
return ctx.throw(403, 'You must be authenticated to access this resource')
}
if (!ctx.state.atLeast(level)) {
return ctx.throw(403, 'You do not have enough access to access this resource')
}
return next()
}
}

370
api/bookshelf.mjs Normal file
View file

@ -0,0 +1,370 @@
import _ from 'lodash'
import knex from 'knex'
import bookshelf from 'bookshelf'
import config from './config'
import defaults from './defaults'
import log from './log'
let host = config.get('knex:connection')
log.info(host, 'Connecting to DB')
const client = knex(config.get('knex'))
// Check if we're running tests while connected to
// potential production environment.
/* istanbul ignore if */
if (config.get('NODE_ENV') === 'test' &&
(config.get('knex:connection:database') !== 'kisildalur_test' ||
config.get('knex:connection:connection'))) {
// There is an offchance that we're running tests on
// production database. Exit NOW!
log.error('Critical: potentially running test on production enviroment. Shutting down.')
process.exit(1)
}
const shelf = bookshelf(client)
shelf.plugin('virtuals')
shelf.plugin('pagination')
// Helper method to create models
shelf.createModel = (attr, opts) => {
// Create default attributes to all models
let attributes = defaults(attr, {
/**
* Always include created_at and updated_at for all models default.
*/
hasTimestamps: true,
/**
* Columns selected in get single queries.
*/
privateFields: ['*'],
/**
* Event handler when fetch() is called. This gets called for both
* when getSingle() or just manual fetch() is called as well as
* when relation models through belongsTo() resources get fetched.
*
* @param {Model} model - The model instance if fetch() was used. For
* belongsTo this is the relation model thingy.
* @param {Array} columns - Array of columns to select if fetch() was used.
* Otherwise this is null.
* @param {Object} options - Options for the fetch. Includes the query
* builder object.
*/
checkFetching(model, columns, options) {
// First override that is_deleted always gets filtered out.
options.query.where({ is_deleted: false })
// If we have columns, fetch() or getSingle is the caller and no
// custom select() was called on the query.
if (columns) {
// We override columns default value of 'table_name.*' select and
// replace it with actual fields. This allows us to hide columns in
// public results.
columns.splice(...[0, columns.length].concat(
model.privateFields.map(item => `${model.tableName}.${item}`)
))
// If we have relatedData in the model object, then we're dealing with a
// belongsTo relation query. If not, then we're dealing with a custom
// fetch() with select() query.
} else if (model.relatedData) {
// We are dealing with belongsTo relation query. Override the default
// 'relation_table.*' with public select columns.
// We override the actual value in the query because doing select()
// does not override or replace the previous value during testing.
let relatedColums = options.query._statements[0].value
// During some Model.relatedDAta() queries, the select statement
// is actually hidden in the third statement so we grab that instead
if (options.query._statements[0].grouping === 'where') {
relatedColums = options.query._statements[2].value
}
relatedColums.splice(...[0, relatedColums.length].concat(
model.relatedData.target.publicFields.map(item => `${model.relatedData.targetTableName}.${item}`)
))
}
},
/**
* Event handler after a fetch() operation and finished.
*
* @param {Model} model - The model instance.
* @param {Object} response - Knex query response.
* @param {Object} options - Options for the fetched.
*/
checkFetched(model, response, options) {
model._ctx = options.ctx
},
/**
* Event handler when fetchALL() is called. This gets called for both
* when getAll() or just manual fetchAll().
*
* @param {CollectionBase} collection - The collection base for the model.
* This does not contain a model
* instance so privateFields is not
* accessible here.
* @param {Array} columns - Array of columns to select if fetchAll() was
* used. Otherwise this is null.
* @param {Object} options - Options for the fetch. Includes the query
* builder object.
*/
checkFetchingCollection(collection, columns, options) {
// I really really apologise for this.
if (!options.query._statements[0] ||
!options.query._statements[0].column ||
!options.query._statements[0].column.indexOf ||
options.query._statements[0].column.indexOf('is_deleted') === -1) {
// First override that is_deleted always gets filtered out.
options.query.where(`${collection.tableName()}.is_deleted`, false)
}
// If we have columns, we're dealing with a normal basic fetchAll() or
// a getAll() caller.
if (columns) {
columns.splice(...[0, columns.length].concat(collection.model.publicFields))
}
},
/**
* Event handler when fetchAll() has been called and fetched.
*
* @param {CollectionBase} collection - The collection that has been fetched.
* @param {Array} columns - Array of columns to select if fetchAll() was
* used. Otherwise this is null.
* @param {Object} options - Options for the fetch.
*/
checkFetchedCollection(collection, columns, options) {
collection.forEach(item => (item._ctx = options.ctx))
},
/**
* Event handler for hasMany relation fetching. This gets called whenever
* hasMany related is being fetched.
*
* @param {CollectionBase} collection - The collection base for the model.
* This does not contain a model
* instance so privateFields is not
* accessible here.
* @param {Array} columns - Array of columns to select. This is
* always null.
* @param {Object} options - Options for the fetch. Includes the query
* builder object.
*/
checkFetchingHasMany(collection, columns, options) {
// First override that is_deleted always gets filtered out.
options.query.where({ is_deleted: false })
// Then we override the actual value in the query because doing select()
// does not override or replace the previous value during testing.
let relatedColums
if (options.query._statements[0].grouping === 'columns') {
relatedColums = options.query._statements[0].value
} else {
relatedColums = options.query._statements[1].value
}
relatedColums.splice(...[0, relatedColums.length]
.concat(collection.model.publicFields.map(
item => `${collection.relatedData.targetTableName}.${item}`
))
)
// check if pagination is being requested and we support it
if (collection.relatedName
&& options.ctx
&& options.ctx.state.pagination
&& options.ctx.state.pagination[collection.relatedName]) {
let pagination = options.ctx.state.pagination[collection.relatedName]
options.query.limit(pagination.perPage).offset((pagination.page - 1) * pagination.perPage)
}
},
/**
* Event handler for belongsTo relation fetching. This gets called whenever
* belongsTo related is being fetched.
*
* @param {CollectionBase} collection - The collection base for the model.
* This does not contain a model
* instance so privateFields is not
* accessible here.
* @param {Array} columns - Array of columns to select. This is
* always null.
* @param {Object} options - Options for the fetch. Includes the query
* builder object.
*/
checkFetchingBelongs(model, columns, options) {
// First override that is_deleted always gets filtered out.
options.query.where({ is_deleted: false })
// Then we override the actual value in the query because doing select()
// does not override or replace the previous value during testing.
// The difference between belongsTo and hasMany is in belongsTo, the
// actual 'table_name.*' value is in the second item in _statements as
// opposed to the first.
let relatedColums = options.query._statements[1].value
relatedColums.splice(...[0, relatedColums.length].concat(
model.model.publicFields.map(item => `${model.relatedData.targetTableName}.${item}`)
))
// check if pagination is being requested and we support it
if (model.relatedName
&& options.ctx
&& options.ctx.state.pagination
&& options.ctx.state.pagination[model.relatedName]) {
let pagination = options.ctx.state.pagination[model.relatedName]
options.query.limit(pagination.perPage).offset((pagination.page - 1) * pagination.perPage)
}
},
/**
* Initialize a new instance of model. This does not get called when
* relations to this model is being fetched though.
*/
initialize() {
this.on('fetching', this.checkFetching)
this.on('fetched', this.checkFetched)
this.on('fetching:collection', this.checkFetchingCollection)
this.on('fetched:collection', this.checkFetchedCollection)
},
/**
* Define a hasMany relations with the model. This version as opposed to
* the default hasMany has filtering enabled to filter is_deleted items
* out among other things.
*/
hasManyFiltered(model, relatedName, foreignKey) {
let out = this.hasMany(model, foreignKey)
// Hook to the fetching event on the relation
out.on('fetching', this.checkFetchingHasMany)
out.on('fetched', this.checkFetched)
// Add related name if specified to add pagination support
out.relatedName = relatedName
return out
},
/**
* Define belongsToMany relations with the model. This version as opposed
* to the default belongsToMany has filtering enabled to filter is_deleted items
* out among other things.
*/
belongsToManyFiltered(model, table, foreignKey, otherKey, relatedName) {
let out = this.belongsToMany(model, table, foreignKey, otherKey)
// Hook to the fetching event on the relation
out.on('fetching', this.checkFetchingBelongs)
out.on('fetched', this.checkFetched)
// Add related name if specified to add pagination support
out.relatedName = relatedName
return out
},
})
// Create default options for all models
let options = defaults(opts, {
/**
* Columns selected in get many queries and relation queries.
*/
publicFields: ['*'],
/**
* Create new model object in database.
*
* @param {Object} data - The values the new model should have
* @return {Model} The resulted model
*/
create(data) {
return this.forge(data).save()
},
/**
* Apply basic filtering to query builder object. Basic filtering
* applies stuff like custom filtering in the query and ordering and other stuff
*
* @param {Request} ctx - API Request object
* @param {QueryBuilder} qb - knex query builder object to apply filtering on
* @param {Object} [where={}] - Any additional filtering
* @param {string} [orderBy=id] - property to order result by
* @param {Object[]} [properties=[]] - Properties allowed to filter by from query
*/
_baseQueryAll(ctx, qb, where = {}, orderBy = 'id', properties = []) {
let orderProperty = orderBy
let sort = 'ASC'
if (orderProperty[0] === '-') {
orderProperty = orderProperty.slice(1)
sort = 'DESC'
}
qb.where(where)
_.forOwn(ctx.state.filter.where(properties), (value, key) => {
if (key.startsWith('is_')) {
qb.where(key, value === '0' ? false : true)
} else {
qb.where(key, 'LIKE', `%${value}%`)
}
})
_.forOwn(ctx.state.filter.whereNot(properties), (value, key) => {
if (key.startsWith('is_')) {
qb.whereNot(key, value === '0' ? false : true)
} else {
qb.where(key, 'NOT LIKE', `%${value}%`)
}
})
qb.orderBy(orderProperty, sort)
},
/**
* Wrapper for _baseQueryAll that can be overridden.
*/
baseQueryAll(ctx, qb, where, orderBy, properties) {
return this._baseQueryAll(ctx, qb, where, orderBy, properties)
},
getSingle(id, withRelated = [], require = true, ctx = null) {
let where = { id: Number(id) || 0 }
return this.query({ where })
.fetch({ require, withRelated, ctx })
},
getAll(ctx, where = {}, withRelated = [], orderBy = 'id') {
return this.query(qb => {
this.baseQueryAll(ctx, qb, where, orderBy)
})
.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
})
},
})
return shelf.Model.extend(attributes, options)
}
shelf.safeColumns = (extra) =>
['id', 'is_deleted', 'created_at', 'updated_at'].concat(extra || [])
export default shelf

81
api/config.mjs Normal file
View file

@ -0,0 +1,81 @@
import _ from 'lodash'
import nconf from 'nconf'
import { readFileSync } from 'fs'
// Helper method for global usage.
nconf.inTest = () => nconf.get('NODE_ENV') === 'test'
// Config follow the following priority check order:
// 1. Enviroment variables
// 2. package.json
// 3. config/config.json
// 4. config/config.default.json
// Load enviroment variables as first priority
nconf.env({
separator: '__',
whitelist: [
'DATABASE_URL',
'NODE_ENV',
'server__port',
'server__host',
'knex__connection__host',
'knex__connection__user',
'knex__connection__database',
'knex__connection__password',
'bunyan__name',
'frontend__url',
'jwt__secret',
'sessionsecret',
'bcrypt',
'name',
'NODE_VERSION',
],
parseValues: true,
})
// Load package.json for name and such
let pckg = JSON.parse(readFileSync('./package.json'))
pckg = _.pick(pckg, ['name', 'version', 'description', 'author', 'license', 'homepage'])
if (nconf.get('DATABASE_URL')) {
pckg.knex = { connection: nconf.get('DATABASE_URL') }
}
// Load overrides as second priority
nconf.overrides(pckg)
// Load any overrides from the appropriate config file
let configFile = 'config/config.json'
/* istanbul ignore else */
if (nconf.get('NODE_ENV') === 'test') {
configFile = 'config/config.test.json'
}
/* istanbul ignore if */
if (nconf.get('NODE_ENV') === 'production') {
configFile = 'config/config.production.json'
}
nconf.file('main', configFile)
// Load defaults
nconf.file('default', 'config/config.default.json')
// Final sanity checks
/* istanbul ignore if */
if (typeof global.it === 'function' & !nconf.inTest()) {
// eslint-disable-next-line no-console
console.log('Critical: potentially running test on production enviroment. Shutting down.')
process.exit(1)
}
export default nconf

34
api/defaults.mjs Normal file
View file

@ -0,0 +1,34 @@
// taken from isobject npm library
function isObject(val) {
return val != null && typeof val === 'object' && Array.isArray(val) === false
}
export default function defaults(options, def) {
let out = { }
if (options) {
Object.keys(options || {}).forEach(key => {
out[key] = options[key]
if (Array.isArray(out[key])) {
out[key] = out[key].map(item => {
if (isObject(item)) return defaults(item)
return item
})
} else if (out[key] && typeof out[key] === 'object') {
out[key] = defaults(options[key], def && def[key])
}
})
}
if (def) {
Object.keys(def).forEach(function(key) {
if (typeof out[key] === 'undefined') {
out[key] = def[key]
}
})
}
return out
}

31
api/error/middleware.mjs Normal file
View file

@ -0,0 +1,31 @@
import createError from 'http-errors'
export function errorHandler() {
return async (ctx, next) => {
try {
await next()
} catch (org) {
let error = org
if (error.message === 'EmptyResponse') {
error = createError(404)
} else if (error.message === 'AccessLevelDenied') {
error = createError(403)
}
if (!error.status) {
ctx.log.error(error)
error = createError(500, `Unknown error occured: ${error.message}`)
ctx.log.warn(error)
} else {
ctx.log.warn(error)
}
ctx.status = error.status
ctx.body = {
status: error.status,
message: error.message,
body: error.body || { },
}
}
}
}

66
api/jwt.mjs Normal file
View file

@ -0,0 +1,66 @@
import _ from 'lodash'
import jwt from 'jsonwebtoken'
import koaJwt from 'koa-jwt'
import Staff from './staff/model'
import config from './config'
export default class Jwt {
constructor(opts = {}) {
Object.assign(this, {
Staff: opts.Staff || Staff,
jwt: opts.jwt || jwt,
})
}
sign(value, appendSecret = '', opts) {
let secret = config.get('jwt:secret') + appendSecret
let options = _.defaults(opts, config.get('jwt:options'))
if (options.expiresIn === null) {
delete options.expiresIn
}
return this.jwt.sign(value, secret, options)
}
signDirect(value, secret) {
return this.jwt.sign(value, secret)
}
verify(token, appendSecret = '') {
let secret = config.get('jwt:secret') + appendSecret
return new Promise((resolve, reject) =>
this.jwt.verify(token, secret, (err, res) => {
if (err) return reject(err)
resolve(res)
})
)
}
decode(token) {
return this.jwt.decode(token)
}
createStaffToken(staff, 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
}
static jwtMiddleware() {
return koaJwt({
secret: (header, payload) =>
Staff.getSingle(payload.id)
.then(staff => `${config.get('jwt:secret')}${staff.get('password')}`),
passthrough: true,
})
}
}

28
api/log.mjs Normal file
View file

@ -0,0 +1,28 @@
import bunyan from 'bunyan-lite'
import config from './config'
import * as defaults from './defaults'
// Clone the settings as we will be touching
// on them slightly.
let settings = defaults.default(config.get('bunyan'))
// Replace any instance of 'process.stdout' with the
// actual reference to the process.stdout.
for (let i = 0; i < settings.streams.length; i++) {
/* istanbul ignore else */
if (settings.streams[i].stream === 'process.stdout') {
settings.streams[i].stream = process.stdout
}
}
// Create our logger.
const log = bunyan.createLogger(settings)
export default log
log.logMiddleware = () =>
(ctx, next) => {
ctx.log = log
return next()
}

35
api/parser/middleware.mjs Normal file
View file

@ -0,0 +1,35 @@
import format from 'format-link-header'
import * as pagination from './pagination'
export default class ParserMiddleware {
constructor(opts = {}) {
Object.assign(this, {
pagination: opts.pagination || pagination,
format: opts.format || format,
})
}
contextParser() {
return (ctx, next) => {
ctx.state.pagination = this.pagination.parsePagination(ctx)
ctx.state.filter = this.pagination.parseFilter(ctx)
return next()
}
}
generateLinks() {
return async (ctx, next) => {
await next()
if (ctx.state.pagination.total > 0) {
ctx.set('Link', this.format(this.pagination.generateLinks(ctx, ctx.state.pagination.total)))
}
if (ctx.state.pagination.total != null) {
ctx.set('pagination_total', ctx.state.pagination.total)
}
}
}
}

129
api/parser/pagination.mjs Normal file
View file

@ -0,0 +1,129 @@
import _ from 'lodash'
import { format } from 'url'
import config from '../config'
function limit(value, min, max, fallback) {
let out = parseInt(value, 10)
if (!out) {
out = fallback
}
if (out < min) {
out = min
}
if (out > max) {
out = max
}
return out
}
export function parsePagination(ctx) {
let out = {
perPage: limit(ctx.query.perPage, 1, 1500, 1250),
page: limit(ctx.query.page, 1, Number.MAX_SAFE_INTEGER, 1),
}
Object.keys(ctx.query).forEach(item => {
if (item.startsWith('perPage.')) {
let name = item.substring(8)
out[name] = {
perPage: limit(ctx.query[`perPage.${name}`], 1, 1500, 1250),
page: limit(ctx.query[`page.${name}`], 1, Number.MAX_SAFE_INTEGER, 1),
}
}
})
return out
}
export function parseFilter(ctx) {
let where
let whereNot
where = _.omitBy(ctx.query, test => test[0] === '!')
whereNot = _.pickBy(ctx.query, test => test[0] === '!')
whereNot = _.transform(
whereNot,
(result, value, key) => (result[key] = value.slice(1))
)
return {
where: pick => _.pick(where, pick),
whereNot: pick => _.pick(whereNot, pick),
includes: (ctx.query.includes && ctx.query.includes.split(',')) || [],
}
}
export function generateLinks(ctx, total) {
let out = []
let base = _(ctx.query)
.omit(['page'])
.transform((res, val, key) => res.push(`${key}=${val}`), [])
.value()
if (!ctx.query.perPage) {
base.push(`perPage=${ctx.state.pagination.perPage}`)
}
// let protocol = ctx.protocol
// if (config.get('frontend:url').startsWith('https')) {
// protocol = 'https'
// }
let proto = ctx.protocol
if (config.get('frontend:url').startsWith('https')) {
proto = 'https'
}
let first = format({
protocol: proto,
host: ctx.host,
pathname: ctx.path,
})
first += `?${base.join('&')}`
// Add the current page first
out.push({
rel: 'current',
title: `Page ${ctx.query.page || 1}`,
url: `${first}`,
})
// Then add any previous pages if we can
if (ctx.state.pagination.page > 1) {
out.push({
rel: 'previous',
title: 'Previous',
url: `${first}&page=${ctx.state.pagination.page - 1}`,
})
out.push({
rel: 'first',
title: 'First',
url: `${first}&page=1`,
})
}
// Then add any next pages if we can
if ((ctx.state.pagination.perPage * (ctx.state.pagination.page - 1)) + ctx.state.pagination.perPage < total) {
out.push({
rel: 'next',
title: 'Next',
url: `${first}&page=${ctx.state.pagination.page + 1}`,
})
out.push({
rel: 'last',
title: 'Last',
url: `${first}&page=${Math.ceil(total / ctx.state.pagination.perPage)}`,
})
}
return out
}

14
api/router.mjs Normal file
View file

@ -0,0 +1,14 @@
/* eslint max-len: 0 */
import Router from 'koa-router'
// import access from './access'
// import AuthRoutes from './authentication/routes'
// import { restrict } from './access/middleware'
const router = new Router()
// API Authentication
// const authentication = new AuthRoutes()
// router.post('/api/login', authentication.login.bind(authentication))
export default router

21
api/serve.mjs Normal file
View file

@ -0,0 +1,21 @@
import send from 'koa-send'
export function serve(docRoot, pathname, options = {}) {
options.root = docRoot
return (ctx, next) => {
if (ctx.request.method === 'OPTIONS') return
let filepath = ctx.path.replace(pathname, '')
if (filepath === '/') {
filepath = '/index.html'
}
return send(ctx, filepath, options).catch((er) => {
if (er.code === 'ENOENT' && er.status === 404) {
return send(ctx, '/index.html', options)
}
})
}
}

28
api/setup.mjs Normal file
View file

@ -0,0 +1,28 @@
import _ from 'lodash'
import config from './config'
import log from './log'
import knex from 'knex'
// This is important for setup to run cleanly.
let knexConfig = _.cloneDeep(config.get('knex'))
knexConfig.pool = { min: 1, max: 1 }
let knexSetup = knex(knexConfig)
export default function setup() {
log.info(knexConfig, 'Running database integrity scan.')
return knexSetup.migrate.latest({
directory: './migrations',
})
.then((result) => {
if (result[1].length === 0) {
return log.info('Database is up to date')
}
for (let i = 0; i < result[1].length; i++) {
log.info('Applied migration from', result[1][i].substr(result[1][i].lastIndexOf('\\') + 1))
}
return knexSetup.destroy()
})
}

28
api/staff/model.mjs Normal file
View file

@ -0,0 +1,28 @@
import bookshelf from '../bookshelf'
/* Staff model:
{
id,
username,
password,
fullname,
is_deleted,
level,
created_at,
updated_at,
}
*/
const Staff = bookshelf.createModel({
tableName: 'staff',
}, {
// Hide password from any relations and include requests.
publicFields: bookshelf.safeColumns([
'username',
'fullname',
'level',
]),
})
export default Staff

4
app/app.sass Normal file
View file

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

1
app/index.js Normal file
View file

@ -0,0 +1 @@
console.log('Success')

View file

@ -0,0 +1,42 @@
{
"NODE_ENV": "development",
"server": {
"port": 4030,
"host": "0.0.0.0"
},
"knex": {
"client": "pg",
"connection": {
"host" : "127.0.0.1",
"user" : "postgres",
"password" : "postgres",
"database" : "nfpmoe"
},
"migrations": {
}
},
"bunyan": {
"name": "nfpmoe",
"streams": [{
"stream": "process.stdout",
"level": "debug"
}
]
},
"frontend": {
"url": "http://localhost:8080"
},
"jwt": {
"secret": "this-is-my-secret",
"options": {
"expiresIn": 604800
}
},
"sessionsecret": "this-is-session-secret-lol",
"bcrypt": 5,
"fileSize": 524288000,
"upload": {
"name": "nfpmoe-dev",
"secret": "TJlAbWgpQy0zMGu01XoW"
}
}

14
index.mjs Normal file
View file

@ -0,0 +1,14 @@
import log from './api/log'
// Run the database script automatically.
import setup from './api/setup'
setup().then(() =>
import('./server')
).catch((error) => {
import('./api/config').then(module => {
log.error(error, 'Error while preparing database')
log.error({ config: module.default.get() }, 'config used')
process.exit(1)
})
})

View file

@ -0,0 +1,49 @@
/* eslint-disable */
exports.up = function up(knex, Promise) {
return Promise.all([
knex.schema.createTable('staff', function(table) {
table.increments()
table.text('email')
table.text('fullname')
table.boolean('is_deleted')
.notNullable()
.default(false)
table.integer('level')
.notNullable()
.defaultTo(1)
table.timestamps()
})
.then(pass =>
knex('staff').insert({
email: 'jonatan@nilsson.is',
fullname: 'Admin',
level: 100,
})
),
knex.schema.createTable('media', function(table) {
table.increments()
table.text('filename')
table.text('filetype')
table.text('small_image')
table.text('medium_image')
table.text('large_image')
table.integer('size')
table.integer('login_id')
.references('staff.id')
table.integer('staff_id')
.references('staff.id')
table.boolean('is_deleted')
.notNullable()
.default(false)
table.timestamps()
}),
])
}
exports.down = function down(knex, Promise) {
return Promise.all([
knex.schema.dropTable('media'),
knex.schema.dropTable('staff'),
])
}

View file

@ -7,7 +7,15 @@
"test": "test"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"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",
"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",
"dev": "run-p watch:api watch:app watch:sass",
"prod": "npm run build && npm start"
},
"repository": {
"type": "git",
@ -20,7 +28,31 @@
},
"homepage": "https://github.com/nfp-projects/nfp_moe",
"dependencies": {
"@koa/cors": "^2.2.3",
"bookshelf": "^0.14.2",
"bunyan-lite": "^1.0.1",
"format-link-header": "^2.1.0",
"http-errors": "^1.7.2",
"jsonwebtoken": "^8.4.0",
"knex": "^0.16.3",
"koa": "^2.7.0",
"koa-bodyparser": "^4.2.1",
"koa-jwt": "^3.5.1",
"koa-router": "^7.4.0",
"koa-send": "^5.0.0",
"lodash": "^4.17.11",
"multer": "^1.4.1",
"nconf": "^0.10.0",
"pg": "^7.8.0",
"sharp": "^0.21.3"
},
"devDependencies": {
"browserify": "^16.2.3",
"eslint": "^5.14.1",
"mithril": "^2.0.0-rc.4",
"nodemon": "^1.18.10",
"npm-run-all": "^4.1.5",
"sass": "^1.17.0",
"watchify": "^3.11.0"
}
}

0
public/assets/.gitkeep Normal file
View file

6
public/index.html Normal file
View file

@ -0,0 +1,6 @@
<!doctype html>
<html>
<body>
Works
</body>
</html>

37
server.mjs Normal file
View file

@ -0,0 +1,37 @@
import Koa from 'koa'
import bodyParser from 'koa-bodyparser'
import cors from '@koa/cors'
import config from './api/config'
import router from './api/router'
import Jwt from './api/jwt'
import log from './api/log'
import { serve } from './api/serve'
import { errorHandler } from './api/error/middleware'
import { accessChecks } from './api/access/middleware'
import ParserMiddleware from './api/parser/middleware'
const app = new Koa()
const parser = new ParserMiddleware()
app.use(errorHandler())
app.use(bodyParser())
app.use(parser.contextParser())
app.use(accessChecks())
app.use(parser.generateLinks())
app.use(log.logMiddleware())
app.use(Jwt.jwtMiddleware())
app.use(cors({
exposeHeaders: ['link', 'pagination_total'],
credentials: true,
}))
app.use(router.routes())
app.use(router.allowedMethods())
app.use(serve('./public', '/public'))
const server = app.listen(
config.get('server:port'),
() => log.info(`Server running on port ${config.get('server:port')}`)
)
export default server