mirror of
https://github.com/xiaoxinpro/nginx-proxy-manager-zh.git
synced 2025-01-22 12:58:13 -05:00
Backend
This commit is contained in:
parent
9e919c3c24
commit
80d78cbf25
95
src/backend/app.js
Normal file
95
src/backend/app.js
Normal 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
23
src/backend/db.js
Normal 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
45
src/backend/index.js
Normal 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();
|
166
src/backend/internal/token.js
Normal file
166
src/backend/internal/token.js
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
382
src/backend/internal/user.js
Normal file
382
src/backend/internal/user.js
Normal 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
256
src/backend/lib/access.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
45
src/backend/lib/access/roles.json
Normal file
45
src/backend/lib/access/roles.json
Normal 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$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
23
src/backend/lib/access/users-get.json
Normal file
23
src/backend/lib/access/users-get.json
Normal 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$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
7
src/backend/lib/access/users-list.json
Normal file
7
src/backend/lib/access/users-list.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
83
src/backend/lib/error.js
Normal file
83
src/backend/lib/error.js
Normal 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);
|
||||
});
|
32
src/backend/lib/express/cors.js
Normal file
32
src/backend/lib/express/cors.js
Normal 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();
|
||||
}
|
||||
|
||||
};
|
17
src/backend/lib/express/jwt-decode.js
Normal file
17
src/backend/lib/express/jwt-decode.js
Normal 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);
|
||||
};
|
||||
};
|
||||
|
15
src/backend/lib/express/jwt.js
Normal file
15
src/backend/lib/express/jwt.js
Normal 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();
|
||||
};
|
||||
};
|
57
src/backend/lib/express/pagination.js
Normal file
57
src/backend/lib/express/pagination.js
Normal 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();
|
||||
};
|
||||
};
|
11
src/backend/lib/express/user-id-from-me.js
Normal file
11
src/backend/lib/express/user-id-from-me.js
Normal 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();
|
||||
};
|
35
src/backend/lib/helpers.js
Normal file
35
src/backend/lib/helpers.js
Normal 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;
|
||||
}
|
||||
|
||||
};
|
57
src/backend/lib/migrate_template.js
Normal file
57
src/backend/lib/migrate_template.js
Normal 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);
|
||||
};
|
47
src/backend/lib/validator/api.js
Normal file
47
src/backend/lib/validator/api.js
Normal 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;
|
53
src/backend/lib/validator/index.js
Normal file
53
src/backend/lib/validator/index.js
Normal 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
7
src/backend/logger.js
Normal 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
17
src/backend/migrate.js
Normal 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'
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
60
src/backend/migrations/20180618015850_initial.js
Normal file
60
src/backend/migrations/20180618015850_initial.js
Normal 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);
|
||||
};
|
82
src/backend/models/auth.js
Normal file
82
src/backend/models/auth.js
Normal 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
133
src/backend/models/token.js
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
35
src/backend/models/user.js
Normal file
35
src/backend/models/user.js
Normal 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;
|
32
src/backend/routes/api/main.js
Normal file
32
src/backend/routes/api/main.js
Normal 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;
|
56
src/backend/routes/api/tokens.js
Normal file
56
src/backend/routes/api/tokens.js
Normal 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;
|
256
src/backend/routes/api/users.js
Normal file
256
src/backend/routes/api/users.js
Normal 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;
|
44
src/backend/routes/main.js
Normal file
44
src/backend/routes/main.js
Normal 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;
|
139
src/backend/schema/definitions.json
Normal file
139
src/backend/schema/definitions.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
100
src/backend/schema/endpoints/tokens.json
Normal file
100
src/backend/schema/endpoints/tokens.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
240
src/backend/schema/endpoints/users.json
Normal file
240
src/backend/schema/endpoints/users.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
23
src/backend/schema/examples.json
Normal file
23
src/backend/schema/examples.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
21
src/backend/schema/index.json
Normal file
21
src/backend/schema/index.json
Normal 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
87
src/backend/setup.js
Normal 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: {}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
9
src/backend/views/index.ejs
Normal file
9
src/backend/views/index.ejs
Normal 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 %>
|
9
src/backend/views/login.ejs
Normal file
9
src/backend/views/login.ejs
Normal file
@ -0,0 +1,9 @@
|
||||
<% var title = 'Login – 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 %>
|
2
src/backend/views/partials/footer.ejs
Normal file
2
src/backend/views/partials/footer.ejs
Normal file
@ -0,0 +1,2 @@
|
||||
</body>
|
||||
</html>
|
36
src/backend/views/partials/header.ejs
Normal file
36
src/backend/views/partials/header.ejs
Normal 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&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>
|
Loading…
Reference in New Issue
Block a user