Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,167 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user