party/web/html/issue.page.tmpl

209 lines
8.0 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{template "base" .}}
{{define "title"}}{{.Issue.Title}}{{end}}
{{define "body"}}
<div style="margin-bottom:16px;">
<a href="/issues" class="link">&larr; Zurück</a>
</div>
<div class="card" style="margin-bottom:24px;">
<div class="card-title" style="font-size:22px;">{{.Issue.Title}}</div>
<div class="card-text" style="margin-top:8px;">{{.Issue.Description}}</div>
<div style="font-size:13px; color:var(--text-muted); margin-top:8px;">
{{.Issue.StartTime.Format "02.01.2006"}} {{.Issue.EndTime.Format "02.01.2006"}}
</div>
{{if .CanWriteIssues}}
<div style="display:flex; gap:8px; margin-top:16px; flex-wrap:wrap;">
<button class="btn btn--danger"
hx-delete="/issues/{{.Issue.ID}}"
hx-confirm="Abstimmung wirklich löschen?">Löschen</button>
</div>
<details style="margin-top:16px; border-top:1px solid var(--border); padding-top:16px;">
<summary style="cursor:pointer; font-weight:600; list-style:none;">Bearbeiten</summary>
<form hx-patch="/issues/{{.Issue.ID}}" style="margin-top:12px;">
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-input" name="title" type="text" value="{{.Issue.Title}}" required>
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-input" name="description" rows="3">{{.Issue.Description}}</textarea>
</div>
<div class="form-group">
<label class="form-label">Beginn</label>
<div style="display:flex; gap:8px;">
<input class="form-input" name="start_date" type="date"
value="{{.Issue.StartTime.Format "2006-01-02"}}" style="flex:1; min-width:0;">
{{template "time-input" (dict "name" "start_clock" "value" (.Issue.StartTime.Format "15:04"))}}
</div>
</div>
<div class="form-group">
<label class="form-label">Ende</label>
<div style="display:flex; gap:8px;">
<input class="form-input" name="end_date" type="date"
value="{{.Issue.EndTime.Format "2006-01-02"}}" style="flex:1; min-width:0;">
{{template "time-input" (dict "name" "end_clock" "value" (.Issue.EndTime.Format "15:04"))}}
</div>
</div>
<button class="btn btn--primary" type="submit">Speichern</button>
</form>
</details>
{{end}}
</div>
<h2 style="margin-bottom:16px;">Optionen</h2>
{{if .Issue.CanVote}}
<div class="card" style="margin-bottom:24px;">
<p style="margin-bottom:12px; font-weight:600;">Ihre Stimme abgeben</p>
{{range .Issue.Options}}
<label style="display:flex; align-items:center; gap:8px; margin-bottom:8px; cursor:pointer;">
<input type="radio" name="vote-option" value="{{.ID}}">
<span>{{.Label}}</span>
</label>
{{end}}
<div id="vote-status" style="font-size:13px; margin-top:8px;"></div>
<button class="btn btn--primary" style="margin-top:12px;" onclick="castVote()">Abstimmen</button>
</div>
{{end}}
<div class="card-grid">
{{range .Issue.Options}}
<div class="card">
<div class="card-title">{{.Label}}</div>
<div style="font-size:28px; font-weight:700; margin-top:8px;">{{.VoteCount}}</div>
<div style="font-size:13px; color:var(--text-muted);">Stimmen</div>
</div>
{{end}}
</div>
<script>
const issueID = {{.Issue.ID}};
// ── Blind-signature voting ──────────────────────────────────────────────────
function modPow(base, exp, mod) {
if (mod === 1n) return 0n;
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) result = (result * base) % mod;
exp >>= 1n;
base = (base * base) % mod;
}
return result;
}
function modInverse(a, m) {
let [old_r, r] = [a, m];
let [old_s, s] = [1n, 0n];
while (r !== 0n) {
const q = old_r / r;
[old_r, r] = [r, old_r - q * r];
[old_s, s] = [s, old_s - q * s];
}
return ((old_s % m) + m) % m;
}
function bigIntToBase64(n) {
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++)
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return btoa(String.fromCharCode(...bytes));
}
function base64ToBigInt(b64) {
const bin = atob(b64);
let hex = "";
for (let i = 0; i < bin.length; i++)
hex += bin.charCodeAt(i).toString(16).padStart(2, "0");
return BigInt("0x" + hex);
}
function bytesToBase64(bytes) {
return btoa(String.fromCharCode(...bytes));
}
async function castVote() {
const selected = document.querySelector('input[name="vote-option"]:checked');
if (!selected) { alert("Bitte eine Option auswählen."); return; }
const optionID = parseInt(selected.value);
const status = document.getElementById("vote-status");
status.textContent = "Wird verarbeitet…";
try {
// 1. Get public key
const pkRes = await fetch(`/issues/${issueID}/pubkey`);
if (!pkRes.ok) throw new Error("Öffentlicher Schlüssel nicht abrufbar.");
const { public_key } = await pkRes.json();
const N = BigInt("0x" + public_key.n);
const e = BigInt(public_key.e);
// 2. Generate nonce and compute vote message: SHA-256(issueID||optionID||nonce)
const nonce = crypto.getRandomValues(new Uint8Array(32));
const msgBuf = new Uint8Array(16 + nonce.length);
const view = new DataView(msgBuf.buffer);
view.setBigUint64(0, BigInt(issueID), false);
view.setBigUint64(8, BigInt(optionID), false);
msgBuf.set(nonce, 16);
const hashBuf = await crypto.subtle.digest("SHA-256", msgBuf);
const hashHex = Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, "0")).join("");
const m = BigInt("0x" + hashHex);
// 3. Pick random blinding factor r in [2, N-1]
let r;
do {
const rb = crypto.getRandomValues(new Uint8Array(256));
r = BigInt("0x" + Array.from(rb).map(b => b.toString(16).padStart(2, "0")).join(""));
} while (r < 2n || r >= N);
// 4. Blind: m' = m * r^e mod N
const blinded = (m * modPow(r, e, N)) % N;
// 5. Request blind signature
const bsRes = await fetch(`/issues/${issueID}/blind-sign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blinded_vote: Array.from(atob(bigIntToBase64(blinded))).map(c => c.charCodeAt(0)) }),
});
if (!bsRes.ok) {
const err = await bsRes.json();
throw new Error(err.error?.message ?? "Blind-Sign fehlgeschlagen.");
}
const { signed } = await bsRes.json();
const sPrime = base64ToBigInt(signed);
// 6. Unblind: s = s' * r^(-1) mod N
const signature = (sPrime * modInverse(r, N)) % N;
// 7. Cast vote
const voteRes = await fetch("/votes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
issue_id: issueID,
option_id: optionID,
nonce: bytesToBase64(nonce),
signature: bigIntToBase64(signature),
}),
});
if (!voteRes.ok) {
const err = await voteRes.json();
throw new Error(err.error?.message ?? "Abstimmung fehlgeschlagen.");
}
status.style.color = "green";
status.textContent = "Stimme erfolgreich abgegeben!";
setTimeout(() => window.location.reload(), 1500);
} catch (err) {
status.style.color = "red";
status.textContent = err.message;
}
}
</script>
{{end}}