Compare commits

..

2 Commits

Author SHA1 Message Date
Jason Cameron 766132209b Merge branch 'main' into json/fix-pow-deref
Signed-off-by: Jason Cameron <git@jasoncameron.dev>
2026-03-18 12:27:55 -04:00
Jason Cameron dd6b44df90 fix: nil ptr deref
Signed-off-by: Jason Cameron <jason.cameron@stanwith.me>
2026-02-18 14:18:46 -05:00
10 changed files with 306 additions and 441 deletions
-1
View File
@@ -14,7 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)
- Instruct reverse proxies to not cache error pages.
- Fixed mixed tab/space indentation in Caddy documentation code block
- 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: -->
-2
View File
@@ -48,8 +48,6 @@ This simply enables SSL offloading, sets some useful and required headers and ro
Due to the fact that HAProxy can decode JWT, we are able to verify the Anubis token directly in HAProxy and route the traffic to the specific backends ourselves.
Mind that rule logic to allow Git HTTP and other legit bot traffic to bypass is delegated from Anubis to HAProxy then. If required, you should implement any whitelisting in HAProxy using `acl_anubis_ignore` yourself.
In this example are three applications behind one HAProxy frontend. Only App1 and App2 are secured via Anubis; App3 is open for everyone. The path `/excluded/path` can also be accessed by anyone.
```mermaid
@@ -130,52 +130,3 @@ Then point your Ingress to the Anubis port:
# diff-add
name: anubis
```
## Envoy Gateway
If you are using envoy-gateway, the `X-Real-Ip` header is not set by default, but Anubis does require it. You can resolve this by adding the header, either on the specific `HTTPRoute` where Anubis is listening, or on the `ClientTrafficPolicy` to apply it to any number of Gateways:
HTTPRoute:
```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app-route
spec:
hostnames: ["app.domain.tld"]
parentRefs:
- name: envoy-external
namespace: network
sectionName: https
rules:
- backendRefs:
- identifier: *app
port: anubis
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set:
- name: X-Real-Ip
value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%"
```
Applying to any number of Gateways:
```yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
name: envoy
spec:
headers:
earlyRequestHeaders:
set:
- name: X-Real-Ip
value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%"
clientIPDetection:
xForwardedFor:
trustedCIDRs:
- 10.96.0.0/16 # Cluster pod CIDR
targetSelectors: # These will apply to all Gateways
- group: gateway.networking.k8s.io
kind: Gateway
```
+1 -1
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(), "method", chall.Method)
lg.Info("new challenge issued", "challenge", id.String())
return &chall, err
}
+5 -4
View File
@@ -7,12 +7,13 @@ 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 }/>
<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>
<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>
<details>
if anubis.UseSimplifiedExplanation {
<p>
+16 -16
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 style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"centered-div\"><img id=\"image\" 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/happy.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var2, 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: 10, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 165}
}
_, 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, "\"><div id=\"app\"><img style=\"width:100%;max-width:256px;\" src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"> <img style=\"display:none;\" 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/pensive.webp?cacheBuster=" + anubis.Version)
templ_7745c5c3_Var3, 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: 12, Col: 155}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 174}
}
_, 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: 13, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 41}
}
_, 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></div><script async type=\"module\" src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</p><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: 15, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, 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><details>")
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>")
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: 19, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, 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: 23, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, 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: 26, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, 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: 29, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, 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: 32, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, 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: 38, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 39, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
+3 -10
View File
@@ -41,22 +41,15 @@ cp ../lib/localization/locales/*.json static/locales/
shopt -s nullglob globstar
for file in js/**/*.ts js/**/*.tsx js/**/*.mjs; do
for file in js/**/*.ts js/**/*.mjs; do
out="static/${file}"
if [[ "$file" == *.tsx ]]; then
out="static/${file%.tsx}.mjs"
elif [[ "$file" == *.ts ]]; then
if [[ "$file" == *.ts ]]; then
out="static/${file%.ts}.mjs"
fi
mkdir -p "$(dirname "$out")"
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"
esbuild "$file" --sourcemap --bundle --minify --outfile="$out" --banner:js="$LICENSE"
gzip -f -k -n "$out"
zstd -f -k --ultra -22 "$out"
brotli -fZk "$out"
+281
View File
@@ -0,0 +1,281 @@
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
View File
@@ -1,345 +0,0 @@
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
View File
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"noEmit": true,
"skipLibCheck": true,
"strict": false
},
"include": ["js/**/*.ts", "js/**/*.tsx"]
}