1
0
Fork 0
liza/src/server/daemon/controller.js

850 lines
23 KiB
JavaScript
Raw Normal View History

/**
* Route controller
*
* Copyright (C) 2010-2019 R-T Specialty, LLC.
*
* This file is part of the Liza Data Collection Framework.
*
* liza is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @todo this is a mess of routing and glue code
*/
const {
Db: MongoDb,
Server: MongoServer,
Connection: MongoConnection,
ReplSetServers: ReplSetServers,
} = require( 'mongodb/lib/mongodb' );
const easejs = require( 'easejs' );
const regex_base = /^\/quote\/([a-z0-9-]+)\/?(?:\/(\d+)\/?(?:\/(.*))?|\/(program.js))?$/;
const regex_step = /^step\/(\d+)\/?(?:\/(post|visit))?$/;
const http = require( 'http' );
const crypto = require( 'crypto' );
var server = null;
var server_cache = null;
var rating_service = null;
const {
bucket: {
QuoteDataBucket,
delta
},
dapi: {
http: {
HttpDataApi,
HttpDataApiUrlData,
NodeHttpImpl,
SpoofedNodeHttpImpl,
},
DataApiFactory,
DataApiManager,
},
document: {
DocumentProgramFormatter,
},
field: {
FieldClassMatcher,
},
server: {
DocumentServer,
db: {
MongoServerDao: { MongoServerDao },
},
lock: {
Semaphore,
},
quote: {
ServerSideQuote: Quote,
ProgramQuoteCleaner,
},
service: {
export: {
ExportService,
},
RatingService: { RatingService },
RatingServicePublish,
TokenedService,
},
token: {
MongoTokenDao: {
MongoTokenDao
},
},
request: {
CapturedUserResponse,
SessionSpoofHttpClient,
UserResponse,
},
},
store,
} = require( '../..' );
const amqplib = require( 'amqplib' );
// read and write locks, as separate semaphores
var rlock = Semaphore(),
wlock = Semaphore();
// concurrent session flag
var sflag = {};
// TODO: kluge to get liza somewhat decoupled from lovullo (rating module)
exports.rater = {};
exports.skey = "";
exports.post_rate_publish = {};
exports.init = function( logger, enc_service, conf, env )
{
var db = _createDB( logger );
const dao = new MongoServerDao( db, env );
db.collection( 'quotes', function( err, collection )
{
_createDocumentServer( dao, logger, enc_service, conf, collection ).then( srv =>
{
server = srv;
server_cache = _createCache( server );
server.init( server_cache, exports.rater );
// TODO: temporary proof-of-concept
rating_service = easejs( RatingService ).use(
RatingServicePublish( amqplib, exports.post_rate_publish, logger )
)(
logger, dao, server, exports.rater, delta.createDelta
);
// TODO: exports.init needs to support callbacks; this will work, but
// only because it's unlikely that we'll get a request within
// milliseconds of coming online
_initExportService( collection, function( service )
{
c1_export_service = service;
} );
server.on( 'quotePverUpdate', function( quote, program, event )
{
// let them know that we're going to be a moment
var c = event.wait();
getCleaner( program ).clean( quote, function( err )
{
// report on our success/failure
if ( err )
{
event.bad( err );
}
else
{
event.good();
}
// we're done
c();
} );
} );
} );
} );
}
// TODO: Remove this and use the new MongoFactory.ts
function _createDB( logger )
{
if(process.env.LIZA_MONGODB_HA==1)
{
var mongodbPort = process.env.MONGODB_PORT || MongoConnection.DEFAULT_PORT;
var mongodbReplSet = process.env.LIZA_MONGODB_REPLSET || 'rs0';
var dbServers = new ReplSetServers(
[
new MongoServer( process.env.LIZA_MONGODB_HOST_A, +process.env.LIZA_MONGODB_PORT_A || mongodbPort),
new MongoServer( process.env.LIZA_MONGODB_HOST_B, +process.env.LIZA_MONGODB_PORT_B || mongodbPort)
],
{rs_name: mongodbReplSet, auto_reconnect: true}
);
}
else
{
var dbServers = new MongoServer(
process.env.MONGODB_HOST || '127.0.0.1',
+process.env.MONGODB_PORT || MongoConnection.DEFAULT_PORT,
{auto_reconnect: true}
);
}
var db = new MongoDb(
'program',
dbServers,
{native_parser: false, safe: false, logger: logger}
);
return db;
}
function _createDocumentServer( dao, logger, enc_service, conf, collection )
{
const origin_url = process.env.HTTP_ORIGIN_URL || '';
if ( !origin_url )
{
// this allows the system to work without configuration (e.g. for
// local development), but is really bad
logger.log( logger.PRIORITY_IMPORTANT,
"*** HTTP_ORIGIN_URL environment variable not set; " +
"system will fall back to using the origin of HTTP requests, " +
"meaning an attacker can control where server-side requests go! ***"
);
}
return DocumentServer()
.create( dao, logger, enc_service, origin_url, conf, collection );
}
function _initExportService( collection, callback )
{
var spoof_host = (
''+(
process.env.C1_EXPORT_HOST
|| process.env.LV_RATE_DOMAIN
|| process.env.LV_RATE_HOST
).trim()
);
var spoof = SessionSpoofHttpClient( http, spoof_host );
callback(
ExportService
.use( TokenedService(
'c1import',
new MongoTokenDao( collection, "exports", getUnixTimestamp ),
function tokgen()
{
var shasum = crypto.createHash( 'sha1' );
shasum.update( ''+Math.random() );
return shasum.digest( 'hex' );
},
function newcapturedResponse( request, callback )
{
return UserResponse
.use( CapturedUserResponse( callback ) )
( request );
}
) )
( spoof )
);
}
/**
* Retrieve current date as a Unix timestamp
*/
function getUnixTimestamp()
{
return Math.floor( ( new Date() ).getTime() / 1000 );
}
/**
* Create server cache
*
* TODO: This needs to be moved elsewhere; it is a stepping-stone
* kluge.
*
* @param {Server} server server containing miss methods
*
* @return {Store} cache
*/
function _createCache( server )
{
const progjs_cache = store.MemoryStore.use(
store.MissLookup( server.loadProgramFiles.bind( server ) )
)();
const step_prog_cache = store.MemoryStore.use(
store.MissLookup( program_id => Promise.resolve(
store.MemoryStore.use(
store.MissLookup(
server.loadStepHtml.bind( server, program_id )
)
)()
) )
)();
const prog_cache = store.MemoryStore.use(
store.MissLookup( server.loadProgram.bind( server ) )
)();
const cache = store.MemoryStore.use( store.Cascading )();
cache.add( 'program_js', progjs_cache );
cache.add( 'step_html', step_prog_cache );
cache.add( 'program', prog_cache );
return cache;
}
exports.reload = function()
{
// will cause all steps, programs, etc to be reloaded on demand
server_cache.clear();
server.reload( exports.rater );
};
exports.route = function( request )
{
var data;
if ( !( data = request.getUri().match( regex_base ) ) )
{
// we don't handle this URI
return Promise.resolve( false );
}
// we don't want to cache the responses, as most of them change with each
// request
request.noCache();
var program_id = data[1];
return server.getProgram( program_id )
.then( function( program )
{
return new Promise( function( resolve, reject )
{
doRoute( program, request, data, resolve, reject );
} );
} );
}
function doRoute( program, request, data, resolve, reject )
{
// store our data in more sensible vars
var program_id = data[1],
quote_id = +data[2] || 0,
cmd = data[3] || data[4] || '',
session = request.getSession();
// if we were unable to load the program class, that's a problem
if ( program === null )
{
server.sendError( request,
'Internal error. Please contact our support team for ' +
'support.' +
'<br /><br />Your information has <em>not</em> been saved!'
);
resolve( true );
return;
}
var skey = has_skey( request );
// is the user currently logged in?
if ( ( request.getSession().isLoggedIn() === false )
&& !skey
)
{
// todo: this is temporary so we don't break our current setup; remove
// this check once we can error out before we even get to this point
// (PHP current handles the initial page load)
if ( cmd !== 'program.js' )
{
session.setRedirect( '/quote/' + program_id + '/', function()
{
session.setReturnQuoteNumber( quote_id, function()
{
// peoples are trying to steal our secrets!?!!?
server.sendError(
request,
'Please <a href="/login">click here</a> to log in.'
);
} );
} );
resolve( true );
return;
}
}
// if the session key was provided, mark us as internal
if ( skey )
{
request.getSession().setAgentId( '900000' );
}
// we'll be serving all our responses as plain text
request.setContentType( 'text/plain' );
if ( data = cmd.match( regex_step ) )
{
var step_id = data[1];
var step_action = ( data[2] !== undefined ) ? data[2] : '';
switch ( step_action )
{
case 'post':
acquireWriteLock( quote_id, request, function()
{
handleRequest( function( quote )
{
server.handlePost(
step_id, request, quote, program, session
);
} );
} );
break;
case 'visit':
acquireRwLock( quote_id, request, function()
{
handleRequest( function( quote )
{
server.visitStep( step_id, request, quote );
} );
} );
break;
default:
// send the requested step to the client
acquireReadLock( quote_id, request, function()
{
handleRequest( function( quote )
{
server.sendStep(
request, quote, program, step_id, session
);
} );
} );
break;
}
}
else if ( cmd == 'init' )
{
acquireWriteLock( quote_id, request, function()
{
handleRequest( function( quote )
{
server.sendInit(
request,
quote,
program,
// for invalid quote requests
createQuoteQuick,
// concurrent access?
getConcurrentSessionUser( quote_id, session )
);
} );
} );
}
else if ( cmd == 'progdata' )
{
acquireReadLock( quote_id, request, function()
{
handleRequest( function( quote )
{
const response = UserResponse( request );
const bucket = quote.getBucket();
const class_matcher = FieldClassMatcher( program.whens );
DocumentProgramFormatter( program, class_matcher )
.format( bucket )
.then( data => response.ok( data ) )
.catch( e => response.internalError( {}, e ) );
} );
} );
}
else if ( cmd === 'mkrev' )
{
// the database operation for this is atomic and disjoint from
// anything else we're doing, so no need to acquire any sort of
// lock
handleRequest( function( quote )
{
server.createRevision( request, quote );
} );
}
// TODO: diff against other revisions as well
else if ( data = cmd.match( /^revdiffgrp\/(.*?)\/(\d+)$/ ) )
{
// similar to above; no locking needed
handleRequest( function( quote )
{
var gid = data[ 1 ] || '',
revid = +data[ 2 ] || 0;
server.diffRevisionGroup( request, program, quote, gid, revid );
} );
}
else if ( cmd == 'program.js' )
{
// no quote involved; just send the JS
server.sendProgramJs( request, program_id );
resolve( true );
return;
}
else if ( /^rate\b/.test( cmd ) )
{
// the client may have optionally requested the rate for a specific
// alias
var ratedata = cmd.match( /^rate(?:\/([a-z]+))?/ ),
alias = ratedata[ 1 ];
// request manual lock freeing; allows us to free the lock when we
// want to (since we'll be saving data to the DB async, after the
// response is already returned)
acquireWriteLock( quote_id, request, function( free )
{
// if we're performing deferred rating, it must be async;
// immediately free the locks and trust that the deferred process
// knows what it is doing and can properly handle such concurrency
alias && free();
handleRequest( function( quote )
{
var response = UserResponse( request );
rating_service.request( request, response, quote, alias )
.catch( () => {} )
.then( () => free() );
} );
}, true );
}
else if ( /^worksheet\//.test( cmd ) )
{
var wdata = cmd.match( /^worksheet\/(.+)\/([0-9]+)/ ),
supplier = wdata[ 1 ],
index = +wdata[ 2 ];
handleRequest( function( quote )
{
rating_service.serveWorksheet( request, quote, supplier, index );
} );
}
else if ( /^export\//.test( cmd ) )
{
var import_data = cmd.match( /^export\/(.+?)(?:\/(.+))?$/ ),
type = import_data[ 1 ],
subcmd = import_data[ 2 ];
// TODO: extract body
handleRequest( function( quote )
{
// TODO: support type
c1_export_service.request(
request,
UserResponse( request ),
quote,
subcmd
);
} );
}
else if ( cmd === 'quicksave' )
{
quicksave: Remove saving and restoring of state The quicksave feature was added back in 2011 when the framework was somewhat unstable---we were taking calls from users and it wasn't a pleasent experience to tell them that they had to refresh the page to work around some issue with bad state, and lose all unsaved data. This feature is now more trouble than it's worth, since it causes a number of bugs and can even cause data corruption. If this is reintroduced in the future, I'd prefer it be done by periodically saving quote state after every or a few bucket modifications, to permit Meteor-like features. This keeps the feature available on the client as a heartbeat for quote locking; this can be removed in the future when we handle distributed locking. * src/client/Client.js (_changeQuote): Stop setting quicksave data from request. (_mergeQuickSaveData): Remove now-unused method. (_createUi): Stop merging quicksave data on render. (saveStaging): Add note that this method is now only used as a heartbeat. * src/client/quote/ClientQuote.js (saveStaging): Add comment indicating that this is now used for a heartbeat. (setQuickSaveData, getQuickSaveData): Remove methods. * src/quote/BaseQuote.js (_quickSaveData): Remove field. (setQuickSaveData, getQuickSaveData): Remove methods. * src/server/Server.js (initQuote): Do not create or initialize quicksave data. (handleQuickSave): Remove method. * src/server/daemon/controller.js (doRoute): Return empty reply without processing quicksave data. Continue touching session to retain quote lock. * src/server/db/MongoServerDao.js (saveQuote): Do not clear quicksave. (quickSaveQuote): Remove method.
2019-09-04 11:59:36 -04:00
// TODO: we keep this route around only as a heartbeat, for now; the
// original purpose of this route (to save staged data) has been
// removed
handleRequest( function( quote )
{
quicksave: Remove saving and restoring of state The quicksave feature was added back in 2011 when the framework was somewhat unstable---we were taking calls from users and it wasn't a pleasent experience to tell them that they had to refresh the page to work around some issue with bad state, and lose all unsaved data. This feature is now more trouble than it's worth, since it causes a number of bugs and can even cause data corruption. If this is reintroduced in the future, I'd prefer it be done by periodically saving quote state after every or a few bucket modifications, to permit Meteor-like features. This keeps the feature available on the client as a heartbeat for quote locking; this can be removed in the future when we handle distributed locking. * src/client/Client.js (_changeQuote): Stop setting quicksave data from request. (_mergeQuickSaveData): Remove now-unused method. (_createUi): Stop merging quicksave data on render. (saveStaging): Add note that this method is now only used as a heartbeat. * src/client/quote/ClientQuote.js (saveStaging): Add comment indicating that this is now used for a heartbeat. (setQuickSaveData, getQuickSaveData): Remove methods. * src/quote/BaseQuote.js (_quickSaveData): Remove field. (setQuickSaveData, getQuickSaveData): Remove methods. * src/server/Server.js (initQuote): Do not create or initialize quicksave data. (handleQuickSave): Remove method. * src/server/daemon/controller.js (doRoute): Return empty reply without processing quicksave data. Continue touching session to retain quote lock. * src/server/db/MongoServerDao.js (saveQuote): Do not clear quicksave. (quickSaveQuote): Remove method.
2019-09-04 11:59:36 -04:00
server.sendEmptyReply( request, quote );
} );
touchSession( quote_id, session );
}
else if ( /^log\//.test( cmd ) )
{
// the "log" URI currently does absolutely nothing; ideally, we'd be
// able to post to this and log somewhere useful, but for now it
// just appears in the logs
handleRequest( function( quote )
{
server.sendEmptyReply( request, quote );
} );
}
else
{
resolve( false );
return;
}
// create a quote to represent this request
function handleRequest( operation )
{
createQuote( quote_id, program, request, operation, function( fatal )
{
// if fatal, notify the user and bail out
if ( fatal )
{
// an error occurred; quote invalid
server.sendError( request,
'There was a problem loading this quote; please contact ' +
'our support team for assistance.'
);
return;
}
// otherwise, the given quote is invalid, but we can provide a new
// one
server.sendNewQuote( request, createQuoteQuick );
} );
}
// we handled the request; don't do any additional routing
resolve( true );
}
/**
* Creates a new quote instance with the given quote id
*
* @param Integer quote_id id of the quote
* @param Program program program that the quote will be a part of
* @param Object request request to create quote
* @param Function( quote ) callback function to call when quote is ready
* @param Function( quote ) callback function to call when an error occurs
*
* @return undefined
*/
function createQuote( quote_id, program, request, callback, error_callback )
{
// if an invalid callback was given, log it to the console...that's a
// problem, since the quote won't even be returned!
callback = callback || function()
{
server.logger.log( log.PRIORITY_ERROR,
"Invalid createQuote() callback"
);
}
var bucket = QuoteDataBucket(),
metabucket = QuoteDataBucket(),
2019-11-07 14:13:59 -05:00
ratebucket = QuoteDataBucket(),
quote = Quote( quote_id, bucket );
quote.setMetabucket( metabucket );
2019-11-07 14:13:59 -05:00
quote.setRateBucket( ratebucket );
var controller = this;
return server.initQuote( quote, program, request,
function()
{
callback.call( controller, quote );
},
function()
{
error_callback.apply( controller, arguments );
}
);
}
function createQuoteQuick( id )
{
return Quote(
id,
QuoteDataBucket()
);
}
/**
* Check whether the proper skey (session key) was provided
*
* This is a basic authentication token that allows bypassing authentication
* for internal tasks (like creating quotes).
*
* XXX: A single shared secret is a terrible idea; this was intended to
* be a temporary solution. Fix this crap in favor of proper authentication
* between services.
*/
function has_skey( user_request )
{
if ( !exports.skey )
{
return false;
}
return ( user_request.getGetData().skey === exports.skey );
}
function getCleaner( program )
{
return ProgramQuoteCleaner( program );
}
/**
* Acquire a semaphore for a quote id
*
* Note that, since this controller is single-threaded, we do not have to worry
* about race conditions with regards to acquiring the lock.
*/
function acquireLock( type, id, request, c, manual )
{
type.acquire( id, function( free )
{
// automatically release the lock once the request completes (this is
// also safer, as it is hopefully immune to exceptions before lock
// release and will still work with the timeout system)
if ( !manual )
{
request.once( 'end', function()
{
free();
} );
}
// we're good!
c( free );
} );
// keep the quote session alive
touchSession( id, request.getSession() );
}
/**
* ALWAYS USE THIS FUNCTION WHEN TRYING TO ACQUIRE BOTH A READ AND A WRITE LOCK!
* Otherwise, the possibility for a deadlock is introduced if something else is
* attempting to acquire both locks in the opposite order!
*/
function acquireRwLock( id, request, c, manual )
{
acquireWriteLock( id, request, function()
{
acquireReadLock( id, request, c );
}, manual );
}
function acquireWriteLock( id, request, c, manual )
{
acquireLock( wlock, id, request, c, manual );
}
function acquireReadLock( id, request, c, manual )
{
acquireLock( rlock, id, request, c, manual );
}
function acquireWriteLockImmediate( id, request, c, manual )
{
if ( wlock.isLocked( id ) )
{
// we could not obtain the lock
c( null );
return;
}
// lock is free; acquire it
acquireWriteLock.apply( null, arguments );
}
function touchSession( id, session )
{
var cur = sflag[ id ];
// do not allow touching the session if we're not the owner
if ( cur && ( cur.agentName !== session.agentName() ) )
{
return;
}
sflag[ id ] = {
agentName: session.agentName(),
time: ( new Date() ).getTime(),
};
}
function getConcurrentSessionUser( id, session )
{
var flag = sflag[ id ];
// we have a flag; if we're the same user that created it (we check on name
// because internally we all have the same agent id), then do not consider
// this a concurrent access attempt
if ( !flag || ( flag.agentName === session.agentName() ) )
{
return '';
}
return ( flag.agentName );
}
// in the unfortunate situation where a write lock hangs, for whatever reason,
// this will ensure that it is eventually freed; note that, since this shouldn't
// ever happen, this interval is 30s, meaning that a given lock may exist for
// just under 60s
var __wlock_stale_interval = 30e3;
setInterval( function __wlock_stale_free()
{
// TODO: log properly and possibly kill the request
// TODO: if the same quote repeatedly has stale locks, perhaps the
// quote data is bad and should be locked
wlock.freeStale( __wlock_stale_interval, function( id )
{
console.log( 'Freeing stale write lock: ' + id );
} );
rlock.freeStale( __wlock_stale_interval, function( id )
{
console.log( 'Freeing stale read lock: ' + id );
} );
}, __wlock_stale_interval );
// set this to ~10s after the quicksave interval
var __sclear_interval = 70e3;
setInterval( function __sclear_timeout()
{
var now = ( new Date() ).getTime();
for ( var id in sflag )
{
// clear all session flags that have timed out
if ( ( now - sflag[ id ].time ) > __sclear_interval )
{
delete sflag[ id ];
}
}
}, __sclear_interval );