backstrap-server
Version:
Backstrap is a lightweight framework for building APIs
701 lines (644 loc) • 24.4 kB
JavaScript
// ==================================================================================
// SETUP
// ==================================================================================
// ---------------------------------
// IMPORT MODULES
// ---------------------------------
const http = require('http'); // We'll create our server with the http module
const express = require('express'); // Import express to handle routing and server details
const cors = require('cors'); // Setup CORS
const path = require('path'); // Import path to control our folder structure
const rootDir = path.dirname(require.main.filename);
const fs = require('fs');
const multer = require('multer');
const upload = multer();
console.log('==================================================');
console.log('INITIALIZATION');
console.log('==================================================');
require('./ErrorObj');
var Settings = require('./settings');
var Endpoints = require('./endpoints');
var DataAccess = require('./dataAccess');
var ServiceRegistration = require('./serviceRegistration');
var Controller = require('./controller'); // GETS THE CORRECT WEB SERVICE FILE AND ROUTES CALLS
var Utilities = require('./utilities');
var AccessControl = require('./accessControl');
var SchemaControl = require('./schemaControl.js');
var expressSettings;
// ---------------------------------
// SETUP EXPRESS
// ---------------------------------
var app = express();
const requestSizeLimit = (process.env.MAX_REQUEST_SIZE && !isNaN(process.env.MAX_REQUEST_SIZE) && Number(process.env.MAX_REQUEST_SIZE > 0)) ? process.env.MAX_REQUEST_SIZE+'mb' : '50mb';
app.use(express.json({ limit: requestSizeLimit })); // THIS IS A HIGH DEFAULT LIMIT SINCE BACKSTRAP ALLOWS BASE64 ENCODED FILE UPLOAD
app.use(express.urlencoded({ extended: true })); // DETERMINE IF THIS IS HTML OR JSON REQUEST
// UPLOAD FILES AS form-data IN A FIELD CALLED "mpfd_files"
app.use(upload.array("mpfd_files", 10));
app.use(cors());
// PASS THE HANDLE TO THE EXPRESS APP INTO
// express_init.js SO THE USER CAN ADD EXPRESS MODULES
try {
expressSettings = require(`${rootDir}/expressSettings`);
}
catch(esErr) {
console.warn('ExpressSettings could not be created.')
console.warn(esErr);
}
try {
expressSettings.init(app);
}
catch(expressInitErr) {
console.error('ExpressSettings initialization failed');
console.error(expressInitErr);
}
if(process.env.DEBUG_MODE != null && (process.env.DEBUG_MODE === true || process.env.DEBUG_MODE.toString().toLowerCase() === 'true')) {
process.on('warning', e => console.warn(e.stack)); // USEFUL IN DEBUGGING
process.on('unhandledRejection', r => console.log(r)); // USEFUL IN DEBUGGING
}
//Config File, contains DB params
var config;
var nodeEnv = process.env.NODE_ENV || 'local';
var configFile = `${rootDir}/dbconfig/dbconfig.${nodeEnv}.js`;
try {
config = require(configFile);
}
catch(e) {
console.error('INITIALIZATION ERROR -- dbconfig');
console.error(e);
throw {message: 'problem loading database config file', error: e};
}
var errorLog;
var sessionLog;
var accessLog;
var eventLog;
console.log('Settings initialized');
Utilities.init(Settings);
console.log('Utilities initialized');
Endpoints.init(Settings);
console.log('Endpoints initialized');
DataAccess.init(config, Utilities, Settings);
console.log('DataAccess initialized');
//NOW SET THE DATA ACCESS VAR IN UTILITIES
Utilities.setDataAccess(DataAccess);
ServiceRegistration.init(Endpoints);
console.log('ServiceRegistration initialized');
AccessControl.init(Utilities, Settings, DataAccess, 'Security.json')
.then((aclRes) => {
console.log('AccessControl initialized');
return Controller.init(DataAccess, Utilities, AccessControl, ServiceRegistration, Settings, Endpoints);
})
.then((cInit) => {
console.log('Controller initialized');
SchemaControl.init(DataAccess, AccessControl);
let dbInfo = {
name: config.db.name,
user: config.db.user,
pass: config.db.pass,
host: config.db.host,
port: config.db.port
};
if(Array.isArray(config.db)) {
dbInfo = {
name: config.db[0].name,
user: config.db[0].user,
pass: config.db[0].pass,
host: config.db[0].host,
port: config.db[0].port
}
}
return SchemaControl.update(dbInfo.name, dbInfo.user, dbInfo.pass, dbInfo.host, dbInfo.port)
})
.then((schemaUpd) => {
// CREATE A LOG DIRECTORY IF NEEDED
// DO IT SYNCHRONOUSLY WHICH IS ALRIGHT SINCE THIS IS JUST ONCE
// DURING STARTUP
if(!fs.existsSync('./logs')) fs.mkdirSync('./logs');
changeErrorLogs();
Utilities.setLogs(eventLog, errorLog, sessionLog);
console.log('Log files opened');
// SERVER PORT
app.set('port', process.env.PORT || Settings.port);
// STARTUP THE SESSION INVALIDATION -- CHECK EVERY X MINUTES
var invalidSessionTimer = setInterval(() => { checkForInvalidSessions(DataAccess, Settings) }, Settings.timeout_check * 60000);
// EVERYTHING IS INITIALIZED. RUN ANY INITIALIZATION CODE
try {
require(`${rootDir}/onInit`).run(DataAccess, Utilities, AccessControl, ServiceRegistration, Settings);
}
catch(onInitErr) {
if(onInitErr && onInitErr.code === 'MODULE_NOT_FOUND') {
console.log('Initialization script skipped -- no file found');
}
else {
console.error(onInitErr);
}
}
// ========================================================
// SETUP ROUTE HANDLERS
// ========================================================
// ---------------------------------------------------------------------------------
// OVERRIDES
// ---------------------------------------------------------------------------------
if(expressSettings) {
try {
expressSettings.overrideRoutes(app, DataAccess, Utilities);
}
catch(err) {
console.error('Override Routes Failed');
}
}
// ---------------------------------------------------------------------------------
// GETS
// ---------------------------------------------------------------------------------
app.get('/:area/:controller/:serviceCall/:version?', function (req, res) {
requestPipeline(req, res, 'GET');
});
app.get('/:area/:controller?', function (req, res) {
requestPipeline(req, res, 'GET');
});
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// POSTS
// ---------------------------------------------------------------------------------
app.post('/:area/:controller/:serviceCall/:version?', function (req, res) {
requestPipeline(req, res, 'POST');
});
app.post('/:area/:controller?', function (req, res) {
requestPipeline(req, res, 'POST');
});
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// PUTS
// ---------------------------------------------------------------------------------
app.put('/:area/:controller/:serviceCall/:version?', function (req, res) {
requestPipeline(req, res, 'PUT');
});
app.put('/:area/:controller?', function (req, res) {
requestPipeline(req, res, 'PUT');
});
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// PATCH
// ---------------------------------------------------------------------------------
app.patch('/:area/:controller/:serviceCall/:version?', function (req, res) {
requestPipeline(req, res, 'PATCH');
});
app.patch('/:area/:controller?', function (req, res) {
requestPipeline(req, res, 'PATCH');
});
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// DELETES
// ---------------------------------------------------------------------------------
app.delete('/:area/:controller/:serviceCall/:version?', function (req, res) {
requestPipeline(req, res, 'DELETE');
});
app.delete('/:area/:controller?', function (req, res) {
requestPipeline(req, res, 'DELETE');
});
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
app.get('*', function (req, res) {
res.status(404).send({ 'Error': 'Route/File Not Found' });
});
app.use((err, req, res, next) => {
if (req.xhr) {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
console.error('Bad JSON');
res.status(400).send({ 'message': 'json body malformed' });
}
else {
res.status(500).send({ 'message': 'unknown error' });
}
}
else {
next(err);
}
});
// -----------------------------------
// LAUNCH THE SERVER
// -----------------------------------
const server = http.Server(app).listen(app.get('port'), function () {
console.log('\n');
console.log('==============================================');
console.log('**************** BACKSTRAP 3 *****************')
console.log('==============================================');
console.log('Express server listening on port ' + app.get('port'));
console.log(new Date().toISOString());
console.log('');
});
if(Settings.server_timeout != null) server.timeout = parseInt(Settings.server_timeout);
if(Settings.keep_alive_timeout != null) server.keepAliveTimeout = parseInt(Settings.keep_alive_timeout);
if(Settings.headers_timeout != null) server.headersTimeout = parseInt(Settings.headers_timeout);
})
.catch((err) => {
console.log('Initialization Failure');
console.log(err);
return 2;
});
// ================================================
// MAIN REQUEST PIPELINE
// ================================================
function requestPipeline(req, res, verb) {
var params = req.params;
var area = params.area;
var controller = params.controller;
var serviceCall
if(!params.serviceCall) {
if(Settings.index_service_call != null) {
serviceCall = Settings.index_service_call;
}
else {
serviceCall = "index";
}
}
else{
serviceCall = params.serviceCall;
}
var args;
if(verb.toLowerCase() === 'get') {
args = req.query;
}
else if(verb.toLowerCase() === 'delete') {
// CHECK THE BODY FIRST, IF THERE IS NO BODY OR THE BODY IS EMPTY
// CHECK THE QUERY STRING.
args = req.body;
if (args == null || args.length === 0) {
args = {};
}
var argKeys = Object.keys(args);
if (argKeys.length === 0) {
args = req.query;
req.body = req.query;
}
}
else {
args = req.body;
}
var version = params.version;
var accessLogEvent;
if(Settings.access_logging === true) {
var endpointString = area+'/'+controller+'/'+serviceCall+'/'+version;
var ips = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
accessLogEvent = {
start_timestamp: new Date().toISOString(),
endpoint: endpointString,
user_agent: req.headers['user-agent'],
ips: ips
};
}
ServiceRegistration.serviceCallExists(serviceCall, area, controller, verb, version)
.then((sc) => {
let continueWhenInvalid = false;
if (!sc.authRequired) {
continueWhenInvalid = true;
}
// IF THERE IS A BACKSTRAP STYLE AUTH HEADER OR NEITHER A BACKSTRAP AUTH HEADER NOR BASIC/BEARER AUTH HEADER
if(req.headers[Settings.token_header] != null ||
(req.headers[Settings.token_header] == null && req.headers['authorization'] == null)) {
return Promise.all([sc, AccessControl.validateToken(req.headers[Settings.token_header], continueWhenInvalid)]);
}
// OTHERWISE THIS IS BASIC OR BEARER AUTH
// BASIC AUTH IS BACKSTRAP NATIVE API USERS
// BEARER AUTH USES JWTs FROM EXTERNAL IDENTITY PROVIDERS
else {
[authType] = req.headers['authorization'].split(' ');
if(authType.toLowerCase() === 'basic') {
return Promise.all([sc, AccessControl.validateBasicAuth(req.headers['authorization'], continueWhenInvalid)]);
}
else if(authType.toLowerCase() === 'bearer') {
return Promise.all([sc, AccessControl.validateJwt(req.headers['authorization'], continueWhenInvalid)]);
}
else {
if(continueWhenInvalid) {
return Promise.all([sc, Promise.resolve({is_valid: false})]);
}
else {
return Promise.all([sc, Promise.reject(new ErrorObj(403,
'bs0001',
__filename,
'requestPipeline',
'bad auth type',
'Unauthorized',
null))]);
}
}
}
})
.then(([sc, validTokenResponse]) => {
let inner_promise = new Promise((resolve, reject) => {
if(validTokenResponse.is_valid === true) {
// SEE IF THIS IS BACKSTRAP STYLE AUTH OR BASIC/BEARER AUTH
if(validTokenResponse.hasOwnProperty('session')) {
if(Settings.access_logging === true) accessLogEvent.session_id = validTokenResponse.session.id;
DataAccess.getUserBySession(validTokenResponse.session.id)
.then((usr) => {
resolve(usr);
})
.catch((usr_err) => {
if(sc.authRequired) {
let errorObj = new ErrorObj(403,
'bs0002',
__filename,
'requestPipeline',
'unauthorized',
'Unauthorized',
null);
reject(errorObj);
}
else {
resolve(null);
}
});
}
else if(validTokenResponse.hasOwnProperty('user')) {
resolve(validTokenResponse.user);
}
else {
if(Settings.access_logging === true) accessLogEvent.client_id = validTokenResponse.client_id;
DataAccess.getUserByClientId(validTokenResponse.client_id, false)
.then((usr) => {
resolve(usr);
})
.catch((usr_err) => {
if(sc.authRequired) {
let errorObj = new ErrorObj(403,
'bs0003',
__filename,
'requestPipeline',
'unauthorized',
'Unauthorized',
null);
reject(errorObj);
}
else {
resolve(null);
}
});
}
}
else {
resolve(null);
}
});
return Promise.all([sc, validTokenResponse, inner_promise]);
})
.then(([sc, validTokenResponse, userOrNull]) => {
//PUT THE USER OBJECT ON THE REQUEST
if(userOrNull !== null) {
req.this_user = userOrNull;
}
if (sc.authRequired) {
return Promise.all([sc, validTokenResponse, AccessControl.verifyAccess(req, sc)]);
}
else {
return [sc, validTokenResponse];
}
})
.then(([sc, validTokenResponse]) => {
return Promise.all([sc, validTokenResponse, ServiceRegistration.validateArguments(serviceCall, area, controller, verb, version, args)]);
})
.then(async ([sc, validTokenResponse]) => {
try {
let results = await Controller.resolveServiceCall(sc, req);
if(validTokenResponse.session != null) {
DataAccess.UpdateLastTouch(validTokenResponse.session.id);
}
// IF ACCESS LOGGING IS ENABLED. ADD THE END TIMESTAMP
// AND RESPONSE STATUS NUM TO THE ACCESS LOG EVENT AND
// WRITE IT TO THE LOG
if(Settings.access_logging === true) {
accessLogEvent.end_timestamp = new Date().toISOString();
accessLogEvent.http_status = 200;
let logEntry = JSON.stringify(accessLogEvent)+'\n';
accessLog.write(logEntry);
}
if(results && results.express_download === true){
if(results.download_name){
res.status(200).download(results.download_path, results.download_name);
}
else {
res.status(200).download(results.download_path);
}
}
else if (results && results.status_code === 308 && results.redirect_url) {
res.redirect(results.redirect_url);
}
else {
res.status(200).send(results);
}
}
catch(err) {
let formattedErr = formatError(err);
finishLogging(formattedErr, accessLog, accessLogEvent, errorLog);
res.status(formattedErr.status).send(formattedErr);
}
})
.catch((err) => {
let formattedErr = formatError(err);
finishLogging(formattedErr, accessLog, accessLogEvent, errorLog);
res.status(formattedErr.status).send(formattedErr);
});
}
// ----------------------------------------------
// DETERMINE IF THIS IS A CUSTOM OR NATIVE ERROR
// AND FORMAT APPROPRIATELY
// ----------------------------------------------
function formatError(err) {
let formattedErr;
if(err != null) {
if(err instanceof ErrorObj) {
formattedErr = err;
if(err.message == null || err.message == '') formattedErr.message = 'unknown error';
formattedErr.status = err.http_status;
delete formattedErr.http_status;
}
else if(err instanceof Error) {
formattedErr = { timestamp: new Date().toISOString() };
if(err.status == null) {
formattedErr.status = 500;
}
else {
formattedErr.status = err.status;
}
formattedErr.message = err.toString();
formattedErr.stack_trace = err.stack.toString();
}
}
else {
formattedErr = {
status: 500,
message: 'unknown error'
};
}
return formattedErr;
}
// ---------------------------------------------------
// WRITE TO ACCESS LOG AND ERROR LOG WHERE APPLICABLE
// ---------------------------------------------------
function finishLogging(err, accessLog, accessLogEvent, errorLog) {
// IF ACCESS LOGGING IS ENABLED. ADD THE END TIMESTAMP
// AND RESPONSE STATUS NUM TO THE ACCESS LOG EVENT AND
// WRITE IT TO THE LOG
if(Settings.access_logging === true) {
accessLogEvent.end_timestamp = new Date().toISOString();
accessLogEvent.http_status = err.status;
let logEntry = JSON.stringify(accessLogEvent)+'\n';
accessLog.write(logEntry);
}
let errorLogEntry = JSON.stringify(err) + '\n';
errorLog.write(errorLogEntry);
}
// -----------------------------------
// SWITCH ERROR LOGS AT MIDNIGHT
// -----------------------------------
function changeErrorLogs() {
let today = new Date();
var monthNum = today.getMonth()+1;
var monthString = monthNum < 10 ? '0'+monthNum : monthNum;
var dateString = today.getDate() < 10 ? '0'+today.getDate() : today.getDate();
let todayString = monthString+'-'+dateString+'-'+today.getFullYear();
// THESE PATHS ARE USED WITH fs WHICH USES THE PROJECT ROOT AS PWD
let errorLogPath = './logs/error-'+todayString;
let accessLogPath = './logs/access-'+todayString;
let sessionLogPath = './logs/session-'+todayString;
let eventLogPath = './logs/event-'+todayString;
var newErrorLog = fs.createWriteStream(errorLogPath, {flags:'a'});
var newEventLog = fs.createWriteStream(eventLogPath, {flags:'a'});
var newAccessLog = null;
if(Settings.access_logging === true)
newAccessLog = fs.createWriteStream(accessLogPath, {flags:'a'});
var newSessionLog = null;
if(Settings.session_logging === true)
newSessionLog = fs.createWriteStream(sessionLogPath, {flags:'a'});
if(errorLog != null) errorLog.end();
errorLog = newErrorLog;
if(eventLog != null) eventLog.end();
eventLog = newEventLog;
if(Settings.access_logging === true) {
if(accessLog != null) accessLog.end();
accessLog = newAccessLog;
}
else {
accessLog = null;
}
if(Settings.session_logging === true) {
if(sessionLog != null) sessionLog.end();
sessionLog = newSessionLog;
}
else {
sessionLog = null;
}
// DELETE LOGS OLDER THAN today - log_rotation_period
var evictionDate = new Date();
evictionDate.setDate(evictionDate.getDate()-Settings.log_rotation_period);
evictionDate.setHours(0,0,0,0);
fs.readdir('./logs/', (err, files) => {
if(!err) {
for(var fIdx = 0; fIdx < files.length; fIdx++) {
let filepath = './logs/'+files[fIdx];
fs.stat(filepath, (stat_err, stats) => {
if(!stat_err) {
var createDate = new Date(stats.birthtime);
createDate.setHours(0,0,0,0);
if(createDate < evictionDate) {
fs.unlink(filepath, (del_err, del_res) => {
if(del_err) {
var errObj = {
display_message:"Problem evicting log files",
file: filepath,
timestamp: new Date(),
results: del_err
}
let logEntry = JSON.stringify(errObj)+'\n';
errorLog.write(logEntry);
}
})
}
}
else {
var errObj = {
display_message:"Problem evicting log files",
file: filepath,
timestamp: new Date(),
results: stat_err
}
let logEntry = JSON.stringify(errObj)+'\n';
errorLog.write(logEntry);
}
})
}
}
else {
var errObj = {
display_message:"Problem evicting log files",
timestamp: new Date(),
results: err
}
let logEntry = JSON.stringify(errObj)+'\n';
errorLog.write(logEntry);
}
})
var midnightTonight = new Date();
midnightTonight.setDate(midnightTonight.getDate()+1);
midnightTonight.setHours(0, 0, 0, 0);
var rightNow = new Date();
var interval = midnightTonight.getTime() - rightNow.getTime();
setTimeout(changeErrorLogs, interval);
return;
}
// -----------------------------------
// PRINT OBJECT AS JSON TO CONSOLE
// -----------------------------------
function printObject(obj) {
console.log(JSON.stringify(obj, null, 4));
}
// ----------------------------------------
// CHECK FOR SESSIONS WHICH HAVE TIMED OUT
// ----------------------------------------
function checkForInvalidSessions(DataAccess, Settings, callback) {
return new Promise((resolve, reject) => {
//THIS RETURNS STALE SESSIONS
DataAccess.GetDeadSessions(Settings.timeout, true)
.then((deadSessions) => {
let ids = [];
// IF LOGGING SESSIONS, WRITE OUT THE DEAD SESSIONS TO
// THE SESSION LOG
for(var sIdx = 0; sIdx < deadSessions.length; sIdx++) {
ids.push(deadSessions[sIdx].id);
if(Settings.session_logging === true) {
let dsObj = {
session_id: deadSessions[sIdx].id,
token: deadSessions[sIdx].token,
user_id: deadSessions[sIdx].user_id,
started_at: deadSessions[sIdx].created_at,
ended_at: deadSessions[sIdx].ended_at
}
var logEntry = JSON.stringify(dsObj)+'\n';
sessionLog.write(logEntry);
}
}
if(ids.length > 0) {
DataAccess.DeleteSessions(ids)
.then((res) => {
resolve();
})
.catch((err) => {
let logEntry = JSON.stringify(err)+'\n';
errorLog.write(logEntry);
resolve();
})
}
else {
resolve();
}
})
.catch((err) => {
console.log(err);
resolve();
})
});
}