feat(web): port PoW challenge page from vanilla JS to Preact

Replace the imperative DOM manipulation in web/js/main.ts with a
declarative Preact component (web/js/main.tsx). The project already
uses Preact for the timed-delay challenge (lib/challenge/preact/),
so this aligns the PoW challenge with the existing codebase direction.

Convert web/js/main.ts to a Preact TSX component. The worker
orchestration layer (web/js/algorithms/fast.ts) stays untouched --
it is already cleanly separated and works via a Promise API.

web/js/main.ts -> web/js/main.tsx:
  - Phase-based state machine (loading -> computing -> reading/error)
    replaces scattered imperative DOM updates.
  - Worker lifecycle managed in useEffect; progress callback drives
    state setters for speed and progress percentage.
  - Speed updates remain throttled to 1 second intervals.
  - i18n functions (initTranslations, t(), loadTranslations) kept as
    module-level state -- no need for React context in a single-
    component app.
  - The <details> section stays in the templ file as server-rendered
    HTML; the Preact component tracks its toggle state via useRef.
  - Uses esbuild automatic JSX transform (--jsx=automatic
    --jsx-import-source=preact) instead of classic pragmas.

web/build.sh:
  - Add js/**/*.tsx to the glob so esbuild bundles TSX files.
  - Pass --jsx=automatic --jsx-import-source=preact for .tsx files.

web/tsconfig.json (new):
  - IDE-only config (noEmit) so TypeScript understands Preact JSX
    types for editor diagnostics and autocompletion.

lib/challenge/proofofwork/proofofwork.templ:
  - Replace individual DOM elements (img#image, p#status,
    div#progress) with a <div id="app"> Preact mount point
    containing server-rendered fallback (pensive image + loading
    text).
  - Keep <details>, <noscript>, and <div id="testarea"> outside the
    Preact tree as server-rendered content.

lib/anubis.go:
  - Add challenge method to the "new challenge issued" log line.

docs/docs/CHANGELOG.md:
  - Add entry for the Preact rewrite.

  - web/js/algorithms/fast.ts -- untouched
  - web/js/algorithms/index.ts -- untouched
  - web/js/worker/sha256-*.ts -- untouched
  - Server-side Go code (proofofwork.go) -- untouched
  - JSON script data embedding -- untouched
  - Redirect URL construction -- same logic, same parameters
  - Progress bar CSS in web/index.templ -- untouched

Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 4.6 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso
2026-03-19 13:39:09 +00:00
parent dbd64e0f4f
commit b54f88939d
8 changed files with 391 additions and 306 deletions

View File

@@ -17,8 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed mixed tab/space indentation in Caddy documentation code block
- Improve error messages and fix broken REDIRECT_DOMAINS link in docs ([#1193](https://github.com/TecharoHQ/anubis/issues/1193))
- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
- Rewrite main proof of work challenge to use Preact instead of Vanilla.js ([#1149](https://github.com/TecharoHQ/anubis/issues/1149)).
<!-- This changes the project to: -->
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
## v1.25.0: Necron

View File

@@ -141,7 +141,7 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
return nil, err
}
lg.Info("new challenge issued", "challenge", id.String())
lg.Info("new challenge issued", "challenge", id.String(), "method", chall.Method)
return &chall, err
}

View File

@@ -7,13 +7,12 @@ import (
templ page(localizer *localization.SimpleLocalizer) {
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img style="display:none;" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version }/>
<p id="status">{ localizer.T("loading") }</p>
<script async type="module" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version }></script>
<div id="progress" role="progressbar" aria-labelledby="status">
<div class="bar-inner"></div>
<div id="app">
<img style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<p id="status">{ localizer.T("loading") }</p>
</div>
<script async type="module" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version }></script>
<details>
if anubis.UseSimplifiedExplanation {
<p>

View File

@@ -34,27 +34,27 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"centered-div\"><img id=\"image\" style=\"width:100%;max-width:256px;\" src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"centered-div\"><img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 165}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 174}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"> <img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><div id=\"app\"><img style=\"width:100%;max-width:256px;\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 155}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -67,26 +67,26 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("loading"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</p><script async type=\"module\" src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</p></div><script async type=\"module\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 15, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></script><div id=\"progress\" role=\"progressbar\" aria-labelledby=\"status\"><div class=\"bar-inner\"></div></div><details>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></script><details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -98,7 +98,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 19, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -116,7 +116,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 23, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -129,7 +129,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 26, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -142,7 +142,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 29, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -155,7 +155,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 32, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -173,7 +173,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("javascript_required"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 39, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 38, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {

View File

@@ -41,15 +41,22 @@ cp ../lib/localization/locales/*.json static/locales/
shopt -s nullglob globstar
for file in js/**/*.ts js/**/*.mjs; do
for file in js/**/*.ts js/**/*.tsx js/**/*.mjs; do
out="static/${file}"
if [[ "$file" == *.ts ]]; then
if [[ "$file" == *.tsx ]]; then
out="static/${file%.tsx}.mjs"
elif [[ "$file" == *.ts ]]; then
out="static/${file%.ts}.mjs"
fi
mkdir -p "$(dirname "$out")"
esbuild "$file" --sourcemap --bundle --minify --outfile="$out" --banner:js="$LICENSE"
JSX_FLAGS=""
if [[ "$file" == *.tsx ]]; then
JSX_FLAGS="--jsx=automatic --jsx-import-source=preact"
fi
esbuild "$file" --sourcemap --bundle --minify --outfile="$out" $JSX_FLAGS --banner:js="$LICENSE"
gzip -f -k -n "$out"
zstd -f -k --ultra -22 "$out"
brotli -fZk "$out"

View File

@@ -1,281 +0,0 @@
import algorithms from "./algorithms";
// from Xeact
const u = (url: string = "", params: Record<string, any> = {}) => {
let result = new URL(url, window.location.href);
Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v));
return result.toString();
};
const j = (id: string): any | null => {
const elem = document.getElementById(id);
if (elem === null) {
return null;
}
return JSON.parse(elem.textContent);
};
const imageURL = (mood, cacheBuster, basePrefix) =>
u(`${basePrefix}/.within.website/x/cmd/anubis/static/img/${mood}.webp`, {
cacheBuster,
});
// Detect available languages by loading the manifest
const getAvailableLanguages = async () => {
const basePrefix = j("anubis_base_prefix");
if (basePrefix === null) {
return;
}
try {
const response = await fetch(
`${basePrefix}/.within.website/x/cmd/anubis/static/locales/manifest.json`,
);
if (response.ok) {
const manifest = await response.json();
return manifest.supportedLanguages || ["en"];
}
} catch (error) {
console.warn(
"Failed to load language manifest, falling back to default languages",
);
}
// Fallback to default languages if manifest loading fails
return ["en"];
};
// Use the browser language from the HTML lang attribute which is set by the server settings or request headers
const getBrowserLanguage = async () => document.documentElement.lang;
// Load translations from JSON files
const loadTranslations = async (lang) => {
const basePrefix = j("anubis_base_prefix");
if (basePrefix === null) {
return;
}
try {
const response = await fetch(
`${basePrefix}/.within.website/x/cmd/anubis/static/locales/${lang}.json`,
);
return await response.json();
} catch (error) {
console.warn(
`Failed to load translations for ${lang}, falling back to English`,
);
if (lang !== "en") {
return await loadTranslations("en");
}
throw error;
}
};
const getRedirectUrl = () => {
const publicUrl = j("anubis_public_url");
if (publicUrl === null) {
return;
}
if (publicUrl && window.location.href.startsWith(publicUrl)) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get("redir");
}
return window.location.href;
};
let translations = {};
let currentLang;
// Initialize translations
const initTranslations = async () => {
currentLang = await getBrowserLanguage();
translations = await loadTranslations(currentLang);
};
const t = (key) => translations[`js_${key}`] || translations[key] || key;
(async () => {
// Initialize translations first
await initTranslations();
const dependencies = [
{
name: "Web Workers",
msg: t("web_workers_error"),
value: window.Worker,
},
{
name: "Cookies",
msg: t("cookies_error"),
value: navigator.cookieEnabled,
},
];
const status: HTMLParagraphElement = document.getElementById(
"status",
) as HTMLParagraphElement;
const image: HTMLImageElement = document.getElementById(
"image",
) as HTMLImageElement;
const title: HTMLHeadingElement = document.getElementById(
"title",
) as HTMLHeadingElement;
const progress: HTMLDivElement = document.getElementById(
"progress",
) as HTMLDivElement;
const anubisVersion = j("anubis_version");
const basePrefix = j("anubis_base_prefix");
const details = document.querySelector("details");
let userReadDetails = false;
if (details) {
details.addEventListener("toggle", () => {
if (details.open) {
userReadDetails = true;
}
});
}
const ohNoes = ({ titleMsg, statusMsg, imageSrc }) => {
title.innerHTML = titleMsg;
status.innerHTML = statusMsg;
image.src = imageSrc;
progress.style.display = "none";
};
status.innerHTML = t("calculating");
for (const { value, name, msg } of dependencies) {
if (!value) {
ohNoes({
titleMsg: `${t("missing_feature")} ${name}`,
statusMsg: msg,
imageSrc: imageURL("reject", anubisVersion, basePrefix),
});
return;
}
}
const { challenge, rules } = j("anubis_challenge");
const process = algorithms[rules.algorithm];
if (!process) {
ohNoes({
titleMsg: t("challenge_error"),
statusMsg: t("challenge_error_msg"),
imageSrc: imageURL("reject", anubisVersion, basePrefix),
});
return;
}
status.innerHTML = `${t("calculating_difficulty")} ${rules.difficulty}, `;
progress.style.display = "inline-block";
// the whole text, including "Speed:", as a single node, because some browsers
// (Firefox mobile) present screen readers with each node as a separate piece
// of text.
const rateText = document.createTextNode(`${t("speed")} 0kH/s`);
status.appendChild(rateText);
let lastSpeedUpdate = 0;
let showingApology = false;
const likelihood = Math.pow(16, -rules.difficulty);
try {
const t0 = Date.now();
const { hash, nonce } = await process(
{ basePrefix, version: anubisVersion },
challenge.randomData,
rules.difficulty,
null,
(iters) => {
const delta = Date.now() - t0;
// only update the speed every second so it's less visually distracting
if (delta - lastSpeedUpdate > 1000) {
lastSpeedUpdate = delta;
rateText.data = `${t("speed")} ${(iters / delta).toFixed(3)}kH/s`;
}
// the probability of still being on the page is (1 - likelihood) ^ iters.
// by definition, half of the time the progress bar only gets to half, so
// apply a polynomial ease-out function to move faster in the beginning
// and then slow down as things get increasingly unlikely. quadratic felt
// the best in testing, but this may need adjustment in the future.
const probability = Math.pow(1 - likelihood, iters);
const distance = (1 - Math.pow(probability, 2)) * 100;
progress["aria-valuenow"] = distance;
if (progress.firstElementChild !== null) {
(progress.firstElementChild as HTMLElement).style.width =
`${distance}%`;
}
if (probability < 0.1 && !showingApology) {
status.append(
document.createElement("br"),
document.createTextNode(t("verification_longer")),
);
showingApology = true;
}
},
);
const t1 = Date.now();
console.log({ hash, nonce });
if (userReadDetails) {
const container: HTMLDivElement = document.getElementById(
"progress",
) as HTMLDivElement;
// Style progress bar as a continue button
container.style.display = "flex";
container.style.alignItems = "center";
container.style.justifyContent = "center";
container.style.height = "2rem";
container.style.borderRadius = "1rem";
container.style.cursor = "pointer";
container.style.background = "#b16286";
container.style.color = "white";
container.style.fontWeight = "bold";
container.style.outline = "4px solid #b16286";
container.style.outlineOffset = "2px";
container.style.width = "min(20rem, 90%)";
container.style.margin = "1rem auto 2rem";
container.innerHTML = t("finished_reading");
function onDetailsExpand() {
const redir = getRedirectUrl();
window.location.replace(
u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
id: challenge.id,
response: hash,
nonce,
redir,
elapsedTime: t1 - t0,
}),
);
}
container.onclick = onDetailsExpand;
setTimeout(onDetailsExpand, 30000);
} else {
const redir = getRedirectUrl();
window.location.replace(
u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
id: challenge.id,
response: hash,
nonce,
redir,
elapsedTime: t1 - t0,
}),
);
}
} catch (err) {
ohNoes({
titleMsg: t("calculation_error"),
statusMsg: `${t("calculation_error_msg")} ${err.message}`,
imageSrc: imageURL("reject", anubisVersion, basePrefix),
});
}
})();

345
web/js/main.tsx Normal file
View File

@@ -0,0 +1,345 @@
import { render } from "preact";
import { useState, useEffect, useRef } from "preact/hooks";
import algorithms from "./algorithms";
// from Xeact
const u = (url: string = "", params: Record<string, any> = {}) => {
let result = new URL(url, window.location.href);
Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v));
return result.toString();
};
const j = (id: string): any | null => {
const elem = document.getElementById(id);
if (elem === null) {
return null;
}
return JSON.parse(elem.textContent);
};
const imageURL = (
mood: string,
cacheBuster: string,
basePrefix: string,
): string =>
u(`${basePrefix}/.within.website/x/cmd/anubis/static/img/${mood}.webp`, {
cacheBuster,
});
// Detect available languages by loading the manifest
const getAvailableLanguages = async () => {
const basePrefix = j("anubis_base_prefix");
if (basePrefix === null) {
return;
}
try {
const response = await fetch(
`${basePrefix}/.within.website/x/cmd/anubis/static/locales/manifest.json`,
);
if (response.ok) {
const manifest = await response.json();
return manifest.supportedLanguages || ["en"];
}
} catch (error) {
console.warn(
"Failed to load language manifest, falling back to default languages",
);
}
// Fallback to default languages if manifest loading fails
return ["en"];
};
// Use the browser language from the HTML lang attribute which is set by the server settings or request headers
const getBrowserLanguage = async () => document.documentElement.lang;
// Load translations from JSON files
const loadTranslations = async (lang: string) => {
const basePrefix = j("anubis_base_prefix");
if (basePrefix === null) {
return;
}
try {
const response = await fetch(
`${basePrefix}/.within.website/x/cmd/anubis/static/locales/${lang}.json`,
);
return await response.json();
} catch (error) {
console.warn(
`Failed to load translations for ${lang}, falling back to English`,
);
if (lang !== "en") {
return await loadTranslations("en");
}
throw error;
}
};
const getRedirectUrl = () => {
const publicUrl = j("anubis_public_url");
if (publicUrl === null) {
return;
}
if (publicUrl && window.location.href.startsWith(publicUrl)) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get("redir");
}
return window.location.href;
};
let translations: Record<string, string> = {};
let currentLang;
// Initialize translations
const initTranslations = async () => {
currentLang = await getBrowserLanguage();
translations = await loadTranslations(currentLang);
};
const t = (key: string): string =>
translations[`js_${key}`] || translations[key] || key;
interface AppProps {
anubisVersion: string;
basePrefix: string;
}
function App({ anubisVersion, basePrefix }: AppProps) {
const [phase, setPhase] = useState<
"loading" | "computing" | "reading" | "error"
>("loading");
// Error info
const [errorTitle, setErrorTitle] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const [errorImage, setErrorImage] = useState("");
// Computing info
const [difficulty, setDifficulty] = useState(0);
const [speed, setSpeed] = useState("0kH/s");
const [progress, setProgress] = useState(0);
const [showApology, setShowApology] = useState(false);
// Reading redirect callback
const redirectFn = useRef<(() => void) | null>(null);
const detailsRead = useRef(false);
// Sync <h1 id="title"> when entering error state (it's outside the Preact tree)
useEffect(() => {
if (phase === "error") {
const titleEl = document.getElementById("title");
if (titleEl) {
titleEl.textContent = errorTitle;
}
}
}, [phase, errorTitle]);
// Main initialization
useEffect(() => {
const details = document.querySelector("details");
if (details) {
details.addEventListener("toggle", () => {
if (details.open) {
detailsRead.current = true;
}
});
}
const showError = (title: string, message: string, imageSrc: string) => {
setErrorTitle(title);
setErrorMessage(message);
setErrorImage(imageSrc);
setPhase("error");
};
const dependencies = [
{
name: "Web Workers",
msg: t("web_workers_error"),
value: window.Worker,
},
{
name: "Cookies",
msg: t("cookies_error"),
value: navigator.cookieEnabled,
},
];
for (const { value, name, msg } of dependencies) {
if (!value) {
showError(
`${t("missing_feature")} ${name}`,
msg,
imageURL("reject", anubisVersion, basePrefix),
);
return;
}
}
const { challenge, rules } = j("anubis_challenge");
const process = algorithms[rules.algorithm];
if (!process) {
showError(
t("challenge_error"),
t("challenge_error_msg"),
imageURL("reject", anubisVersion, basePrefix),
);
return;
}
setPhase("computing");
setDifficulty(rules.difficulty);
const likelihood = Math.pow(16, -rules.difficulty);
let lastSpeedUpdate = 0;
let apologyShown = false;
const t0 = Date.now();
process(
{ basePrefix, version: anubisVersion },
challenge.randomData,
rules.difficulty,
null,
(iters: number) => {
const delta = Date.now() - t0;
// only update the speed every second so it's less visually distracting
if (delta - lastSpeedUpdate > 1000) {
lastSpeedUpdate = delta;
setSpeed(`${(iters / delta).toFixed(3)}kH/s`);
}
// the probability of still being on the page is (1 - likelihood) ^ iters.
// by definition, half of the time the progress bar only gets to half, so
// apply a polynomial ease-out function to move faster in the beginning
// and then slow down as things get increasingly unlikely. quadratic felt
// the best in testing, but this may need adjustment in the future.
const probability = Math.pow(1 - likelihood, iters);
const distance = (1 - Math.pow(probability, 2)) * 100;
setProgress(distance);
if (probability < 0.1 && !apologyShown) {
apologyShown = true;
setShowApology(true);
}
},
)
.then((result: any) => {
const t1 = Date.now();
const { hash, nonce } = result;
console.log({ hash, nonce });
const doRedirect = () => {
const redir = getRedirectUrl();
window.location.replace(
u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
id: challenge.id,
response: hash,
nonce,
redir,
elapsedTime: t1 - t0,
}),
);
};
if (detailsRead.current) {
redirectFn.current = doRedirect;
setPhase("reading");
setTimeout(doRedirect, 30000);
} else {
doRedirect();
}
})
.catch((err: Error) => {
showError(
t("calculation_error"),
`${t("calculation_error_msg")} ${err.message}`,
imageURL("reject", anubisVersion, basePrefix),
);
});
}, []);
const pensiveURL = imageURL("pensive", anubisVersion, basePrefix);
if (phase === "error") {
return (
<>
<img style="width:100%;max-width:256px;" src={errorImage} />
<p id="status">{errorMessage}</p>
</>
);
}
if (phase === "loading") {
return (
<>
<img style="width:100%;max-width:256px;" src={pensiveURL} />
<p id="status">{t("calculating")}</p>
</>
);
}
// computing or reading
return (
<>
<img style="width:100%;max-width:256px;" src={pensiveURL} />
<p id="status">
{`${t("calculating_difficulty")} ${difficulty}, `}
{`${t("speed")} ${speed}`}
{showApology && (
<>
<br />
{t("verification_longer")}
</>
)}
</p>
{phase === "reading" ? (
<div
id="progress"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "2rem",
borderRadius: "1rem",
cursor: "pointer",
background: "#b16286",
color: "white",
fontWeight: "bold",
outline: "4px solid #b16286",
outlineOffset: "2px",
width: "min(20rem, 90%)",
margin: "1rem auto 2rem",
}}
onClick={() => redirectFn.current?.()}
>
{t("finished_reading")}
</div>
) : (
<div
id="progress"
role="progressbar"
aria-labelledby="status"
aria-valuenow={progress}
style={{ display: "inline-block" }}
>
<div class="bar-inner" style={{ width: `${progress}%` }}></div>
</div>
)}
</>
);
}
// Bootstrap: init translations, then mount Preact
(async () => {
await initTranslations();
const anubisVersion = j("anubis_version");
const basePrefix = j("anubis_base_prefix");
const root = document.getElementById("app");
if (root) {
render(<App anubisVersion={anubisVersion} basePrefix={basePrefix} />, root);
}
})();

13
web/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"noEmit": true,
"skipLibCheck": true,
"strict": false
},
"include": ["js/**/*.ts", "js/**/*.tsx"]
}