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"
|
"test": "test"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -20,7 +28,31 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/nfp-projects/nfp_moe",
|
"homepage": "https://github.com/nfp-projects/nfp_moe",
|
||||||
"dependencies": {
|
"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": {
|
"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