This commit is contained in:
Jamie Curnow 2018-06-20 08:47:26 +10:00
parent 9e919c3c24
commit 80d78cbf25
39 changed files with 2837 additions and 0 deletions

95
src/backend/app.js Normal file
View File

@ -0,0 +1,95 @@
'use strict';
const path = require('path');
const express = require('express');
const bodyParser = require('body-parser');
const compression = require('compression');
const log = require('./logger').express;
/**
* App
*/
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
// Gzip
app.use(compression());
/**
* General Logging, BEFORE routes
*/
app.disable('x-powered-by');
app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.enable('strict routing');
// pretty print JSON when not live
if (process.env.NODE_ENV !== 'production') {
app.set('json spaces', 2);
}
// set the view engine to ejs
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '/views'));
// CORS for everything
app.use(require('./lib/express/cors'));
// General security/cache related headers + server header
app.use(function (req, res, next) {
res.set({
'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload',
'X-XSS-Protection': '0',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate',
Pragma: 'no-cache',
Expires: 0
});
next();
});
// ATTACH JWT value - FOR ANY RATE LIMITERS and JWT DECODE
app.use(require('./lib/express/jwt')());
/**
* Routes
*/
app.use('/assets', express.static('dist/assets'));
app.use('/css', express.static('dist/css'));
app.use('/fonts', express.static('dist/fonts'));
app.use('/images', express.static('dist/images'));
app.use('/js', express.static('dist/js'));
app.use('/api', require('./routes/api/main'));
app.use('/', require('./routes/main'));
// production error handler
// no stacktraces leaked to user
app.use(function (err, req, res, next) {
let payload = {
error: {
code: err.status,
message: err.public ? err.message : 'Internal Error'
}
};
if (process.env.NODE_ENV === 'development') {
payload.debug = {
stack: typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null,
previous: err.previous
};
}
// Not every error is worth logging - but this is good for now until it gets annoying.
if (typeof err.stack !== 'undefined' && err.stack) {
log.warn(err.stack);
}
res
.status(err.status || 500)
.send(payload);
});
module.exports = app;

23
src/backend/db.js Normal file
View File

@ -0,0 +1,23 @@
'use strict';
let config = require('config');
if (!config.has('database')) {
throw new Error('Database config does not exist! Read the README for instructions.');
}
let knex = require('knex')({
client: config.database.engine,
connection: {
host: config.database.host,
user: config.database.user,
password: config.database.password,
database: config.database.name,
port: config.database.port
},
migrations: {
tableName: 'migrations'
}
});
module.exports = knex;

45
src/backend/index.js Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env node
'use strict';
const config = require('config');
const app = require('./app');
const logger = require('./logger').global;
const migrate = require('./migrate');
const setup = require('./setup');
const apiValidator = require('./lib/validator/api');
let port = process.env.PORT || 81;
if (config.has('port')) {
port = config.get('port');
}
function appStart () {
return migrate.latest()
.then(() => {
return setup();
})
.then(() => {
return apiValidator.loadSchemas;
})
.then(() => {
const server = app.listen(port, () => {
logger.info('PID ' + process.pid + ' listening on port ' + port + ' ...');
process.on('SIGTERM', () => {
logger.info('PID ' + process.pid + ' received SIGTERM');
server.close(() => {
logger.info('Stopping.');
process.exit(0);
});
});
});
})
.catch(err => {
logger.error(err.message);
setTimeout(appStart, 1000);
});
}
appStart();

View File

@ -0,0 +1,166 @@
'use strict';
const _ = require('lodash');
const error = require('../lib/error');
const userModel = require('../models/user');
const authModel = require('../models/auth');
const helpers = require('../lib/helpers');
const TokenModel = require('../models/token');
module.exports = {
/**
* @param {Object} data
* @param {String} data.identity
* @param {String} data.secret
* @param {String} [data.scope]
* @param {String} [data.expiry]
* @param {String} [issuer]
* @returns {Promise}
*/
getTokenFromEmail: (data, issuer) => {
let Token = new TokenModel();
data.scope = data.scope || 'user';
data.expiry = data.expiry || '30d';
return userModel
.query()
.where('email', data.identity)
.andWhere('is_deleted', 0)
.andWhere('is_disabled', 0)
.first()
.then(user => {
if (user) {
// Get auth
return authModel
.query()
.where('user_id', '=', user.id)
.where('type', '=', 'password')
.first()
.then(auth => {
if (auth) {
return auth.verifyPassword(data.secret)
.then(valid => {
if (valid) {
if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) {
// The scope requested doesn't exist as a role against the user,
// you shall not pass.
throw new error.AuthError('Invalid scope: ' + data.scope);
}
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
return Token.create({
iss: issuer || 'api',
attrs: {
id: user.id
},
scope: [data.scope]
}, {
expiresIn: expiry.unix()
})
.then(signed => {
return {
token: signed.token,
expires: expiry.toISOString()
};
});
} else {
throw new error.AuthError('Invalid password');
}
});
} else {
throw new error.AuthError('No password auth for user');
}
});
} else {
throw new error.AuthError('No relevant user found');
}
});
},
/**
* @param {Access} access
* @param {Object} [data]
* @param {String} [data.expiry]
* @param {String} [data.scope] Only considered if existing token scope is admin
* @returns {Promise}
*/
getFreshToken: (access, data) => {
let Token = new TokenModel();
data = data || {};
data.expiry = data.expiry || '30d';
if (access && access.token.get('attrs').id) {
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
let token_attrs = {
id: access.token.get('attrs').id
};
// Only admins can request otherwise scoped tokens
let scope = access.token.get('scope');
if (data.scope && access.token.hasScope('admin')) {
scope = [data.scope];
if (data.scope === 'job-board' || data.scope === 'worker') {
token_attrs.id = 0;
}
}
return Token.create({
iss: 'api',
scope: scope,
attrs: token_attrs
}, {
expiresIn: expiry.unix()
})
.then(signed => {
return {
token: signed.token,
expires: expiry.toISOString()
};
});
} else {
throw new error.AssertionFailedError('Existing token contained invalid user data');
}
},
/**
* @param {Object} user
* @returns {Promise}
*/
getTokenFromUser: user => {
let Token = new TokenModel();
let expiry = helpers.parseDatePeriod('1d');
return Token.create({
iss: 'api',
attrs: {
id: user.id
},
scope: ['user']
}, {
expiresIn: expiry.unix()
})
.then(signed => {
return {
token: signed.token,
expires: expiry.toISOString(),
user: user
};
});
}
};

View File

@ -0,0 +1,382 @@
'use strict';
const _ = require('lodash');
const error = require('../lib/error');
const userModel = require('../models/user');
const authModel = require('../models/auth');
const gravatar = require('gravatar');
const internalToken = require('./token');
function omissions () {
return ['is_deleted'];
}
const internalUser = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: (access, data) => {
let auth = data.auth;
delete data.auth;
data.avatar = data.avatar || '';
data.roles = data.roles || [];
if (typeof data.is_disabled !== 'undefined') {
data.is_disabled = data.is_disabled ? 1 : 0;
}
return access.can('users:create', data)
.then(() => {
data.avatar = gravatar.url(data.email, {default: 'mm'});
return userModel
.query()
.omit(omissions())
.insertAndFetch(data);
})
.then(user => {
return authModel
.query()
.insert({
user_id: user.id,
type: auth.type,
secret: auth.secret,
meta: {}
})
.then(() => {
return internalUser.get(access, {id: user.id, expand: ['services']});
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.name]
* @return {Promise}
*/
update: (access, data) => {
if (typeof data.is_disabled !== 'undefined') {
data.is_disabled = data.is_disabled ? 1 : 0;
}
return access.can('users:update', data.id)
.then(() => {
// Make sure that the user being updated doesn't change their email to another user that is already using it
// 1. get user we want to update
return internalUser.get(access, {id: data.id})
.then(user => {
// 2. if email is to be changed, find other users with that email
if (typeof data.email !== 'undefined') {
data.email = data.email.toLowerCase().trim();
if (user.email !== data.email) {
return internalUser.isEmailAvailable(data.email, data.id)
.then(available => {
if (!available) {
throw new error.ValidationError('Email address already in use - ' + data.email);
}
return user;
});
}
}
// No change to email:
return user;
});
})
.then(user => {
if (user.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
}
data.avatar = gravatar.url(data.email || user.email, {default: 'mm'});
return userModel
.query()
.omit(omissions())
.patchAndFetchById(user.id, data)
.then(saved_user => {
return _.omit(saved_user, omissions());
});
})
.then(() => {
return internalUser.get(access, {id: data.id, expand: ['services']});
});
},
/**
* @param {Access} access
* @param {Object} [data]
* @param {Integer} [data.id] Defaults to the token user
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @return {Promise}
*/
get: (access, data) => {
if (typeof data === 'undefined') {
data = {};
}
if (typeof data.id === 'undefined' || !data.id) {
data.id = access.token.get('attrs').id;
}
return access.can('users:get', data.id)
.then(() => {
let query = userModel
.query()
.where('is_deleted', 0)
.andWhere('id', data.id)
.first();
// Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) {
query.omit(data.omit);
}
if (typeof data.expand !== 'undefined' && data.expand !== null) {
query.eager('[' + data.expand.join(', ') + ']');
}
return query;
})
.then(row => {
if (row) {
return _.omit(row, omissions());
} else {
throw new error.ItemNotFoundError(data.id);
}
});
},
/**
* Checks if an email address is available, but if a user_id is supplied, it will ignore checking
* against that user.
*
* @param email
* @param user_id
*/
isEmailAvailable: (email, user_id) => {
let query = userModel
.query()
.where('email', '=', email.toLowerCase().trim())
.where('is_deleted', 0)
.first();
if (typeof user_id !== 'undefined') {
query.where('id', '!=', user_id);
}
return query
.then(user => {
return !user;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: (access, data) => {
return access.can('users:delete', data.id)
.then(() => {
return internalUser.get(access, {id: data.id});
})
.then(user => {
if (!user) {
throw new error.ItemNotFoundError(data.id);
}
// Make sure user can't delete themselves
if (user.id === access.token.get('attrs').id) {
throw new error.PermissionError('You cannot delete yourself.');
}
return userModel
.query()
.where('id', user.id)
.patch({
is_deleted: 1
});
})
.then(() => {
return true;
});
},
/**
* This will only count the users
*
* @param {Access} access
* @param {String} [search_query]
* @returns {*}
*/
getCount: (access, search_query) => {
return access.can('users:list')
.then(() => {
let query = userModel
.query()
.count('id as count')
.where('is_deleted', 0)
.first();
// Query is used for searching
if (typeof search_query === 'string') {
query.where(function () {
this.where('user.name', 'like', '%' + search_query + '%')
.orWhere('user.email', 'like', '%' + search_query + '%');
});
}
return query;
})
.then(row => {
return parseInt(row.count, 10);
});
},
/**
* All users
*
* @param {Access} access
* @param {Integer} [start]
* @param {Integer} [limit]
* @param {Array} [sort]
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, start, limit, sort, expand, search_query) => {
return access.can('users:list')
.then(() => {
let query = userModel
.query()
.where('is_deleted', 0)
.groupBy('id')
.limit(limit ? limit : 100)
.omit(['is_deleted']);
if (typeof start !== 'undefined' && start !== null) {
query.offset(start);
}
if (typeof sort !== 'undefined' && sort !== null) {
_.map(sort, (item) => {
query.orderBy(item.field, item.dir);
});
} else {
query.orderBy('name', 'DESC');
}
// Query is used for searching
if (typeof search_query === 'string') {
query.where(function () {
this.where('name', 'like', '%' + search_query + '%')
.orWhere('email', 'like', '%' + search_query + '%');
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.eager('[' + expand.join(', ') + ']');
}
return query;
});
},
/**
* @param {Access} access
* @param {Integer} [id_requested]
* @returns {[String]}
*/
getUserOmisionsByAccess: (access, id_requested) => {
let response = []; // Admin response
if (!access.token.hasScope('admin') && access.token.get('attrs').id !== id_requested) {
response = ['roles', 'is_deleted']; // Restricted response
}
return response;
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} data.type
* @param {String} data.secret
* @return {Promise}
*/
setPassword: (access, data) => {
return access.can('users:password', data.id)
.then(() => {
return internalUser.get(access, {id: data.id});
})
.then(user => {
if (user.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
}
if (user.id === access.token.get('attrs').id) {
// they're setting their own password. Make sure their current password is correct
if (typeof data.current === 'undefined' || !data.current) {
throw new error.ValidationError('Current password was not supplied');
}
return internalToken.getTokenFromEmail({
identity: user.email,
secret: data.current
})
.then(() => {
return user;
});
}
return user;
})
.then(user => {
return authModel
.query()
.where('user_id', user.id)
.andWhere('type', data.type)
.patch({
type: data.type,
secret: data.secret
})
.then(() => {
return true;
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
*/
loginAs: (access, data) => {
return access.can('users:loginas', data.id)
.then(() => {
return internalUser.get(access, data);
})
.then(user => {
return internalToken.getTokenFromUser(user);
});
}
};
module.exports = internalUser;

256
src/backend/lib/access.js Normal file
View File

@ -0,0 +1,256 @@
'use strict';
const _ = require('lodash');
const validator = require('ajv');
const error = require('./error');
const userModel = require('../models/user');
const TokenModel = require('../models/token');
const roleSchema = require('./access/roles.json');
module.exports = function (token_string) {
let Token = new TokenModel();
let token_data = null;
let initialised = false;
let object_cache = {};
let allow_internal_access = false;
let user_roles = [];
/**
* Loads the Token object from the token string
*
* @returns {Promise}
*/
this.init = () => {
return new Promise((resolve, reject) => {
if (initialised) {
resolve();
} else if (!token_string) {
reject(new error.PermissionError('Permission Denied'));
} else {
resolve(Token.load(token_string)
.then((data) => {
token_data = data;
// At this point we need to load the user from the DB and make sure they:
// - exist (and not soft deleted)
// - still have the appropriate scopes for this token
// This is only required when the User ID is supplied or if the token scope has `user`
if (token_data.attrs.id || (typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'user') !== -1)) {
// Has token user id or token user scope
return userModel
.query()
.where('id', token_data.attrs.id)
.andWhere('is_deleted', 0)
.andWhere('is_disabled', 0)
.first('id')
.then((user) => {
if (user) {
// make sure user has all scopes of the token
// The `user` role is not added against the user row, so we have to just add it here to get past this check.
user.roles.push('user');
let is_ok = true;
_.forEach(token_data.scope, (scope_item) => {
if (_.indexOf(user.roles, scope_item) === -1) {
is_ok = false;
}
});
if (!is_ok) {
throw new error.AuthError('Invalid token scope for User');
} else {
initialised = true;
user_roles = user.roles;
}
} else {
throw new error.AuthError('User cannot be loaded for Token');
}
});
} else {
initialised = true;
}
}));
}
});
};
/**
* Fetches the object ids from the database, only once per object type, for this token.
* This only applies to USER token scopes, as all other tokens are not really bound
* by object scopes
*
* @param {String} object_type
* @returns {Promise}
*/
this.loadObjects = object_type => {
return new Promise((resolve, reject) => {
if (Token.hasScope('user')) {
if (typeof token_data.attrs.id === 'undefined' || !token_data.attrs.id) {
reject(new error.AuthError('User Token supplied without a User ID'));
} else {
let token_user_id = token_data.attrs.id ? token_data.attrs.id : 0;
if (typeof object_cache[object_type] === 'undefined') {
switch (object_type) {
// USERS - should only return yourself
case 'users':
resolve(token_user_id ? [token_user_id] : []);
break;
// DEFAULT: null
default:
resolve(null);
break;
}
} else {
resolve(object_cache[object_type]);
}
}
} else {
resolve(null);
}
})
.then(objects => {
object_cache[object_type] = objects;
return objects;
});
};
/**
* Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema
*
* @param {String} permission_label
* @returns {Object}
*/
this.getObjectSchema = permission_label => {
let base_object_type = permission_label.split(':').shift();
let schema = {
$id: 'objects',
$schema: 'http://json-schema.org/draft-07/schema#',
description: 'Actor Properties',
type: 'object',
additionalProperties: false,
properties: {
user_id: {
anyOf: [
{
type: 'number',
enum: [Token.get('attrs').id]
}
]
},
scope: {
type: 'string',
pattern: '^' + Token.get('scope') + '$'
}
}
};
return this.loadObjects(base_object_type)
.then(object_result => {
if (typeof object_result === 'object' && object_result !== null) {
schema.properties[base_object_type] = {
type: 'number',
enum: object_result,
minimum: 1
};
} else {
schema.properties[base_object_type] = {
type: 'number',
minimum: 1
};
}
return schema;
});
};
return {
token: Token,
/**
*
* @param {Boolean} [allow_internal]
* @returns {Promise}
*/
load: allow_internal => {
return new Promise(function (resolve/*, reject*/) {
if (token_string) {
resolve(Token.load(token_string));
} else {
allow_internal_access = allow_internal;
resolve(allow_internal_access || null);
}
});
},
/**
*
* @param {String} permission
* @param {*} [data]
* @returns {Promise}
*/
can: (permission, data) => {
if (allow_internal_access === true) {
return Promise.resolve(true);
//return true;
} else {
return this.init()
.then(() => {
// Initialised, token decoded ok
return this.getObjectSchema(permission)
.then(objectSchema => {
let data_schema = {
[permission]: {
data: data,
scope: Token.get('scope'),
roles: user_roles
}
};
let permissionSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
$async: true,
$id: 'permissions',
additionalProperties: false,
properties: {}
};
permissionSchema.properties[permission] = require('./access/' + permission.replace(/:/gim, '-') + '.json');
//console.log('objectSchema:', JSON.stringify(objectSchema, null, 2));
//console.log('permissionSchema:', JSON.stringify(permissionSchema, null, 2));
//console.log('data_schema:', JSON.stringify(data_schema, null, 2));
let ajv = validator({
verbose: true,
allErrors: true,
format: 'full',
missingRefs: 'fail',
breakOnError: true,
coerceTypes: true,
schemas: [
roleSchema,
objectSchema,
permissionSchema
]
});
return ajv.validate('permissions', data_schema);
});
})
.catch(err => {
//console.log(err.message);
//console.log(err.errors);
throw new error.PermissionError('Permission Denied', err);
});
}
}
};
};

View File

@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "roles",
"definitions": {
"admin": {
"type": "object",
"required": [
"scope",
"roles"
],
"properties": {
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
},
"roles": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^admin$"
}
}
}
},
"user": {
"type": "object",
"required": [
"scope"
],
"properties": {
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
}
}
}
}
}

View File

@ -0,0 +1,23 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["data", "scope"],
"properties": {
"data": {
"$ref": "objects#/properties/users"
},
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
}
}
}
]
}

View File

@ -0,0 +1,7 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}

83
src/backend/lib/error.js Normal file
View File

@ -0,0 +1,83 @@
'use strict';
const _ = require('lodash');
const util = require('util');
module.exports = {
PermissionError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = 'Permission Denied';
this.public = true;
this.status = 403;
},
ItemNotFoundError: function (id, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = 'Item Not Found - ' + id;
this.public = true;
this.status = 404;
},
AuthError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = message;
this.public = true;
this.status = 401;
},
InternalError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = message;
this.status = 500;
this.public = false;
},
InternalValidationError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = message;
this.status = 400;
this.public = false;
},
CacheError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.message = message;
this.previous = previous;
this.status = 500;
this.public = false;
},
ValidationError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = message;
this.public = true;
this.status = 400;
},
AssertionFailedError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = message;
this.public = false;
this.status = 400;
}
};
_.forEach(module.exports, function (error) {
util.inherits(error, Error);
});

View File

@ -0,0 +1,32 @@
'use strict';
const validator = require('../validator');
module.exports = function (req, res, next) {
if (req.headers.origin) {
// very relaxed validation....
validator({
type: 'string',
pattern: '^[a-z\\-]+:\\/\\/(?:[\\w\\-\\.]+(:[0-9]+)?/?)?$'
}, req.headers.origin)
.then(function () {
res.set({
'Access-Control-Allow-Origin': req.headers.origin,
'Access-Control-Allow-Credentials': true,
'Access-Control-Allow-Methods': 'OPTIONS, GET, POST',
'Access-Control-Allow-Headers': 'Content-Type, Cache-Control, Pragma, Expires, Authorization, X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit',
'Access-Control-Max-Age': 5 * 60,
'Access-Control-Expose-Headers': 'X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit'
});
next();
})
.catch(next);
} else {
// No origin
next();
}
};

View File

@ -0,0 +1,17 @@
'use strict';
const Access = require('../access');
module.exports = () => {
return function (req, res, next) {
res.locals.access = null;
let access = new Access(res.locals.token || null);
access.load()
.then(() => {
res.locals.access = access;
next();
})
.catch(next);
};
};

View File

@ -0,0 +1,15 @@
'use strict';
module.exports = function () {
return function (req, res, next) {
if (req.headers.authorization) {
let parts = req.headers.authorization.split(' ');
if (parts && parts[0] === 'Bearer' && parts[1]) {
res.locals.token = parts[1];
}
}
next();
};
};

View File

@ -0,0 +1,57 @@
'use strict';
let _ = require('lodash');
module.exports = function (default_sort, default_offset, default_limit, max_limit) {
/**
* This will setup the req query params with filtered data and defaults
*
* sort will be an array of fields and their direction
* offset will be an int, defaulting to zero if no other default supplied
* limit will be an int, defaulting to 50 if no other default supplied, and limited to the max if that was supplied
*
*/
return function (req, res, next) {
req.query.offset = typeof req.query.limit === 'undefined' ? default_offset || 0 : parseInt(req.query.offset, 10);
req.query.limit = typeof req.query.limit === 'undefined' ? default_limit || 50 : parseInt(req.query.limit, 10);
if (max_limit && req.query.limit > max_limit) {
req.query.limit = max_limit;
}
// Sorting
let sort = typeof req.query.sort === 'undefined' ? default_sort : req.query.sort;
let myRegexp = /.*\.(asc|desc)$/ig;
let sort_array = [];
sort = sort.split(',');
_.map(sort, function (val) {
let matches = myRegexp.exec(val);
if (matches !== null) {
let dir = matches[1];
sort_array.push({
field: val.substr(0, val.length - (dir.length + 1)),
dir: dir.toLowerCase()
});
} else {
sort_array.push({
field: val,
dir: 'asc'
});
}
});
// Sort will now be in this format:
// [
// { field: 'field1', dir: 'asc' },
// { field: 'field2', dir: 'desc' }
// ]
req.query.sort = sort_array;
next();
};
};

View File

@ -0,0 +1,11 @@
'use strict';
module.exports = (req, res, next) => {
if (req.params.user_id === 'me' && res.locals.access) {
req.params.user_id = res.locals.access.token.get('attrs').id;
} else {
req.params.user_id = parseInt(req.params.user_id, 10);
}
next();
};

View File

@ -0,0 +1,35 @@
'use strict';
const moment = require('moment');
const _ = require('lodash');
module.exports = {
/**
* Takes an expression such as 30d and returns a moment object of that date in future
*
* Key Shorthand
* ==================
* years y
* quarters Q
* months M
* weeks w
* days d
* hours h
* minutes m
* seconds s
* milliseconds ms
*
* @param {String} expression
* @returns {Object}
*/
parseDatePeriod: function (expression) {
let matches = expression.match(/^([0-9]+)(y|Q|M|w|d|h|m|s|ms)$/m);
if (matches) {
return moment().add(matches[1], matches[2]);
}
return null;
}
};

View File

@ -0,0 +1,57 @@
'use strict';
const migrate_name = 'identifier_for_migrate';
const logger = require('../logger').migrate;
/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.up = function (knex, Promise) {
logger.info('[' + migrate_name + '] Migrating Up...');
// Create Table example:
/*return knex.schema.createTable('notification', (table) => {
table.increments().primary();
table.string('name').notNull();
table.string('type').notNull();
table.integer('created_on').notNull();
table.integer('modified_on').notNull();
})
.then(function () {
logger.info('[' + migrate_name + '] Notification Table created');
});*/
logger.info('[' + migrate_name + '] Migrating Up Complete');
return Promise.resolve(true);
};
/**
* Undo Migrate
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.down = function (knex, Promise) {
logger.info('[' + migrate_name + '] Migrating Down...');
// Drop table example:
/*return knex.schema.dropTable('notification')
.then(() => {
logger.info('[' + migrate_name + '] Notification Table dropped');
});*/
logger.info('[' + migrate_name + '] Migrating Down Complete');
return Promise.resolve(true);
};

View File

@ -0,0 +1,47 @@
'use strict';
const error = require('../error');
const path = require('path');
const parser = require('json-schema-ref-parser');
const ajv = require('ajv')({
verbose: true,
validateSchema: true,
allErrors: false,
format: 'full',
coerceTypes: true
});
/**
* @param {Object} schema
* @param {Object} payload
* @returns {Promise}
*/
function apiValidator (schema, payload/*, description*/) {
return new Promise(function Promise_apiValidator (resolve, reject) {
if (typeof payload === 'undefined') {
reject(new error.ValidationError('Payload is undefined'));
}
let validate = ajv.compile(schema);
let valid = validate(payload);
if (valid && !validate.errors) {
resolve(payload);
} else {
let message = ajv.errorsText(validate.errors);
let err = new error.ValidationError(message);
err.debug = [validate.errors, payload];
reject(err);
}
});
}
apiValidator.loadSchemas = parser
.dereference(path.resolve('src/backend/schema/index.json'))
.then(schema => {
ajv.addSchema(schema);
return schema;
});
module.exports = apiValidator;

View File

@ -0,0 +1,53 @@
'use strict';
const _ = require('lodash');
const error = require('../error');
const definitions = require('../../schema/definitions.json');
RegExp.prototype.toJSON = RegExp.prototype.toString;
const ajv = require('ajv')({
verbose: true, //process.env.NODE_ENV === 'development',
allErrors: true,
format: 'full', // strict regexes for format checks
coerceTypes: true,
schemas: [
definitions
]
});
/**
*
* @param {Object} schema
* @param {Object} payload
* @returns {Promise}
*/
function validator (schema, payload) {
return new Promise(function (resolve, reject) {
if (!payload) {
reject(new error.InternalValidationError('Payload is falsy'));
} else {
try {
let validate = ajv.compile(schema);
let valid = validate(payload);
if (valid && !validate.errors) {
resolve(_.cloneDeep(payload));
} else {
//console.log('Validation failed:', schema, payload);
let message = ajv.errorsText(validate.errors);
reject(new error.InternalValidationError(message));
}
} catch (err) {
reject(err);
}
}
});
}
module.exports = validator;

7
src/backend/logger.js Normal file
View File

@ -0,0 +1,7 @@
const {Signale} = require('signale');
module.exports = {
global: new Signale({scope: 'Global '}),
migrate: new Signale({scope: 'Migrate '}),
express: new Signale({scope: 'Express '})
};

17
src/backend/migrate.js Normal file
View File

@ -0,0 +1,17 @@
'use strict';
const db = require('./db');
const logger = require('./logger').migrate;
module.exports = {
latest: function () {
return db.migrate.currentVersion()
.then(version => {
logger.info('Current database version:', version);
return db.migrate.latest({
tableName: 'migrations',
directory: 'src/backend/migrations'
});
});
}
};

View File

@ -0,0 +1,60 @@
'use strict';
const migrate_name = 'initial-schema';
const logger = require('../logger').migrate;
/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.up = function (knex/*, Promise*/) {
logger.info('[' + migrate_name + '] Migrating Up...');
return knex.schema.createTable('auth', table => {
table.increments().primary();
table.dateTime('created_on').notNull();
table.dateTime('modified_on').notNull();
table.integer('user_id').notNull().unsigned();
table.string('type', 30).notNull();
table.string('secret').notNull();
table.json('meta').notNull();
table.integer('is_deleted').notNull().unsigned().defaultTo(0);
})
.then(() => {
logger.info('[' + migrate_name + '] auth Table created');
return knex.schema.createTable('user', table => {
table.increments().primary();
table.dateTime('created_on').notNull();
table.dateTime('modified_on').notNull();
table.integer('is_deleted').notNull().unsigned().defaultTo(0);
table.integer('is_disabled').notNull().unsigned().defaultTo(0);
table.string('email').notNull();
table.string('name').notNull();
table.string('nickname').notNull();
table.string('avatar').notNull();
table.json('roles').notNull();
});
})
.then(() => {
logger.info('[' + migrate_name + '] user Table created');
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.down = function (knex, Promise) {
logger.warn('[' + migrate_name + '] You can\'t migrate down the initial data.');
return Promise.resolve(true);
};

View File

@ -0,0 +1,82 @@
// Objection Docs:
// http://vincit.github.io/objection.js/
'use strict';
const bcrypt = require('bcrypt-then');
const db = require('../db');
const Model = require('objection').Model;
const User = require('./user');
Model.knex(db);
function encryptPassword () {
/* jshint -W040 */
let _this = this;
if (_this.type === 'password' && _this.secret) {
return bcrypt.hash(_this.secret, 13)
.then(function (hash) {
_this.secret = hash;
});
}
return null;
}
class Auth extends Model {
$beforeInsert (queryContext) {
this.created_on = Model.raw('NOW()');
this.modified_on = Model.raw('NOW()');
return encryptPassword.apply(this, queryContext);
}
$beforeUpdate (queryContext) {
this.modified_on = Model.raw('NOW()');
return encryptPassword.apply(this, queryContext);
}
/**
* Verify a plain password against the encrypted password
*
* @param {String} password
* @returns {Promise}
*/
verifyPassword (password) {
return bcrypt.compare(password, this.secret);
}
static get name () {
return 'Auth';
}
static get tableName () {
return 'auth';
}
static get jsonAttributes () {
return ['meta'];
}
static get relationMappings () {
return {
user: {
relation: Model.HasOneRelation,
modelClass: User,
join: {
from: 'auth.user_id',
to: 'user.id'
},
filter: {
is_deleted: 0
},
modify: function (qb) {
qb.omit(['is_deleted']);
}
}
};
}
}
module.exports = Auth;

133
src/backend/models/token.js Normal file
View File

@ -0,0 +1,133 @@
/**
NOTE: This is not a database table, this is a model of a Token object that can be created/loaded
and then has abilities after that.
*/
'use strict';
const _ = require('lodash');
const config = require('config');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const error = require('../lib/error');
const ALGO = 'RS256';
module.exports = function () {
const public_key = config.get('jwt.pub');
const private_key = config.get('jwt.key');
let token_data = {};
return {
/**
* @param {Object} payload
* @param {Object} [user_options]
* @param {Integer} [user_options.expires]
* @returns {Promise}
*/
create: (payload, user_options) => {
user_options = user_options || {};
// sign with RSA SHA256
let options = {
algorithm: ALGO
};
if (typeof user_options.expires !== 'undefined' && user_options.expires) {
options.expiresIn = user_options.expires;
}
payload.jti = crypto.randomBytes(12)
.toString('base64')
.substr(-8);
return new Promise((resolve, reject) => {
jwt.sign(payload, private_key, options, (err, token) => {
if (err) {
reject(err);
} else {
token_data = payload;
resolve({
token: token,
payload: payload
});
}
});
});
},
/**
* @param {String} token
* @returns {Promise}
*/
load: function (token) {
return new Promise((resolve, reject) => {
try {
if (!token || token === null || token === 'null') {
reject('Empty token');
} else {
jwt.verify(token, public_key, {ignoreExpiration: false, algorithms: [ALGO]}, (err, result) => {
if (err) {
if (err.name === 'TokenExpiredError') {
reject(new error.AuthError('Token has expired', err));
} else {
reject(err);
}
} else {
token_data = result;
// Hack: some tokens out in the wild have a scope of 'all' instead of 'user'.
// For 30 days at least, we need to replace 'all' with user.
if ((typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'all') !== -1)) {
//console.log('Warning! Replacing "all" scope with "user"');
token_data.scope = ['user'];
}
resolve(token_data);
}
});
}
} catch (err) {
reject(err);
}
});
},
/**
* Does the token have the specified scope?
*
* @param {String} scope
* @returns {Boolean}
*/
hasScope: function (scope) {
return typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, scope) !== -1;
},
/**
* @param {String} key
* @return {*}
*/
get: function (key) {
if (typeof token_data[key] !== 'undefined') {
return token_data[key];
}
return null;
},
/**
* @param {String} key
* @param {*} value
*/
set: function (key, value) {
token_data[key] = value;
}
};
};

View File

@ -0,0 +1,35 @@
// Objection Docs:
// http://vincit.github.io/objection.js/
'use strict';
const db = require('../db');
const Model = require('objection').Model;
Model.knex(db);
class User extends Model {
$beforeInsert () {
this.created_on = Model.raw('NOW()');
this.modified_on = Model.raw('NOW()');
}
$beforeUpdate () {
this.modified_on = Model.raw('NOW()');
}
static get name () {
return 'User';
}
static get tableName () {
return 'user';
}
static get jsonAttributes () {
return ['roles'];
}
}
module.exports = User;

View File

@ -0,0 +1,32 @@
'use strict';
const express = require('express');
const pjson = require('../../../../package.json');
let router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true
});
/**
* Health Check
* GET /api
*/
router.get('/', (req, res/*, next*/) => {
let version = pjson.version.split('-').shift().split('.');
res.status(200).send({
status: 'OK',
version: {
major: parseInt(version.shift(), 10),
minor: parseInt(version.shift(), 10),
revision: parseInt(version.shift(), 10)
}
});
});
router.use('/tokens', require('./tokens'));
router.use('/users', require('./users'));
module.exports = router;

View File

@ -0,0 +1,56 @@
'use strict';
const express = require('express');
const jwtdecode = require('../../lib/express/jwt-decode');
const internalToken = require('../../internal/token');
const apiValidator = require('../../lib/validator/api');
let router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true
});
router
.route('/')
.options((req, res) => {
res.sendStatus(204);
})
/**
* GET /tokens
*
* Get a new Token, given they already have a token they want to refresh
* We also piggy back on to this method, allowing admins to get tokens
* for services like Job board and Worker.
*/
.get(jwtdecode(), (req, res, next) => {
internalToken.getFreshToken(res.locals.access, {
expiry: (typeof req.query.expiry !== 'undefined' ? req.query.expiry : null),
scope: (typeof req.query.scope !== 'undefined' ? req.query.scope : null)
})
.then(data => {
res.status(200)
.send(data);
})
.catch(next);
})
/**
* POST /tokens
*
* Create a new Token
*/
.post((req, res, next) => {
apiValidator({$ref: 'endpoints/tokens#/links/0/schema'}, req.body)
.then(payload => {
return internalToken.getTokenFromEmail(payload);
})
.then(data => {
res.status(200)
.send(data);
})
.catch(next);
});
module.exports = router;

View File

@ -0,0 +1,256 @@
'use strict';
const express = require('express');
const validator = require('../../lib/validator');
const jwtdecode = require('../../lib/express/jwt-decode');
const pagination = require('../../lib/express/pagination');
const userIdFromMe = require('../../lib/express/user-id-from-me');
const internalUser = require('../../internal/user');
const apiValidator = require('../../lib/validator/api');
let router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true
});
/**
* /api/users
*/
router
.route('/')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
/**
* GET /api/users
*
* Retrieve all users
*/
.get(pagination('name', 0, 50, 300), (req, res, next) => {
validator({
additionalProperties: false,
required: ['sort'],
properties: {
sort: {
$ref: 'definitions#/definitions/sort'
},
expand: {
$ref: 'definitions#/definitions/expand'
},
query: {
$ref: 'definitions#/definitions/query'
}
}
}, {
sort: req.query.sort,
expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
query: (typeof req.query.query === 'string' ? req.query.query : null)
})
.then((data) => {
return Promise.all([
internalUser.getCount(res.locals.access, data.query),
internalUser.getAll(res.locals.access, req.query.offset, req.query.limit, data.sort, data.expand, data.query)
]);
})
.then((data) => {
res.setHeader('X-Dataset-Total', data.shift());
res.setHeader('X-Dataset-Offset', req.query.offset);
res.setHeader('X-Dataset-Limit', req.query.limit);
return data.shift();
})
.then((users) => {
res.status(200)
.send(users);
})
.catch(next);
})
/**
* POST /api/users
*
* Create a new User
*/
.post((req, res, next) => {
apiValidator({$ref: 'endpoints/users#/links/1/schema'}, req.body)
.then((payload) => {
return internalUser.create(res.locals.access, payload);
})
.then((result) => {
res.status(201)
.send(result);
})
.catch(next);
});
/**
* Specific user
*
* /api/users/123
*/
router
.route('/:user_id')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
.all(userIdFromMe)
/**
* GET /users/123 or /users/me
*
* Retrieve a specific user
*/
.get((req, res, next) => {
validator({
required: ['user_id'],
additionalProperties: false,
properties: {
user_id: {
$ref: 'definitions#/definitions/id'
},
expand: {
$ref: 'definitions#/definitions/expand'
}
}
}, {
user_id: req.params.user_id,
expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
})
.then((data) => {
return internalUser.get(res.locals.access, {
id: data.user_id,
expand: data.expand,
omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id)
});
})
.then((user) => {
res.status(200)
.send(user);
})
.catch(next);
})
/**
* PUT /api/users/123
*
* Update and existing user
*/
.put((req, res, next) => {
apiValidator({$ref: 'endpoints/users#/links/2/schema'}, req.body)
.then((payload) => {
payload.id = req.params.user_id;
return internalUser.update(res.locals.access, payload);
})
.then((result) => {
res.status(200)
.send(result);
})
.catch(next);
})
/**
* DELETE /api/users/123
*
* Update and existing user
*/
.delete((req, res, next) => {
internalUser.delete(res.locals.access, {id: req.params.user_id})
.then((result) => {
res.status(200)
.send(result);
})
.catch(next);
});
/**
* Specific user auth
*
* /api/users/123/auth
*/
router
.route('/:user_id/auth')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
.all(userIdFromMe)
/**
* PUT /api/users/123/auth
*
* Update password for a user
*/
.put((req, res, next) => {
apiValidator({$ref: 'endpoints/users#/links/4/schema'}, req.body)
.then(payload => {
payload.id = req.params.user_id;
return internalUser.setPassword(res.locals.access, payload);
})
.then(result => {
res.status(201)
.send(result);
})
.catch(next);
});
/**
* Specific user service settings
*
* /api/users/123/services
*/
router
.route('/:user_id/services')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
.all(userIdFromMe)
/**
* POST /api/users/123/services
*
* Sets Service Settings for a user
*/
.post((req, res, next) => {
apiValidator({$ref: 'endpoints/users#/links/5/schema'}, req.body)
.then((payload) => {
payload.id = req.params.user_id;
return internalUser.setServiceSettings(res.locals.access, payload);
})
.then((result) => {
res.status(200)
.send(result);
})
.catch(next);
});
/**
* Specific user login as
*
* /api/users/123/login
*/
router
.route('/:user_id/login')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
/**
* POST /api/users/123/login
*
* Log in as a user
*/
.post((req, res, next) => {
internalUser.loginAs(res.locals.access, {id: parseInt(req.params.user_id, 10)})
.then(result => {
res.status(201)
.send(result);
})
.catch(next);
});
module.exports = router;

View File

@ -0,0 +1,44 @@
'use strict';
const express = require('express');
const fs = require('fs');
const PACKAGE = require('../../../package.json');
const router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true
});
/**
* GET /login
*/
router.get('/login', function (req, res, next) {
res.render('login', {
version: PACKAGE.version
});
});
/**
* GET .*
*/
router.get(/(.*)/, function (req, res, next) {
req.params.page = req.params['0'];
if (req.params.page === '/') {
res.render('index', {
version: PACKAGE.version
});
} else {
fs.readFile('dist' + req.params.page, 'utf8', function (err, data) {
if (err) {
res.render('index', {
version: PACKAGE.version
});
} else {
res.contentType('text/html').end(data);
}
});
}
});
module.exports = router;

View File

@ -0,0 +1,139 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "definitions",
"definitions": {
"id": {
"description": "Unique identifier",
"example": 123456,
"readOnly": true,
"type": "integer",
"minimum": 1
},
"token": {
"type": "string",
"minLength": 10
},
"expand": {
"anyOf": [
{
"type": "null"
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
]
},
"sort": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"field",
"dir"
],
"additionalProperties": false,
"properties": {
"field": {
"type": "string"
},
"dir": {
"type": "string",
"pattern": "^(asc|desc)$"
}
}
}
},
"query": {
"anyOf": [
{
"type": "null"
},
{
"type": "string",
"minLength": 1,
"maxLength": 255
}
]
},
"criteria": {
"anyOf": [
{
"type": "null"
},
{
"type": "object"
}
]
},
"fields": {
"anyOf": [
{
"type": "null"
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
]
},
"omit": {
"anyOf": [
{
"type": "null"
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
]
},
"created_on": {
"description": "Date and time of creation",
"format": "date-time",
"readOnly": true,
"type": "string"
},
"modified_on": {
"description": "Date and time of last update",
"format": "date-time",
"readOnly": true,
"type": "string"
},
"user_id": {
"description": "User ID",
"example": 1234,
"type": "integer",
"minimum": 1
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 255
},
"email": {
"description": "Email Address",
"example": "john@example.com",
"format": "email",
"type": "string",
"minLength": 8,
"maxLength": 100
},
"password": {
"description": "Password",
"type": "string",
"minLength": 8,
"maxLength": 255
}
}
}

View File

@ -0,0 +1,100 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "endpoints/tokens",
"title": "Token",
"description": "Tokens are required to authenticate against the API",
"stability": "stable",
"type": "object",
"definitions": {
"identity": {
"description": "Email Address or other 3rd party providers identifier",
"example": "john@example.com",
"type": "string"
},
"secret": {
"description": "A password or key",
"example": "correct horse battery staple",
"type": "string"
},
"token": {
"description": "JWT",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk",
"type": "string"
},
"expires": {
"description": "Token expiry time",
"format": "date-time",
"type": "string"
},
"scope": {
"description": "Scope of the Token, defaults to 'user'",
"example": "user",
"type": "string"
}
},
"links": [
{
"title": "Create",
"description": "Creates a new token.",
"href": "/tokens",
"access": "public",
"method": "POST",
"rel": "create",
"schema": {
"type": "object",
"required": [
"identity",
"secret"
],
"properties": {
"identity": {
"$ref": "#/definitions/identity"
},
"secret": {
"$ref": "#/definitions/secret"
},
"scope": {
"$ref": "#/definitions/scope"
}
}
},
"targetSchema": {
"type": "object",
"properties": {
"token": {
"$ref": "#/definitions/token"
},
"expires": {
"$ref": "#/definitions/expires"
}
}
}
},
{
"title": "Refresh",
"description": "Returns a new token.",
"href": "/tokens",
"access": "private",
"method": "GET",
"rel": "self",
"http_header": {
"$ref": "../examples.json#/definitions/auth_header"
},
"schema": {},
"targetSchema": {
"type": "object",
"properties": {
"token": {
"$ref": "#/definitions/token"
},
"expires": {
"$ref": "#/definitions/expires"
},
"scope": {
"$ref": "#/definitions/scope"
}
}
}
}
]
}

View File

@ -0,0 +1,240 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "endpoints/users",
"title": "Users",
"description": "Endpoints relating to Users",
"stability": "stable",
"type": "object",
"definitions": {
"id": {
"$ref": "../definitions.json#/definitions/id"
},
"created_on": {
"$ref": "../definitions.json#/definitions/created_on"
},
"modified_on": {
"$ref": "../definitions.json#/definitions/modified_on"
},
"name": {
"description": "Name",
"example": "Jamie Curnow",
"type": "string",
"minLength": 2,
"maxLength": 100
},
"nickname": {
"description": "Nickname",
"example": "Jamie",
"type": "string",
"minLength": 2,
"maxLength": 50
},
"email": {
"$ref": "../definitions.json#/definitions/email"
},
"avatar": {
"description": "Avatar",
"example": "http://somewhere.jpg",
"type": "string",
"minLength": 2,
"maxLength": 150,
"readOnly": true
},
"roles": {
"description": "Roles",
"example": [
"admin"
],
"type": "array"
},
"is_disabled": {
"description": "Is Disabled",
"example": false,
"type": "boolean"
}
},
"links": [
{
"title": "List",
"description": "Returns a list of Users",
"href": "/users",
"access": "private",
"method": "GET",
"rel": "self",
"http_header": {
"$ref": "../examples.json#/definitions/auth_header"
},
"targetSchema": {
"type": "array",
"items": {
"$ref": "#/properties"
}
}
},
{
"title": "Create",
"description": "Creates a new User",
"href": "/users",
"access": "private",
"method": "POST",
"rel": "create",
"http_header": {
"$ref": "../examples.json#/definitions/auth_header"
},
"schema": {
"type": "object",
"required": [
"name",
"nickname",
"email"
],
"properties": {
"name": {
"$ref": "#/definitions/name"
},
"nickname": {
"$ref": "#/definitions/nickname"
},
"email": {
"$ref": "#/definitions/email"
},
"roles": {
"$ref": "#/definitions/roles"
},
"is_disabled": {
"$ref": "#/definitions/is_disabled"
},
"auth": {
"type": "object",
"description": "Auth Credentials",
"example": {
"type": "password",
"secret": "bigredhorsebanana"
}
}
}
},
"targetSchema": {
"properties": {
"$ref": "#/properties"
}
}
},
{
"title": "Update",
"description": "Updates a existing User",
"href": "/users/{definitions.identity.example}",
"access": "private",
"method": "PUT",
"rel": "update",
"http_header": {
"$ref": "../examples.json#/definitions/auth_header"
},
"schema": {
"type": "object",
"properties": {
"name": {
"$ref": "#/definitions/name"
},
"nickname": {
"$ref": "#/definitions/nickname"
},
"email": {
"$ref": "#/definitions/email"
},
"roles": {
"$ref": "#/definitions/roles"
},
"is_disabled": {
"$ref": "#/definitions/is_disabled"
}
}
},
"targetSchema": {
"properties": {
"$ref": "#/properties"
}
}
},
{
"title": "Delete",
"description": "Deletes a existing User",
"href": "/users/{definitions.identity.example}",
"access": "private",
"method": "DELETE",
"rel": "delete",
"http_header": {
"$ref": "../examples.json#/definitions/auth_header"
},
"targetSchema": {
"type": "boolean"
}
},
{
"title": "Set Password",
"description": "Sets a password for an existing User",
"href": "/users/{definitions.identity.example}/auth",
"access": "private",
"method": "PUT",
"rel": "update",
"http_header": {
"$ref": "../examples.json#/definitions/auth_header"
},
"schema": {
"type": "object",
"required": [
"type",
"secret"
],
"properties": {
"type": {
"type": "string",
"pattern": "^password$"
},
"current": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"secret": {
"type": "string",
"minLength": 8,
"maxLength": 64
}
}
},
"targetSchema": {
"type": "boolean"
}
}
],
"properties": {
"id": {
"$ref": "#/definitions/id"
},
"created_on": {
"$ref": "#/definitions/created_on"
},
"modified_on": {
"$ref": "#/definitions/modified_on"
},
"name": {
"$ref": "#/definitions/name"
},
"nickname": {
"$ref": "#/definitions/nickname"
},
"email": {
"$ref": "#/definitions/email"
},
"avatar": {
"$ref": "#/definitions/avatar"
},
"roles": {
"$ref": "#/definitions/roles"
},
"is_disabled": {
"$ref": "#/definitions/is_disabled"
}
}
}

View File

@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "examples",
"type": "object",
"definitions": {
"name": {
"description": "Name",
"example": "John Smith",
"type": "string",
"minLength": 1,
"maxLength": 255
},
"auth_header": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk",
"X-API-Version": "next"
},
"token": {
"type": "string",
"description": "JWT",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk"
}
}
}

View File

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "root",
"title": "Nginx Proxy Manager REST API",
"description": "This is the Nginx Proxy Manager REST API",
"version": "2.0.0",
"links": [
{
"href": "http://npm.example.com/api",
"rel": "self"
}
],
"properties": {
"tokens": {
"$ref": "endpoints/tokens.json"
},
"users": {
"$ref": "endpoints/users.json"
}
}
}

87
src/backend/setup.js Normal file
View File

@ -0,0 +1,87 @@
'use strict';
const fs = require('fs');
const NodeRSA = require('node-rsa');
const config = require('config');
const logger = require('./logger').global;
const userModel = require('./models/user');
const authModel = require('./models/auth');
module.exports = function () {
return new Promise((resolve, reject) => {
// Now go and check if the jwt gpg keys have been created and if not, create them
if (!config.has('jwt') || !config.has('jwt.key') || !config.has('jwt.pub')) {
logger.info('Creating a new JWT key pair...');
// jwt keys are not configured properly
const filename = config.util.getEnv('NODE_CONFIG_DIR') + '/' + (config.util.getEnv('NODE_ENV') || 'default') + '.json';
let config_data = {};
try {
config_data = require(filename);
} catch (err) {
// do nothing
}
// Now create the keys and save them in the config.
let key = new NodeRSA({b: 2048});
key.generateKeyPair();
config_data.jwt = {
key: key.exportKey('private').toString(),
pub: key.exportKey('public').toString()
};
// Write config
fs.writeFile(filename, JSON.stringify(config_data, null, 2), (err) => {
if (err) {
logger.error('Could not write JWT key pair to config file: ' + filename);
reject(err);
} else {
logger.info('Wrote JWT key pair to config file: ' + filename);
config.util.loadFileConfigs();
resolve();
}
});
} else {
// JWT key pair exists
resolve();
}
})
.then(() => {
return userModel
.query()
.select(userModel.raw('COUNT(`id`) as `count`'))
.where('is_deleted', 0)
.first('count')
.then((row) => {
if (!row.count) {
// Create a new user and set password
logger.info('Creating a new user: admin@example.com with password: changeme');
let data = {
is_deleted: 0,
email: 'admin@example.com',
name: 'Administrator',
nickname: 'Admin',
avatar: '',
roles: ['admin']
};
return userModel
.query()
.insertAndFetch(data)
.then(user => {
return authModel
.query()
.insert({
user_id: user.id,
type: 'password',
secret: 'changeme',
meta: {}
});
});
}
});
});
};

View File

@ -0,0 +1,9 @@
<% var title = 'Nginx Proxy Manager' %>
<%- include partials/header.ejs %>
<div id="app">
<span class="loader"></span>
</div>
<script type="text/javascript" src="/js/main.js?v=<%= version %>"></script>
<%- include partials/footer.ejs %>

View File

@ -0,0 +1,9 @@
<% var title = 'Login &ndash; Nginx Proxy Manager' %>
<%- include partials/header.ejs %>
<div class="page" id="login" data-version="<%= version %>">
<span class="loader"></span>
</div>
<script type="text/javascript" src="/js/login.js?v=<%= version %>"></script>
<%- include partials/footer.ejs %>

View File

@ -0,0 +1,2 @@
</body>
</html>

View File

@ -0,0 +1,36 @@
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta http-equiv="Content-Language" content="en">
<meta name="msapplication-TileColor" content="#2d89ef">
<meta name="theme-color" content="#4188c9">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="HandheldFriendly" content="True">
<meta name="MobileOptimized" content="320">
<title><%- title %></title>
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicons/apple-touch-icon.png?v=<%= version %>">
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicons/favicon-32x32.png?v=<%= version %>">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicons/favicon-16x16.png?v=<%= version %>">
<link rel="manifest" href="/images/favicons/site.webmanifest?v=<%= version %>">
<link rel="mask-icon" href="/images/favicons/safari-pinned-tab.svg?v=<%= version %>" color="#5bbad5">
<link rel="shortcut icon" href="/images/favicons/favicon.ico?v=<%= version %>">
<meta name="msapplication-TileColor" content="#f5f5f5">
<meta name="msapplication-config" content="/images/favicons/browserconfig.xml?v=<%= version %>">
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,500,500i,600,600i,700,700i&amp;subset=latin-ext">
<link href="/css/main.css?v=<%= version %>" rel="stylesheet">
</head>
<body>
<noscript>
<div class="container no-js-warning">
<div class="alert alert-warning text-center">
<strong>Warning!</strong> This application requires Javascript and your browser doesn't support it.
</div>
</div>
</noscript>