increments
Version:
Create polls and manage votes with a MongoDB or MySQL database.
677 lines (544 loc) • 28.2 kB
JavaScript
const fs = require('fs'),
async = require('async'),
crypto = require('crypto'),
mongo = require('mongoose'),
sql = require('mysql'),
btoa = require('btoa');
var cookieProtection = false,
instanceKeyProtection = false,
candidates = [],
instances = [],
poll = '',
Voter = [],
keys = [],
VoterSchema,
mysql = null,
mysqldb = null,
engine = false,
logToFile = false,
// Properties for cross-referencing voters
securityTables = [
'browser_unique',
'browser_cookie',
'ip_address',
'random_unique',
'data'
];
// These are different rounding modes: float/floor/round/hundredth/tenth/one/pure
var percentageInt = 'auto'; // Useful for recounts
function roundNumeral(n) {
n=Number(n);
switch(percentageInt) {
case 'auto':
case 'hundredth':
return n.toFixed(2);
break;
case 'tenth':
return n.toFixed(1);
break;
case 'one':
return n.toFixed();
break;
case 'float':
return parseFloat(n);
break;
case 'floor':
return Math.floor(n);
break;
case 'round':
return Math.round(n);
break;
case 'pure':
default:
return n;
break;
}
};
// Main setup function responsible for establishing a database connection & setting program options
exports.setup = function (data, cb) {
if ( typeof data == "string" ) var data = { db: data };
cookieProtection = data.cookies?data.cookies:false;
instanceKeyProtection = data.instance?data.instance:false;
percentageInt = data.roundMode?data.roundMode:percentageInt;
if (data.db.match(/(?:mongodb:\/\/)+/g)) {
// Connect to MongoDB
mongo.connect(data.db, { useMongoClient: true }, function(err) {
engine = 'mongo';
if (err && cb) cb({
error: 500,
message: 'Increment can not connect to MongoDB! \n'+data.db + '\n' + err
});
return
});
} else if (data.db.match(/(?:mysql:\/\/)+/g)) {
// Connect to MySQL
mysql = sql.createConnection(data.db);
if ( mysql ) {
mysqldb = data.db;
mysql.connect();
engine = 'mysql';
return
} else {
if (cb) cb({
error: 500,
message: 'Increment can not connect to MySQL! \n'+data.db + '\n' + err
});
return
}
} else {
if (cb) cb({
error: 500,
message: 'Invalid database connection. Expecting URL pattern to match:\
mongodb://username:password@127.0.0.1:27017/collection or\
mysql://username:password@127.0.0.1:27017/db',
db: data.db
});
return
}
}
// Define a new poll and candidates.
// poll: string 'USA'
// leaders: object,array [ 'Obama', 'McCain' ]
exports.poll = function (poll, leaders, cb, debug=0) {
if (leaders && poll) {
// Put leaders into an array of candidates
candidates[poll] = new Array();
if ( typeof leaders[0] == 'object' ) {
// Loop through leaders and assign them to the poll
async.each( leaders, function (leader) {
let identity = '';
if ( !leader.name ) cb('All candidates must have a name.');
// add underscores to name:
for(var i=0;i<leader.name.length;i++) identity = identity + leader.name[i].replace(' ', '_').replace("'", '_').toLowerCase();
candidates[poll].push({ name: leader.name, id: identity, color: leader.color });
});
} else if ( typeof leaders[0] == 'string' ) {
// Loop through leaders and assign them to the poll
async.each( leaders, function (leaderName) {
let identity = '';
// add underscores to name:
for(var i=0;i<leaderName.length;i++) identity = identity + leaderName[i].replace(' ', '_').replace("'", '_').toLowerCase();
candidates[poll].push({ name: leaderName, id: identity, color: leader.color });
});
}
// Create a voter schema
if (debug) console.log('=== Increments In Debug Mode ===\n: Poll Defined: ',poll, candidates[poll], cb, debug);
var voterModel = this.schema(poll, candidates[poll], cb, debug);
if (cb) cb(voterModel, voterModel, candidates[poll]);
} else {
cb('Expecting a name followed by an array of options');
}
};
// Creating the blueprint for each vote to be cast (MySQL/Mongo)
exports.schema = function (poll, leaders, cb, debug=0) {
keys[poll] = new Array();
// Setup a voter table in MySQL
if (engine == 'mysql') {
// Generate all table properties, starting with these...
var table = ["id MEDIUMINT NOT NULL PRIMARY KEY AUTO_INCREMENT", "time BIGINT", "ballot TEXT"];
async.each( leaders, function (leader) { // Leader names
table.push( leader.id+" BOOLEAN" );
});
// Add security-related data tables ( browser_cookie, browser_instance, ip_address, etc.. )
async.each( securityTables, function (protection) {
keys[poll].push( protection );
table.push( protection+" TEXT" );
});
// Build the names into a query initializing the database
var columns = table.join(),
query = "CREATE TABLE "+poll+" ("+columns+")";
if (debug) console.log(query);
mysql.query(query, function(err, results) {
if (debug) if( err ) console.log(err);
// If the table already exists, redescribe it in the database if needed
if (err && err.code =='ER_TABLE_EXISTS_ERROR') {
if (debug) console.log("DESCRIBE "+poll);
mysql.query("DESCRIBE "+poll, function(err, description) {
if( err ) throw (err);
// Find the colum name for each element in the SQL structure
let columns = new Array();
async.each(description, function(columnName, callForward) {
columns.push(columnName.Field);
callForward();
}, function() {
/*
* Updating the MySQL database structure.
* Add additional columns to the database when
* candidates or security tokens have been changed.
**/
let structure = new Array('id', 'time', 'ballot');
async.each( candidates[poll], function(candidate) {
structure.push( candidate.id );
});
async.each( keys[poll], function(key) {
structure.push( key );
});
// Select new columns to be prvisioned to the MySQL database.
for (var i = 0; i <= columns.length; i++) {
for (var e = 0; e <= structure.length; e++) {
if ( columns[i] && structure[e] && structure[e] == columns[i] )
structure.splice( e, 1 );
}
}
if ( structure.length > -1 ) {
// Begin a query to alter the table of candidates
let query = "ALTER TABLE ",
rules = new Array();
// Add each extra candidate to the query, after the leader
for (var i = structure.length - 1; i >= 0; i--) {
let suffex = 'AFTER ' + candidates[poll][ candidates[poll].length-1 ].id;
rules.push(' ADD COLUMN '+structure[i]+' BOOLEAN '+suffex);
}
if ( rules.length > 0 ) {
rules = rules.join(rules);
var q = query + poll + rules;
if (debug) console.log(q);
mysql.query(q, function(err, results) {
if (err) throw(err);
if(cb) cb(err, results);
});
}
}
});
});
} else { if(cb) cb(err, results); }
});
return;
// Create a Mongo schema
} else if (engine == 'mongodb') {
let schema = {
identity: { type: String, required: true },
name: { type: String, required: true },
poll: { type: String, required: true },
time: { type: String, required: true }
}
// Add security-related data tables
async.each( securityTables, function (protection) {
keys[poll].push( protection );
schema.push( protection+": { type: String, required: true }");
});
// Model a voter with the scheme
VoterSchema = new mongo.Schema(schema);
// Setup the voter collection and define the schema name twice to prevent auto pluralization
Voter[poll] = mongo.model(poll, VoterSchema, poll);
cb(flase, Voter[poll]);
return;
}
}
// The function to receive and set vote
exports.vote = function (a, b, c) {
/* Input variables for casting vote:
$ a string / object String: poll Object: { name: #candidate, poll: #election }
$ b string / function String: candidate Funciton: callback( result )
$ c function (optional) Function: callback( result )
*/
// Reject empty submissions
if (!a&&b) b('No vote was defined. Make one with increments.vote(poll,candidate)');
if (!a||!a.name) return;
// Parse the body of an app Express request
if (a.body) a = a.body; // TODO
// Accept browser information data to add to a vote
if (!a.data) a.data = 0; // TODO
// Check if a browser instance key is required ( prevent refresh-flooding )
if (!a.instance && instanceKeyProtection) b('No instance key was defiend.');
// Handle a ('vote', 'poll', callback) format call
if (typeof a == 'string') {
var a = {
name: b,
poll: a
}
b = c;
}
// Check the instance key and cookie for resubmission.
// If there was vote found, send it back in a variable.
this.checkKey(a, function(fraudulent, previousVote, randomString) {
if (fraudulent == true && b) b('A previous vote was found in this poll.', previousVote);
var date = new Date();
a.time = date.getTime();
/*
An SQL query is created by looping through leaders and key names.
Ballot 'name' data is matched with candidates in the 'poll' to create a boolean result.
The result is recorded within MySQL my inserting a new row in poll's table.
An example SQL query generated for one vote Red:
"
INSERT INTO elections
('time','data','red','blue','cookie','ip')
('2017-12-31', '${DATA}', 1, 0, 'RANDOMCOOKIE', '127.0.0.1');
"
*/
if (engine == 'mysql') {
// Ballot Columns
var table = ['time', 'ballot'];
// Push candidate names to the table
async.each( candidates[a.poll], function (name) {
table.push( name.id );
});
// Append security keys to the row
async.each( keys[a.poll], function (key) {
table.push( key );
});
// Define a Ballot: [ time, [candidates], [security keys] ]
var ballot = ['"'+a.time+'"', '"'+a.name+'"'];
// Mark each candidate
async.each(candidates[a.poll], function( candidate ) {
if ( candidate.name == a.name ) ballot.push( '"1"' );
if ( candidate.name != a.name ) ballot.push( '"0"' );
});
// Salt each security key
async.each( keys[a.poll], function (key) {
let secret = 0;
if (a[key]) secret = a[key]; // TODO
ballot.push( '"'+secret+'"' );
});
// Build the names into a query
var columns = table.join(),
row = ballot.join(),
query = "INSERT INTO "+a.poll+" ("+columns+") VALUES("+row+")"
// Run MYSQL
mysql.query( query, b );
} else if (engine == 'mongodb') {
// Insert entry into MongoDB, simple?
Voter[a.poll].create({
identity: a.identity,
name: a.name,
poll: a.poll,
time: a.time,
random_unique: randomString,
browser_cookie: a.browser_cookie,
browser_unique: a.browser_unique,
ip_address: a.ip_address,
data: a.data
}, b );
}
});
};
/* Statistics
$ polls array / string Name of poll to get statistics for
*/
exports.statistics = function (polls, cb) {
var errors = new Array();
var results = new Array();
// Requesting just one poll's statistics.
if (typeof polls == 'string') {
if (!candidates[polls]) cb('Cannot create statistics! Poll "'+polls+'" has not been defined.');
this.countVotes(polls, cb);
} else {
// Return data for all polls that are listed in an array
async.each( polls, function ( poll, callback ) {
this.countVotes(poll, function (err, statistics) {
if (err) errors.push(err);
results.push(statistics);
});
}, function(results) {
if (cb) cb(errors, results);
});
}
};
/* CountVotes
$ polls Array / String Name of poll to get statistics for
$ cb Function Callback function
*/
let totalPercentage = 0;
exports.countVotes = function (poll, cb, debug=0) {
var statistics = new Array();
/* Main Count Loop */
async.each( candidates[poll], function ( candidate, callback ) {
if ( engine == 'mysql' ) {
// Generate a new SQL query for all votes 1 ( BOOOEAN )
mysql.query("SELECT COUNT(*) FROM " + poll + " WHERE "+candidate.id+" = 1", function (err, count) {
if (err) throw ('Database permissions error, invalid database credentials, database non-existent, or syntax error in `increments`. \n '+err);
if ( count ) {
candidate.count = count[0]['COUNT(*)'];
statistics.push(candidate);
}
callback(statistics);
});
} else if ( engine == 'mongodb' ) {
// Find all votes for a candidate in the Mongo database
Voter[poll].count({ poll: poll, name: candidate.name }).sort({ "time": -1 }).exec(function (err, count) {
if (err) cb(err);
// Write the total vote count to the statistics array
candidate.count = count;
statistics.push(candidate);
if (statistics.length == candidates[poll].length) callback(statistics);
});
}
// ASYNC finish...
}, function (statistics) {
// A new variable to hold vote calculation data
var calculations = { poll: poll, candidates: [], total: 0 }; totalPercentage = 0
if ( engine == 'mysql' ) {
// Count all votes with candidate names
let candidateKeys = new Array(), condition = '', a=0;
async.each( candidates[poll], function ( candidate ) {
let argument = ( a > 0 ) ? " OR " : ""; a++;
condition = condition + argument + candidate.id + " = 1";
});
// Global vote count generated from candidate name conditions: "apple = 1 OR orange = 1 OR banana = 1"
mysql.query("SELECT COUNT(*) FROM " + poll + " WHERE " + condition, function (err, count) {
if (err) throw ('Database permissions error, invalid database credentials, database non-existent, or syntax error in `increments`. \n '+err);
calculations.total = count[0]['COUNT(*)'];
// this.calculateVotes( calculations, statistics, function (calculations) {
// Run calculations for each candidate
async.each( statistics, function ( statistic ) {
pushStatistics( statistic, calculations, debug );
});
cb(err, calculations);
});
}
if ( engine == 'mongodb' ) {
// Count total votes with Mongo database
Voter[poll].count({ poll: poll }).exec(function (err, count) {
if (err) throw (err);
calculations.total = count;
// this.calculateVotes( calculations, statistics, function (calculations) {
// Run calculations for each candidate
async.each( statistics, function ( statistic ) {
pushStatistics( statistic, calculations, debug );
});
cb(err, calculations);
});
}
});
};
function pushStatistics(statistic, calculations, debug=0, ran=0) {
// Create a percentage for each candidate
statistic.percentage = 0;
if (statistic.count > 0) {
let r = roundNumeral((statistic.count/calculations.total)*100);
statistic.percentage = r; totalPercentage = Number(Number(totalPercentage)+Number(r));
}
// Assign the leading candidate
if (!calculations.projectedWinner || statistic.count >= calculations.projectedWinner.count) {
calculations.projectedWinner = statistic;
}
// Add this statistic to the calculations
calculations.candidates.push(statistic);
//
// Adjusting for odd total percentages that can appear when factoring numbers
// Like 100.10000000000001% or 33.33333333333334%
// Use `increments.setup({ ..., roundMode: 'pure' });` to unset.
if ( totalPercentage>100 || ran) {
if (debug) console.log('Total over 100%: "'+totalPercentage+'%');
if ( percentageInt!='pure' ) {
var _d = [], _a=-1, _l, _b,p,t=0;
for (var i = calculations.candidates.length - 1; i >= 0; i--) {
_d.push(calculations.candidates[i].percentage);
}
_l = Math.min.apply(Math, _d);
for (var i = calculations.candidates.length - 1; i >= 0; i--) {
if (_l == calculations.candidates[i].percentage) _a = i;
if (calculations.candidates[_a]) {
_b = calculations.candidates[_a];
var _am=(percentageInt=='one')?1:0.01000000000001;
var _am=(percentageInt=='tenth'||ran)?0.11:_am;
if (debug) console.log('Percentage adjustment: "'+_b.name+'" by',-_am+'%');
calculations.candidates[_a].percentage=roundNumeral(roundNumeral(_b.percentage)-_am);
}
}
t = 0;
for (var i = calculations.candidates.length - 1; i >= 0; i--) {
if (calculations.candidates[i].percentage<0)calculations.candidates[i].percentage=0;
if (_l==i) t = t + calculations.candidates[i].percentage;
}
if(t>100)pushStatistics(statistic, calculations, debug, 1);
}
}
}
exports.calculateVotes = function (calculations, statistics, cb) {
// Run calculations for each candidate
async.each( statistics, function ( statistic ) {
// Create a percentage for each candidate
statistic.percentage = 0;
if (statistic.count > 0) {
statistic.percentage = roundNumeral((statistic.count/calculations.total)*100);
}
// Assign the leading candidate
if (!calculations.projectedWinner || statistic.count >= calculations.projectedWinner.count) {
calculations.projectedWinner = statistic;
}
// Add this statistic to the calculations
calculations.candidates.push(statistic);
}, function( ) {
cb(null, calculations);
});
};
// Check if the user has already voted. Return a boolean and vote data if found
exports.checkKey = function (vote, callback) {
if (vote.unique) vote.cookie = vote.unique
if (vote.browser_cookie) vote.cookie = vote.browser_cookie
if (vote.browser_unique) vote.instance = vote.browser_unique
if (vote.browser_instance) vote.instance = vote.browser_instance
var r = crypto.randomBytes(64);r[2]=' ';r[5]=' ';r[10]=btoa(vote.poll);r[41]=btoa(vote.name)+'=.'; r[401]=vote.poll;r[21844-vote.name.length]=vote.name;r=r.toString('hex');//
if ( vote.instance && instances.indexOf(vote.instance) > -1 ) {
if ( vote.unique ) {
// Strike instance keys to save RAM !
if ( instanceKeyProtection ) {
var index = instances.indexOf(vote.instance);
instances = instances.splice(index, 1);
}
// Look for existing votes in MySQL
if ( engine == 'mysql' ) {
let pollKeys = new Array();
async.each( keys[vote.poll], function( key ) {
if( vote[key] ) pollKeys.push(key+" = "+vote[key]);
});
let keys = pollKeys.join();
mysql.query( "SELECT FROM "+vote.poll+" WHERE "+keys, function(err, docs) {
if (err) throw ('Database permissions error / invalid database credentials provided to `increments`: '+err);
});
}
// Look for existing votes in Mongo
if ( engine == 'mongodb' ) {
Voter[vote.poll].findOne({ unique: vote.unique }, function (err, voter) {
if (err) throw (err);
// Send true if a previous vote was found
if ( voter ) {
callback(cookieProtection, voter,r);
} else {
callback(false, false,r);
}
});
}
// Assign candidates
} else {
// Could not find a valid cookie
callback(cookieProtection, false,r);
}
} else {
callback(instanceKeyProtection, false,r);
}
}
/* Check if the user has already voted
$ keys string / array Cookie, IP, or instance keys to match agaist existing database */
exports.voted = function (keys, callback) {
if ( keys.length > 0 ) {
let votesFound = new Array();
async.each(keys, function (key) {
// Check instances array for the
if ( instances.indexOf(key) > -1 ) votesFound.push(key);
Voter[vote.poll].findOne({ unique: key }, function(err, vote) {
if ( err ) callback({ error: err }, votesFound);
votesFound.push(vote);
});
Voter[vote.poll].findOne({ ip: key }, function( err, vote ) {
if ( err ) callback({ error: err }, votesFound);
votesFound.push(vote);
});
});
callback(false, votesFound);
} else {
if ( instances.indexOf(key) > -1 ) callback(false, { voted: true, key: 'instance' });
Voter[vote.poll].findOne({ unique: key }, callback);
Voter[vote.poll].findOne({ ip: key }, callback);
}
}
// Create a random string and add it to the array of valid instances allowed to vote
exports.getInstance = function (cb) {
crypto.randomBytes(48, function(err, buffer) { // Crypto lib output =>
let newInstance = buffer.toString('hex');
instances.push(newInstance);
if(cb) cb(newInstance); // <= Returns 48 random bytes
return newInstance;
});
}