209 lines
8.0 KiB
Cheetah
209 lines
8.0 KiB
Cheetah
{{template "base" .}}
|
||
|
||
{{define "title"}}{{.Issue.Title}}{{end}}
|
||
|
||
{{define "body"}}
|
||
<div style="margin-bottom:16px;">
|
||
<a href="/issues" class="link">← 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 hasPermission "issues:write"}}
|
||
<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}}
|