anubis/web/js/main.mjs
Xe Iaso 0dccf2e009
refactor(web): redo proof of work web worker logic (#941)
* chore(web/js): delete proof-of-work-slow.mjs

This code has served its purpose and now needs to be retired to the
great beyond. There is no replacement for this, the fast implementation
will be used instead.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(web): handle building multiple JS entrypoints and web workers

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(web): rewrite frontend worker handling

This completely rewrites how the proof of work challenge works based on
feedback from browser engine developers and starts the process of making
the proof of work function easier to change out.

- Import @aws-crypto/sha256-js to use in Firefox as its implementation
  of WebCrypto doesn't jump directly from highly optimized browser
  internals to JIT-ed JavaScript like Chrome's seems to.
- Move the worker code to `web/js/worker/*` with each worker named after
  the hashing method and hash method implementation it uses.
- Update bench.mjs to import algorithms the new way.
- Delete video.mjs, it was part of a legacy experiment that I never had
  time to finish.
- Update LibreJS comment to add info about the use of
  @aws-crypto/sha256-js.
- Also update my email to my @techaro.lol address.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(web): don't hard dep webcrypto anymore

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(lib/policy): start the deprecation process for slow

This mostly adds a warning, but the "slow" method is in the process of
being removed. Warn admins with slog.Warn.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: update CHANGELOG

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(web/js): allow running Anubis in non-secure contexts

Signed-off-by: Xe Iaso <me@xeiaso.net>

* Update metadata

check-spelling run (pull_request) for Xe/purge-slow

Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
2025-08-02 11:27:26 -04:00

240 lines
7.3 KiB
JavaScript

import algorithms from "./algorithms/index.mjs";
// from Xeact
const u = (url = "", params = {}) => {
let result = new URL(url, window.location.href);
Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v));
return result.toString();
};
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 = JSON.parse(
document.getElementById("anubis_base_prefix").textContent,
);
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 = JSON.parse(
document.getElementById("anubis_base_prefix").textContent,
);
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;
}
};
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 = document.getElementById("status");
const image = document.getElementById("image");
const title = document.getElementById("title");
const progress = document.getElementById("progress");
const anubisVersion = JSON.parse(
document.getElementById("anubis_version").textContent,
);
const basePrefix = JSON.parse(
document.getElementById("anubis_base_prefix").textContent,
);
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 } = JSON.parse(
document.getElementById("anubis_challenge").textContent,
);
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.report_as}, `;
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.report_as);
try {
const t0 = Date.now();
const { hash, nonce } = await process(
{ basePrefix, version: anubisVersion },
challenge,
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;
progress.firstElementChild.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 = document.getElementById("progress");
// 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 = window.location.href;
window.location.replace(
u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
response: hash,
nonce,
redir,
elapsedTime: t1 - t0,
}),
);
}
container.onclick = onDetailsExpand;
setTimeout(onDetailsExpand, 30000);
} else {
const redir = window.location.href;
window.location.replace(
u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {
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),
});
}
})();