diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..c80ff61 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +.circleci +node_modules +public diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..1b07e26 --- /dev/null +++ b/.eslintrc @@ -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 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dca22c --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/api/access/index.mjs b/api/access/index.mjs new file mode 100644 index 0000000..69961c8 --- /dev/null +++ b/api/access/index.mjs @@ -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 diff --git a/api/access/middleware.mjs b/api/access/middleware.mjs new file mode 100644 index 0000000..b058f90 --- /dev/null +++ b/api/access/middleware.mjs @@ -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() + } +} diff --git a/api/bookshelf.mjs b/api/bookshelf.mjs new file mode 100644 index 0000000..1527c7b --- /dev/null +++ b/api/bookshelf.mjs @@ -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 diff --git a/api/config.mjs b/api/config.mjs new file mode 100644 index 0000000..f4a8a53 --- /dev/null +++ b/api/config.mjs @@ -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 diff --git a/api/defaults.mjs b/api/defaults.mjs new file mode 100644 index 0000000..a2ef666 --- /dev/null +++ b/api/defaults.mjs @@ -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 +} diff --git a/api/error/middleware.mjs b/api/error/middleware.mjs new file mode 100644 index 0000000..515ed97 --- /dev/null +++ b/api/error/middleware.mjs @@ -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 || { }, + } + } + } +} diff --git a/api/jwt.mjs b/api/jwt.mjs new file mode 100644 index 0000000..e59fcf9 --- /dev/null +++ b/api/jwt.mjs @@ -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, + }) + } +} diff --git a/api/log.mjs b/api/log.mjs new file mode 100644 index 0000000..2ecca8d --- /dev/null +++ b/api/log.mjs @@ -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() + } diff --git a/api/parser/middleware.mjs b/api/parser/middleware.mjs new file mode 100644 index 0000000..a15843c --- /dev/null +++ b/api/parser/middleware.mjs @@ -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) + } + } + } +} diff --git a/api/parser/pagination.mjs b/api/parser/pagination.mjs new file mode 100644 index 0000000..fc393cb --- /dev/null +++ b/api/parser/pagination.mjs @@ -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 +} diff --git a/api/router.mjs b/api/router.mjs new file mode 100644 index 0000000..aa89c79 --- /dev/null +++ b/api/router.mjs @@ -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 diff --git a/api/serve.mjs b/api/serve.mjs new file mode 100644 index 0000000..ac92d5a --- /dev/null +++ b/api/serve.mjs @@ -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) + } + }) + } +} diff --git a/api/setup.mjs b/api/setup.mjs new file mode 100644 index 0000000..8250c4d --- /dev/null +++ b/api/setup.mjs @@ -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() + }) +} diff --git a/api/staff/model.mjs b/api/staff/model.mjs new file mode 100644 index 0000000..bd383c3 --- /dev/null +++ b/api/staff/model.mjs @@ -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 diff --git a/app/app.sass b/app/app.sass new file mode 100644 index 0000000..98cebb5 --- /dev/null +++ b/app/app.sass @@ -0,0 +1,4 @@ + +body + margin: 0 + padding: 0 diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..a92ff52 --- /dev/null +++ b/app/index.js @@ -0,0 +1 @@ +console.log('Success') \ No newline at end of file diff --git a/config/config.default.json b/config/config.default.json new file mode 100644 index 0000000..88e743d --- /dev/null +++ b/config/config.default.json @@ -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" + } +} diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..f699fbd --- /dev/null +++ b/index.mjs @@ -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) + }) +}) diff --git a/migrations/20190219105500_base.js b/migrations/20190219105500_base.js new file mode 100644 index 0000000..84fb88e --- /dev/null +++ b/migrations/20190219105500_base.js @@ -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'), + ]) +} diff --git a/package.json b/package.json index 2e42967..8531fa6 100644 --- a/package.json +++ b/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" } } diff --git a/public/assets/.gitkeep b/public/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..cf1254d --- /dev/null +++ b/public/index.html @@ -0,0 +1,6 @@ + + + + Works + + diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..bc3ed8f --- /dev/null +++ b/server.mjs @@ -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