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} */ 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; } }