diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index b961722a..3cc83d5b 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -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)). + - 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 diff --git a/lib/anubis.go b/lib/anubis.go index 2ff5ec92..1e2f7f4e 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -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 } diff --git a/lib/challenge/proofofwork/proofofwork.templ b/lib/challenge/proofofwork/proofofwork.templ index d08085ab..94b841ff 100644 --- a/lib/challenge/proofofwork/proofofwork.templ +++ b/lib/challenge/proofofwork/proofofwork.templ @@ -7,13 +7,12 @@ import ( templ page(localizer *localization.SimpleLocalizer) {
- { localizer.T("loading") }

-
if anubis.UseSimplifiedExplanation {

diff --git a/lib/challenge/proofofwork/proofofwork_templ.go b/lib/challenge/proofofwork/proofofwork_templ.go index bbae45c0..48d2df2d 100644 --- a/lib/challenge/proofofwork/proofofwork_templ.go +++ b/lib/challenge/proofofwork/proofofwork_templ.go @@ -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, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">
") 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 { diff --git a/web/build.sh b/web/build.sh index d0818e0c..194236bb 100755 --- a/web/build.sh +++ b/web/build.sh @@ -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" diff --git a/web/js/main.ts b/web/js/main.ts deleted file mode 100644 index b07d52d9..00000000 --- a/web/js/main.ts +++ /dev/null @@ -1,281 +0,0 @@ -import algorithms from "./algorithms"; - -// from Xeact -const u = (url: string = "", params: Record = {}) => { - 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), - }); - } -})(); diff --git a/web/js/main.tsx b/web/js/main.tsx new file mode 100644 index 00000000..a9efd770 --- /dev/null +++ b/web/js/main.tsx @@ -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 = {}) => { + 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 = {}; +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

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 ( + <> + +

{errorMessage}

+ + ); + } + + if (phase === "loading") { + return ( + <> + +

{t("calculating")}

+ + ); + } + + // computing or reading + return ( + <> + +

+ {`${t("calculating_difficulty")} ${difficulty}, `} + {`${t("speed")} ${speed}`} + {showApology && ( + <> +
+ {t("verification_longer")} + + )} +

+ {phase === "reading" ? ( +
redirectFn.current?.()} + > + {t("finished_reading")} +
+ ) : ( +
+
+
+ )} + + ); +} + +// 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(, root); + } +})(); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 00000000..3cdd56be --- /dev/null +++ b/web/tsconfig.json @@ -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"] +}