Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@node-core/caritat": "workspace:^",
"@sveltejs/vite-plugin-svelte": "^2.0.1",
"@tsconfig/svelte": "^4.0.0",
"@types/js-yaml": "^4",
"gh-pages": "^4.0.0",
"js-yaml": "^4.1.0",
"svelte": "^4.0.0",
"svelte-check": "^3.0.0",
"svelte-preprocess": "^5.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/web-ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import GitHubCredentials from "./GitHubCredentials.svelte";
import FindPrForm from "./FindPRForm.svelte";

let encryptDataPromise = new Promise(() => {}) as Promise<never>;
let encryptDataPromise = new Promise<never>(() => {});

let url = globalThis.location?.hash.slice(1);

Expand Down
117 changes: 103 additions & 14 deletions packages/web-ui/src/FillBallotForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,57 @@
import uint8ArrayToBase64 from "./uint8ArrayToBase64.ts";
import fetchFromGitHub from "./fetchDataFromGitHub.ts";

import type { VoteFileFormat } from "@node-core/caritat/parser";
import { templateBallot } from "@node-core/caritat/parser";
import {
getSummarizedBallot,
summarizeCondorcetBallotForVoter,
} from "@node-core/caritat/summary/condorcet";

export let url, username, token, registerEncryptedBallot;

let fetchedBallot: Promise<string>, fetchedPublicKey;
let fetchedVoteConfig: Promise<VoteFileFormat>;

const textEncoder =
typeof TextEncoder === "undefined" ? { encode() {} } : new TextEncoder();

function onClick(this: HTMLImageElement, event: MouseEvent) {
(
this.parentElement.nextElementSibling.lastElementChild as HTMLInputElement
).focus();
}
function onSubmit(this: HTMLFormElement, event: SubmitEvent) {
event.preventDefault();
const textarea = this.elements.namedItem("ballot") as HTMLInputElement;
registerEncryptedBallot(
(async () => {
const voteConfig = await fetchedVoteConfig;
const preferences = new Map(
voteConfig.candidates.map((candidate) => [
candidate,
Number(
(this.elements.namedItem(candidate) as HTMLInputElement).value
),
])
);
const ballot = templateBallot(voteConfig, undefined, preferences);
const summary = summarizeCondorcetBallotForVoter(
getSummarizedBallot({
voter: {},
preferences: new Map(
voteConfig.imageCandidates.map((c) =>
typeof c === "string"
? [c, preferences.get(c)]
: [c.alt, preferences.get(c.raw)]
)
),
})
);

if (!confirm(summary)) throw new Error("Aborted by user");

const { encryptedSecret, saltedCiphertext } = await encryptData(
textEncoder.encode(textarea.value) as Uint8Array,
await fetchedPublicKey
textEncoder.encode(ballot) as Uint8Array,
voteConfig.publicKey
);
return JSON.stringify({
encryptedSecret: uint8ArrayToBase64(new Uint8Array(encryptedSecret)),
Expand All @@ -29,27 +65,49 @@
);
}

fetchedBallot = fetchedPublicKey = Promise.reject("no data");
fetchedVoteConfig = Promise.reject("no data");
beforeUpdate(() => {
fetchFromGitHub({ url, username, token }, (errOfResult) => {
[fetchedBallot, fetchedPublicKey] = errOfResult;
fetchedVoteConfig = errOfResult;
});
});
</script>

<summary>Fill in ballot</summary>

{#await fetchedBallot}
{#await fetchedVoteConfig}
<p>...loading as {username || "anonymous"}</p>
{:then ballotPlainText}
{:then voteConfig}
<p class="instructions">
{voteConfig.headerInstructions}
</p>
<form on:submit={onSubmit}>
<textarea name="ballot">{ballotPlainText}</textarea>
{#await fetchedPublicKey}
<button type="submit" disabled>Loading public key…</button>
{:then}
<button type="submit">Encrypt ballot</button>
{/await}
<ul>
{#each voteConfig.imageCandidates ?? voteConfig.candidates ?? [] as candidate}
<li>
{#if typeof candidate === "string"}
{candidate}
{:else}
<figure>
<img src={candidate.src} alt={candidate.alt} on:click={onClick} />
<figcaption>{candidate.alt}</figcaption>
</figure>
{/if}
<label
>Score: <input
type="number"
value="0"
name={typeof candidate === "string" ? candidate : candidate.raw}
/></label
>
</li>
{/each}
</ul>
<button type="submit">Generate encrypted ballot</button>
</form>
<p class="instructions">
{voteConfig.footerInstructions}
</p>
{:catch error}
<p>
An error occurred: {error?.message ?? error}
Expand All @@ -62,3 +120,34 @@
</p>
{/if}
{/await}

<style>
ul {
display: flex;
list-style: none;
flex-wrap: wrap;
gap: 1rem;
padding: 0;
margin: 0 0 1rem;
justify-content: center;
}

figcaption {
text-align: center;
}

label {
margin-top: auto;
}

.instructions {
white-space: pre-line;
}

ul > li,
figure {
max-width: 240px;
display: flex;
flex-direction: column;
}
</style>
112 changes: 29 additions & 83 deletions packages/web-ui/src/fetchDataFromGitHub.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as yaml from "js-yaml";
import type { VoteFileFormat } from "@node-core/caritat/parser";
import base64ArrayBuffer from "./uint8ArrayToBase64";

const githubPRUrlPattern = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/;
const startCandidateList = /\npreferences:[^\n\S]*(#[^\n]*)?\n/;

const fetch2JSON = (response: Awaited<ReturnType<typeof fetch>>) =>
response.ok
Expand Down Expand Up @@ -184,97 +187,40 @@ async function act(
},
};
try {
const { voteFile, ballotFile, publicKeyFile } = await fetchVoteFilesInfo(
url as string,
fetchOptions
);

// This won't catch all the cases (if the PR modifies an existing file
// rather than creating a new one, or if the YAML is formatted differently),
// but it saves us from doing another request so deemed worth it.
const shouldShuffleCandidates = /^\+canShuffleCandidates:\s*true$/m.test(
voteFile.patch
const { voteFile } = await fetchVoteFilesInfo(url as string, fetchOptions);

const response = await fetch(voteFile.contents_url, contentsFetchOptions);
if (!response.ok)
throw new Error(`Fetch error: ${response.status} ${response.statusText}`);
const ballotData = await response.arrayBuffer();
const hash = await crypto.subtle.digest("SHA-512", ballotData);
const decoder = new TextDecoder();
const voteData = yaml.load(decoder.decode(ballotData)) as VoteFileFormat;
voteData.checksum = base64ArrayBuffer(new Uint8Array(hash));
voteData.imageCandidates = shuffle(
voteData.candidates.map((raw) => {
const imageMatch = /^!\[([^\]]+)\]\(([^)]+)\)$/.exec(raw);
if (imageMatch != null) {
return {
raw,
alt: imageMatch[1],
src: imageMatch[2],
};
}
return raw;
})
);

return [
fetch(ballotFile.contents_url, contentsFetchOptions)
.then((response) =>
response.ok
? response.text()
: Promise.reject(
new Error(
`Fetch error: ${response.status} ${response.statusText}`
)
)
)
.then(
shouldShuffleCandidates
? (ballotData) => {
const match = startCandidateList.exec(ballotData);
if (match == null) {
console.warn(
"Cannot find the list of candidates to shuffle, ignoring..."
);
return ballotData;
}
const headerEnd = match.index + match[0].length - 1;

const candidates = [];
let currentCandidate: string;
let lineStart;
let lineEnd = headerEnd;
for (;;) {
lineStart = lineEnd + 1;
lineEnd = ballotData.indexOf("\n", lineStart);
if (lineEnd === -1) {
if (lineStart !== ballotData.length) {
currentCandidate += ballotData.slice(lineStart - 1);
}
break;
}
if (
ballotData[lineStart] !== " " ||
ballotData[lineStart + 1] !== " "
)
break;
if (ballotData[lineStart + 2] === "-") {
if (currentCandidate) candidates.push(currentCandidate);
currentCandidate = "";
}
currentCandidate += ballotData.slice(lineStart - 1, lineEnd);
}
if (currentCandidate) candidates.push(currentCandidate);
return (
ballotData.slice(0, headerEnd) +
shuffle(candidates).join("") +
"\n" +
ballotData.slice(lineStart)
);
}
: undefined
),
fetch(publicKeyFile.contents_url, contentsFetchOptions).then((response) =>
response.ok
? response.arrayBuffer()
: Promise.reject(
new Error(
`Fetch error: ${response.status} ${response.statusText}`
)
)
),
] as [Promise<string>, Promise<ArrayBuffer>];
return voteData;
} catch (err) {
const error = Promise.reject(err);
return [error, error] as [never, never];
return error as never;
}
}

let previousURL: string | null;
export default function fetchFromGitHub(
{ url, username, token }: { url: string; username?: string; token?: string },
callback: (
errOfResult: [Promise<string>, Promise<ArrayBuffer>]
) => void | Promise<void>
callback: (errOfResult: Promise<VoteFileFormat>) => void | Promise<void>
) {
const options =
username && token
Expand Down
12 changes: 11 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ __metadata:
languageName: unknown
linkType: soft

"@node-core/caritat@workspace:*, @node-core/caritat@workspace:packages/core":
"@node-core/caritat@workspace:*, @node-core/caritat@workspace:^, @node-core/caritat@workspace:packages/core":
version: 0.0.0-use.local
resolution: "@node-core/caritat@workspace:packages/core"
dependencies:
Expand Down Expand Up @@ -454,6 +454,13 @@ __metadata:
languageName: node
linkType: hard

"@types/js-yaml@npm:^4":
version: 4.0.9
resolution: "@types/js-yaml@npm:4.0.9"
checksum: 139f705e10a7c50459eeee31d18eec685f8df689a6686eff392466d4accb72ec1ecb8da2d71349945de8663cd0f22f715420b5ea19774190e1feb595827f8439
languageName: node
linkType: hard

"@types/js-yaml@npm:^4.0.5":
version: 4.0.5
resolution: "@types/js-yaml@npm:4.0.5"
Expand Down Expand Up @@ -3311,9 +3318,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "web-ui@workspace:packages/web-ui"
dependencies:
"@node-core/caritat": "workspace:^"
"@sveltejs/vite-plugin-svelte": "npm:^2.0.1"
"@tsconfig/svelte": "npm:^4.0.0"
"@types/js-yaml": "npm:^4"
gh-pages: "npm:^4.0.0"
js-yaml: "npm:^4.1.0"
svelte: "npm:^4.0.0"
svelte-check: "npm:^3.0.0"
svelte-preprocess: "npm:^5.0.0"
Expand Down