mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-04-10 02:28:45 +00:00
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>
346 lines
9.3 KiB
TypeScript
346 lines
9.3 KiB
TypeScript
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);
|
|
}
|
|
})();
|