This commit is contained in:
Felix Klement committed 2026-10-05 10:44:55 +02:00
1 parent 490444032c
commit efecc500c4
15 files changed
+349 -47

No files matched your search

+39 -11
View File
@@ -51,6 +51,7 @@ const f = ui.contact.form;
<button class="button" type="submit">{f.submit}</button>
<p class="small muted"><span aria-hidden="true">* </span>{f.required}</p>
</div>
<noscript><p class="field--full small">{f.noscript}</p></noscript>
</form>
</div>
@@ -227,8 +228,13 @@ const f = ui.contact.form;
</style>
<script>
// With scripting the form sends in place and answers in a toast. Without it, the server
// redirects to /contact/sent or /contact/error.
// The form sends in place and answers in a toast. Every send carries the answer to a
// proof-of-work challenge (ALTCHA), worked out in the background from the moment the visitor
// first touches the form. Without scripting the server refuses the post and redirects to
// /contact/error, which points to the email address.
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
const form = document.querySelector<HTMLFormElement>('[data-contact]');
const toast = document.querySelector<HTMLElement>('[data-toast]');
if (form && toast) {
@@ -238,6 +244,22 @@ const f = ui.contact.form;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
// One challenge per message: fetched and solved once, spent by the send that carries it.
let proof: Promise<string | null> | null = null;
const solve = async (): Promise<string | null> => {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
};
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
const show = (state: 'sending' | 'sent' | 'failed', text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
@@ -264,15 +286,21 @@ const f = ui.contact.form;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new URLSearchParams(new FormData(form) as unknown as Record<string, string>),
});
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
const altcha = await prepare();
proof = null;
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
});
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
}
if (outcome === 'sent') {
form.reset();