The simplest answer here is to have a neutral party (a server script) monitor the list of voters and increment the counter. Then one only need make sure users add themselves by their uid and can only do so once.
I'm sure there are also some brilliant ways to do this entirely with security rules. I'm, unfortunately, not that brilliant, but here's a brute force approach you can improve on if you really want the pain of a client-only solution.
The plan:
- force the user to write an audit record first
- force them to add their name to the list of voters second
- allow them to update the counter when both of these records exist and match the vote number
The schema:
/audit/<$x>/<$user_id>
/voters/$user_id/<$x>
/total/<$x>
We prevent user from modifying audit/ if they have already voted (voters/$user_id exists), or if the audit record already exists (someone has already claimed that count), or if the vote is not incremented by exactly one:
"audit": {
"$x": {
".write": "newData.exists() && !data.exists()", // no delete, no overwrite
".validate": "!root.child('voters/'+auth.uid).exists() && $x === root.child('total')+1"
}
}
You would update audit
in a transaction, essentially trying to "claim" each increment until successful and cancelling the transaction (by returning undefined) any time the record to be added is not null (someone has already claimed it). This gives you your unique vote number.
To prevent any funny business, we store a list of voters, which forces each voter to only write into audit/ once. I can only write to voters if I've never voted before and only if an audit record has already been created with my unique vote number:
"voters": {
"$user_id": {
".write": "newData.exists() && !data.exists()", // no delete, no replace
".validate": "newData.isNumber() && root.child('audit/'+newData.val()).val() === $user_id"
}
}
Last, but not least, we update the counter to match our claimed vote id. It must match my vote number and it may only increase. This prevents a race condition where one user creates an audit record and voters record, but someone else has already increased the total before I finished my three steps.
"total": {
".write": "newData.exists()", // no delete
".validate": "newData.isNumber() && newData.val() === root.child('audit/'+auth.uid).val() && newData.val() > data.val()"
}
Updating the total, like adding the initial audit record, would be done in a transaction. If the current value of the total is greater than my assigned vote number, then I just cancel the transaction with undefined
because someone else has already voted after me and updated it higher. Not a problem.