Bootstrapped everything kinda
This commit is contained in:
parent
f17edde48f
commit
e57af31b08
26 changed files with 1260 additions and 1 deletions
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
.circleci
|
||||
node_modules
|
||||
public
|
83
.eslintrc
Normal file
83
.eslintrc
Normal 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
63
.gitignore
vendored
Normal 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
29
api/access/index.mjs
Normal 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
31
api/access/middleware.mjs
Normal 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
370
api/bookshelf.mjs
Normal 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
81
api/config.mjs
Normal 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
34
api/defaults.mjs
Normal 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
31
api/error/middleware.mjs
Normal 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
66
api/jwt.mjs
Normal 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
28
api/log.mjs
Normal 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
35
api/parser/middleware.mjs
Normal 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
129
api/parser/pagination.mjs
Normal 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
14
api/router.mjs
Normal 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
21
api/serve.mjs
Normal 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
28
api/setup.mjs
Normal 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
28
api/staff/model.mjs
Normal 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
4
app/app.sass
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
body
|
||||
margin: 0
|
||||
padding: 0
|
1
app/index.js
Normal file
1
app/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
console.log('Success')
|
42
config/config.default.json
Normal file
42
config/config.default.json
Normal 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
14
index.mjs
Normal 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)
|
||||
})
|
||||
})
|
49
migrations/20190219105500_base.js
Normal file
49
migrations/20190219105500_base.js
Normal 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'),
|
||||
])
|
||||
}
|
34
package.json
34
package.json
|
@ -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
0
public/assets/.gitkeep
Normal file
6
public/index.html
Normal file
6
public/index.html
Normal file
|
@ -0,0 +1,6 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
Works
|
||||
</body>
|
||||
</html>
|
37
server.mjs
Normal file
37
server.mjs
Normal 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
|
Loading…
Reference in a new issue