ObjectCloud's voting booth system is built on top of an embedded SQlite database. Each .vote file is a unique SQlite file. The voting system uses server-side Javascript to prevent a malicious user from "stuffing the box."
/Classes/vote contains all server-side code for a voting booth. It is applied to all files
objects that have a .vote extension. This section attempts to
highlight coding techniques, but doesn't cover the entire source code
file.
The first part of the Javascript checks the embedded database's
version. If it hasn't been set, it creates the schema. Calls to
base.PostQuery() must be called as the .vote file's owner, and must be
called in an elevated security context. Once the schema is created,
the version is assigned to 0.
elevate(function()
{
var version = base.GetVersion_Sync({});
// Constructor
if (null == version)
lockMe(function()
{
callAsOwner(function()
{
// Need to double-check in case someone else built the DB...
var version = base.GetVersion_Sync({});
if (null == version)
{
base.PostQuery_Sync({query:"create table Question (qId integer not null primary key AUTOINCREMENT, text not null)"});
base.PostQuery_Sync({query:"create table AuthenticatedVotes (qId not null, voterId not null, timestamp integer not null)"});
base.PostQuery_Sync({query:"create table AnonymousVotes (qId not null, timestamp integer not null)"});
base.PostQuery_Sync({query:"create table Options (theOptions not null)"});
var defaultOptions =
{
canChangeVote: false,
canSeeResultsWithoutVoting: false,
title: "",
question: ""
};
base.PostQuery_Sync({query:"insert into Options (theOptions) values (@theOptions)", "@theOptions": JSON.stringify(defaultOptions)});
base.SetVersion_Sync({version:0});
version = 0;
}
});
});
});When constructing the schema, the entire object is locked. The
version is checked inside and outside of the lock in case multiple
requests attempt to create the schema.
SQL is used to count the results. In this case, when getting the results, we use a compound query that has 3 sub-queries:
From the getVotes() function:
elevate(function()
{
resultsFromDB = base.PostQuery_Sync( { query: "select qId from Question group by qId;" + "select distinct qId, count(qId) as numVotes from AuthenticatedVotes group by qId;" + "select distinct qId, count(qId) as numVotes from AnonymousVotes group by qId;" });
});
var resultsToReturn =
{
authenticated: aggregate(resultsFromDB[0], resultsFromDB[1]),
anonymous: aggregate(resultsFromDB[0], resultsFromDB[2])
};
The contents of aggregate are shown for your convenience:
function aggregate(validQIds, results)
{
var toReturn = {};
for (var ctr = 0; ctr < validQIds.length; ctr++)
toReturn[validQIds[ctr].qId] = 0;
for (var ctr = 0; ctr < results.length; ctr++)
if (null != toReturn[results[ctr].qId])
toReturn[results[ctr].qId] = results[ctr].numVotes;
return toReturn;
}
base.PostQuery() always returns a compound array. For compound queries, the most significant index corresponds with the specific query; for single queries the most significant index is always 0. The inner array is always an array of objects returned from the query. When a query returns a scalar, the scalar is used instead of the inner array.
A simpler query is to get the current user's vote:
getVote.webCallable = "GET";
getVote.minimumWebPermission = "Read"; // In case someone's permission changed! What if someone closed voting by revoking Write?
getVote.webReturnConvention = "JavaScriptObject";
function getVote()
{var userMetadata = getConnectionMetadata();var qId = null;
if (userMetadata.name != "anonymous")
elevate(function()
{
var votes = base.PostQuery_Sync( { query: "select qId from AuthenticatedVotes where voterId = @voterId", "@voterId": userMetadata.id })[0];
if (votes.length > 0)
qId = votes[0].qId;
});
return qId;
}
The above query still returns an outer array, thus votes is assigned to the 0th element of base.PostQuery's results. Another interesting difference is that this query uses symbolic arguments. SQlite replaces @voterId with the value passed in to the second argument's array. This technique avoids SQL injection attacks.
Note that the above function sets the webCallable, minimumWebPermission, and webReturnConvention values for the getVote function. These values are needed in order to expose the getVote function to the web.
The voting booth's options are stored as a serialized JSON object in a table with one row. This keeps the schema simple, and allows for simple addition of options without needing to update the schema.
setOptions.webCallable = "POST_application_x_www_form_urlencoded";
setOptions.minimumWebPermission = "Administer";
setOptions.parser_canChangeVote = "bool";
setOptions.parser_canSeeResultsWithoutVoting = "bool";
function setOptions(canChangeVote, canSeeResultsWithoutVoting, question, title)
{
var options = getOptions();
if (null != canChangeVote)
options.canChangeVote = canChangeVote;
if (null != canSeeResultsWithoutVoting)
options.canSeeResultsWithoutVoting = canSeeResultsWithoutVoting;
if (null != question)
options.question = question;
if (null != title)
options.title = title;
elevate(function()
{
base.PostQuery_Sync( { query: "update Options set theOptions=@theOptions", "@theOptions": JSON.stringify(options) });
});
}
function getOptions()
{
var options;
elevate(function()
{
callAsOwner(function()
{
options = base.PostQuery_Sync( { query: "select theOptions from Options" })[0][0];
options = eval('(' + options + ')');
});
});
return options;
}
Because PostQuery always returns a compound array, the single row from the Options table is element [0][0]. theOptions, a serialized JSON string, is eval()ed to parse. Because the string comes from trusted sources, we can use eval() instead of JSON.parse().
The template .vote file, /Templates/Voting Booth Template.vote is copied whenever a new voting booth is created. The file was created with the following steps:
The following line was added to /Shell/Navigation/Directory.json so that users can create a new Voting Booth:
{"Template":"/Templates/Voting Booth Template.vote","Extension":".vote","Display":"Voting Booth (Experimental)"}
It's possible to use Whisquil, a simple SQL shell, to view the contents of a .vote file. To do so, use the following URL syntax:
/Shell/Editors/Whisquil.wchtml?FileName=[Path to .vote file]
Narl, (pronounced "nar-eye",) allows someone to set the valid responses and options for a voting booth. Narl is stored at /Shell/Editors/Narl.wchtml
Narl loads the Javascript object to access the server-side code with the following <? Scripts... tag:
<? Scripts(/API/Prototype.js, <? $_GET["FileName"] ?>?Method=GetJSW&assignToVariable=VotingBooth, /API/nicEdit.js) ?>
The options and questions are pre-loaded using WebComponents:
var pollInfo = <? WebComponent($_GET["FileName"] . "?Method=getQuestionsAndOptions") ?>;
var options = pollInfo.options;
var questions = pollInfo.questions;
Options are written back to the server using the VotingBooth object
that wraps all AJAX. (Note: Prototype.js gives the $("...") syntax)
function updateOptions()
{
$("OptionsEditor").disable();
var question = $("question").innerHTML;
VotingBooth.setOptions(
{ canChangeVote: $("canChangeVote").checked, canSeeResultsWithoutVoting: $("canSeeResultsWithoutVoting").checked, question: question, title: $("q_title").value, }, function() { $("OptionsEditor").enable(); options.question = question; }); }
A question is added and updated using the VotingBooth object as well:
function addQuestion()
{
var newQuestion = $("AddQuestionInput").value;
$("AddQuestionInput").value = "";
VotingBooth.addQuestion(
newQuestion,
{},
displayQuestion);
}
function updateQuestion(qId)
{
var text = $("q" + qId).value;
VotingBooth.updateQuestion(
qId,
text,
{},
function(question)
{
$("q" + qId).value = question.text;
});
}
Goreal, (pronounced "Gore-al,") allows someone to vote. Goreal is stored at /Shell/Viewers/Goreal.wchtml
Goreal loads the Javascript wrapper, questions, and options just like Narl.
Goreal loads the currently-submitted vote with a WebComponent. If the user hasn't voted, it's set to null.
var vote = <? WebComponent($_GET["FileName"] . "?Method=getVote") ?>;
The vote is submitted through AJAX:
function submitVote()
{
var voteToSubmit = null;
questions.each(function(question)
{
if ($("q" + question.qId).checked)
voteToSubmit = question.qId;
});
if (null != voteToSubmit)
VotingBooth.vote(
voteToSubmit,
{},
function()
{
window.location.reload(true);
});
else
alert("Please select a question");
}
Results are also loaded through AJAX. In the event that the query takes awhile to run, the user will still see a page:
if (options.canSeeResultsWithoutVoting || null != vote)
VotingBooth.getVotes(
{},
function(votes)
{
questions.each(function(question)
{
var qId = question.qId;
var qSpan = $("s" + qId);
qSpan.innerHTML = ': <span style="font-size: 1.5em">' + (votes.authenticated[qId] + votes.anonymous[qId]) + '</span><span style="font-size: 0.75em"> (' +
votes.authenticated[qId] + ' authenticated, ' + votes.anonymous[qId] + ' anonymous)</span>';
});
});