Files
timebank-cc-public/app/Http/Controllers/PollVerificationController.php
T

168 lines
6.4 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Poll;
use App\Models\PollLedgerEntry;
use App\Models\User;
use App\Polls\Ledger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
/**
* Verification page of a poll and its ledger as JSON.
*
* The page is for the profiles the poll is meant for (and poll admins), once its post is
* published: chain state, seal and the receipt check. Level "public" adds the anonymous
* ballot list, the recount and the ledger download, but only for viewers who may see the
* results under the poll's results setting: the ballots are the results.
*/
class PollVerificationController extends Controller
{
public function show(Poll $poll, Ledger $ledger)
{
if (!$this->mayView($poll)) {
return view('posts.not_found');
}
$public = $this->maySeeBallots($poll);
$entries = PollLedgerEntry::where('poll_id', $poll->id)->get(['sequence', 'entry_type', 'refers_to_sequence']);
$counts = [
'cast' => $entries->where('entry_type', PollLedgerEntry::CAST)->count(),
'change' => $entries->where('entry_type', PollLedgerEntry::CHANGE)->count(),
'withdraw' => $entries->where('entry_type', PollLedgerEntry::WITHDRAW)->count(),
'active' => $ledger->activeBallots($poll)->count(),
];
return view('polls.verify', [
'poll' => $poll,
'title' => $this->title($poll),
'slug' => $this->slug($poll),
'chain' => $ledger->verifyChain($poll),
'counts' => $counts,
'public' => $public,
'ballotsHidden' => !$public && $poll->verification === Poll::VERIFICATION_PUBLIC,
'recount' => $public ? $this->recount($poll, $ledger) : null,
'ballots' => $public ? PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->paginate(50) : null,
'replacedBy' => $entries->whereNotNull('refers_to_sequence')->pluck('entry_type', 'refers_to_sequence'),
'questions' => $poll->questions()->get()->keyBy('id'),
]);
}
/** The public ledger, for verification in the browser or with any other tool. */
public function ledger(Poll $poll, Ledger $ledger)
{
if (!$this->mayView($poll) || !$this->maySeeBallots($poll)) {
abort(404);
}
$entries = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->get()
->map(fn (PollLedgerEntry $entry) => $ledger->publicEntry($entry));
return response()->json([
'poll_id' => $poll->id,
'format' => 'hash = SHA-256 of canonical JSON of {v, poll_id, sequence, entry_type, fingerprint, refers_to_sequence, payload, recorded_at, previous_hash}; keys sorted by UTF-8 bytes; first previous_hash is 64 zeros. Verifier: resources/js/poll-ledger-verifier.js',
'sealed_at' => $poll->sealed_at?->toIso8601String(),
'seal_hash' => $poll->seal_hash,
'published_tally' => $ledger->publishedTally($poll),
'entries' => $entries,
]);
}
/**
* The verification page: verification on, not a draft, and the viewer is a poll admin or
* a profile the poll is meant for, with the poll's post published. Anyone else gets the
* same "not found" as for a poll that does not exist.
*/
private function mayView(Poll $poll): bool
{
if ($poll->verification === Poll::VERIFICATION_OFF || $poll->status === Poll::STATUS_DRAFT) {
return false;
}
if ($this->isPollAdmin()) {
return true;
}
$published = $poll->post?->translations()
->whereDate('from', '<=', now())
->where(fn ($q) => $q->whereDate('till', '>', now())->orWhereNull('till'))
->exists();
[$profile, $user] = $this->viewer();
return $published && $profile && $user
&& $poll->matchesProfile($profile) && $poll->memberMayVote($profile, $user);
}
/**
* The ballot list, recount and ledger JSON reveal the results, so they follow the poll's
* results setting (after voting / after closing / never), except for poll admins.
*/
private function maySeeBallots(Poll $poll): bool
{
if ($poll->verification !== Poll::VERIFICATION_PUBLIC) {
return false;
}
if ($this->isPollAdmin()) {
return true;
}
[$profile, $user] = $this->viewer();
return $profile && $user && $poll->resultsVisibleToVoter($poll->responseFor($profile, $user->id) !== null);
}
/** Same access as the Polls page and the results export. */
private function isPollAdmin(): bool
{
return Poll::viewerIsPollAdmin();
}
/** @return array{0: ?\Illuminate\Database\Eloquent\Model, 1: ?User} */
private function viewer(): array
{
$user = Auth::guard('web')->user();
return [getActiveProfile(), $user instanceof User ? $user : null];
}
/**
* Per question: does the recount of the ledger match the published result?
*
* @return array{matches: bool, questions: array<int, bool>}
*/
private function recount(Poll $poll, Ledger $ledger): array
{
$ledgerTally = collect($ledger->ledgerTally($poll))->groupBy('question_id');
$publishedTally = collect($ledger->publishedTally($poll))->groupBy('question_id');
$questions = [];
foreach ($poll->questions()->pluck('id') as $questionId) {
$questions[$questionId] = $this->rows($ledgerTally->get($questionId)) === $this->rows($publishedTally->get($questionId));
}
return ['matches' => !in_array(false, $questions, true) && $ledgerTally->keys()->diff($publishedTally->keys())->isEmpty(), 'questions' => $questions];
}
private function rows(?Collection $rows): array
{
return $rows ? $rows->values()->all() : [];
}
private function title(Poll $poll): string
{
$translations = $poll->post?->translations ?? collect();
return ($translations->firstWhere('locale', App::getLocale()) ?? $translations->first())?->title ?? '';
}
private function slug(Poll $poll): ?string
{
$translations = $poll->post?->translations ?? collect();
return ($translations->firstWhere('locale', App::getLocale()) ?? $translations->first())?->slug;
}
}