various improvements and fixes to PoW challenge UI and token refresh

This commit is contained in:
nai-degen 2024-09-22 11:11:30 -05:00
parent ff0d3dfdcd
commit ee26e7be65
8 changed files with 257 additions and 134 deletions

View File

@ -30,7 +30,6 @@ self.onmessage = async (event) => {
nonce = data.nonce;
const c = data.challenge;
// decode salt to Uint8Array
const salt = new Uint8Array(c.s.length / 2);
for (let i = 0; i < c.s.length; i += 2) {
salt[i / 2] = parseInt(c.s.slice(i, i + 2), 16);
@ -99,7 +98,7 @@ const solve = async () => {
self.postMessage({ type: "solved", nonce: solution.nonce });
active = false;
} else {
if (Date.now() - lastNotify > 1000) {
if (Date.now() - lastNotify >= 500) {
console.log("Last nonce", nonce, "Hashes", hashesSinceLastNotify);
self.postMessage({ type: "progress", hashes: hashesSinceLastNotify });
lastNotify = Date.now();

View File

@ -344,10 +344,11 @@ router.post("/maintenance", (req, res) => {
case "setDifficulty": {
const selected = req.body["pow-difficulty"];
const valid = ["low", "medium", "high", "extreme"];
if (!selected || !valid.includes(selected)) {
throw new HttpError(400, "Invalid difficulty" + selected);
const isNumber = Number.isInteger(Number(selected));
if (!selected || !valid.includes(selected) && !isNumber) {
throw new HttpError(400, "Invalid difficulty " + selected);
}
config.powDifficultyLevel = selected;
config.powDifficultyLevel = isNumber ? Number(selected) : selected;
invalidatePowChallenges();
break;
}

View File

@ -38,15 +38,20 @@
<h3>Difficulty Level</h3>
<div>
<label for="difficulty">Difficulty Level:</label>
<span id="currentDifficulty">Current: <%= difficulty %></span>
<select name="difficulty" id="difficulty">
<select name="difficulty" id="difficulty" onchange="difficultyChanged(event)">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="extreme">Extreme</option>
<option value="custom">Custom</option>
</select>
<div id="custom-difficulty-container" style="display: none">
<label for="customDifficulty">Hashes required (average):</label>
<input type="number" id="customDifficulty" value="0" min="1" max="1000000000" />
</div>
<button onclick='doAction("setDifficulty")'>Update Difficulty</button>
</div>
<div><span id="currentDifficulty">Current Difficulty: <%= difficulty %></span></div>
<% } %>
<form id="maintenanceForm" action="/admin/manage/maintenance" method="post">
<input id="_csrf" type="hidden" name="_csrf" value="<%= csrfToken %>" />
@ -63,15 +68,15 @@
<div>
<h2>IP Whitelists and Blacklists</h2>
<p>
You can specify IP ranges to whitelist or blacklist from accessing the proxy. Note that changes here are not
persisted across server restarts. If you want to make changes permanent, you can copy the values to your deployment
configuration.
</p>
<p>
Entries can be specified as single addresses or
You can specify IP ranges to whitelist or blacklist from accessing the proxy. Entries can be specified as single
addresses or
<a href="https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation">CIDR notation</a>. IPv6 is
supported but not recommended for use with the current version of the proxy.
</p>
<p>
<strong>Note:</strong> Changes here are not persisted across server restarts. If you want to make changes permanent,
you can copy the values to your deployment configuration.
</p>
<% for (let i = 0; i < whitelists.length; i++) { %>
<%- include("partials/admin-cidr-widget", { list: whitelists[i] }) %>
<% } %>
@ -99,10 +104,25 @@
</div>
<script>
function difficultyChanged(event) {
const value = event.target.value;
if (value === "custom") {
document.getElementById("custom-difficulty-container").style.display = "block";
} else {
document.getElementById("custom-difficulty-container").style.display = "none";
}
}
function doAction(action) {
document.getElementById("hiddenAction").value = action;
if (action === "setDifficulty") {
document.getElementById("hiddenDifficulty").value = document.getElementById("difficulty").value;
const selected = document.getElementById("difficulty").value;
const hiddenDifficulty = document.getElementById("hiddenDifficulty");
if (selected === "custom") {
hiddenDifficulty.value = document.getElementById("customDifficulty").value;
} else {
hiddenDifficulty.value = selected;
}
}
document.getElementById("maintenanceForm").submit();
}

View File

@ -171,7 +171,7 @@ function getSelfServiceLinks() {
}
return `<div class="self-service-links">${links
.map(([text, link]) => `<a target="_blank" href="${link}">${text}</a>`)
.map(([text, link]) => `<a href="${link}">${text}</a>`)
.join(" | ")}</div>`;
}

View File

@ -1,7 +1,14 @@
<p>
Next refresh: <time><%- nextQuotaRefresh %></time>
</p>
<table class="striped">
<%
const quotaTableId = Math.random().toString(36).slice(2);
%>
<div>
<label for="quota-family-filter-<%= quotaTableId %>">Filter:</label>
<input type="text" id="quota-family-filter-<%= quotaTableId %>" oninput="filterQuotaTable(this, '<%= quotaTableId %>')" />
</div>
<table class="striped" id="quota-table-<%= quotaTableId %>">
<thead>
<tr>
<th scope="col">Model Family</th>
@ -50,3 +57,18 @@
<% }) %>
</tbody>
</table>
<script>
function filterQuotaTable(input, tableId) {
const filter = input.value.toLowerCase();
const table = document.getElementById("quota-table-" + tableId);
const rows = table.querySelectorAll("tbody tr");
for (const row of rows) {
const modelFamily = row.querySelector("th").textContent;
if (modelFamily.toLowerCase().includes(filter)) {
row.style.display = "";
} else {
row.style.display = "none";
}
}
}
</script>

View File

@ -187,7 +187,7 @@ function verifyTokenRefreshable(token: string, req: express.Request) {
}
}
req.log.info({ token }, "Allowing token refresh");
req.log.info({ token: `...${token.slice(-5)}` }, "Allowing token refresh");
return true;
}
@ -227,51 +227,57 @@ router.post("/verify", async (req, res) => {
const ip = req.ip;
req.log.info("Got verification request");
if (recentAttempts.has(ip)) {
res
.status(429)
.json({ error: "Rate limited; wait a minute before trying again" });
const error = "Rate limited; wait a minute before trying again";
req.log.info({ error }, "Verification rejected");
res.status(429).json({ error });
return;
}
const result = verifySchema.safeParse(req.body);
if (!result.success) {
res
.status(400)
.json({ error: "Invalid verify request", details: result.error });
const error = "Invalid verify request";
req.log.info({ error, result }, "Verification rejected");
res.status(400).json({ error, details: result.error });
return;
}
const { challenge, signature, solution } = result.data;
if (signMessage(challenge, powKeySalt) !== signature) {
res.status(400).json({
error:
"Invalid signature; server may have restarted since challenge was issued. Please request a new challenge.",
});
const error =
"Invalid signature; server may have restarted since challenge was issued. Please request a new challenge.";
req.log.info({ error }, "Verification rejected");
res.status(400).json({ error });
return;
}
if (config.proxyKey && result.data.proxyKey !== config.proxyKey) {
res.status(401).json({ error: "Invalid proxy password" });
const error = "Invalid proxy password";
req.log.info({ error }, "Verification rejected");
res.status(401).json({ error, password: result.data.proxyKey });
return;
}
if (challenge.ip && challenge.ip !== ip) {
req.log.warn("Attempt to verify from different IP address");
res.status(400).json({
error: "Solution must be verified from original IP address",
});
const error = "Solution must be verified from original IP address";
req.log.info(
{ error, challengeIp: challenge.ip, clientIp: ip },
"Verification rejected"
);
res.status(400).json({ error });
return;
}
if (solves.has(signature)) {
req.log.warn("Attempt to reuse signature");
res.status(400).json({ error: "Reused signature" });
const error = "Reused signature";
req.log.info({ error }, "Verification rejected");
res.status(400).json({ error });
return;
}
if (Date.now() > challenge.e) {
req.log.warn("Verification took too long");
res.status(400).json({ error: "Verification took too long" });
const error = "Verification took too long";
req.log.info({ error }, "Verification rejected");
res.status(400).json({ error });
return;
}
@ -285,7 +291,7 @@ router.post("/verify", async (req, res) => {
const success = await verifySolution(challenge, solution, req.log);
if (!success) {
recentAttempts.set(ip, Date.now() + 1000 * 60 * 60 * 6);
req.log.warn("Solution failed verification");
req.log.warn("Bogus solution, client blocked");
res.status(400).json({ error: "Solution failed verification" });
return;
}
@ -299,10 +305,12 @@ router.post("/verify", async (req, res) => {
if (challenge.token) {
const user = getUser(challenge.token);
if (user) {
user.expiresAt = Date.now() + config.powTokenHours * 60 * 60 * 1000;
user.disabledAt = undefined;
user.disabledReason = undefined;
upsertUser(user);
upsertUser({
token: challenge.token,
expiresAt: Date.now() + config.powTokenHours * 60 * 60 * 1000,
disabledAt: null,
disabledReason: null,
});
req.log.info(
{ token: `...${challenge.token.slice(-5)}` },
"Token refreshed"

View File

@ -5,8 +5,8 @@
</noscript>
<style>
#captcha-container {
max-width: 500px;
margin: 50px auto;
max-width: 550px;
margin: 20px auto;
}
@media (max-width: 1000px) {
#captcha-container {
@ -42,7 +42,7 @@
#captcha-progress-text {
width: 100%;
height: 18rem;
height: 20rem;
resize: vertical;
font-family: monospace;
}
@ -70,13 +70,22 @@
height: 100%;
background-color: #76c7c0;
}
#copy-token {
border: none;
background: none;
filter: saturate(0);
padding: 0;
}
#copy-token:hover {
filter: saturate(1);
}
</style>
<div style="display: none" id="captcha-container">
<p>
Your device needs to perform a verification task before you can receive a token. This might take anywhere from a few
seconds to a few minutes, depending on your device and the proxy's security settings.
Your device needs to be verified before you can receive a token. This might take anywhere from a few seconds to a
few minutes, depending on your device and the proxy's security settings.
</p>
<p>Click the button below to start.</p>
<details>
<summary>What is this?</summary>
<p>
@ -107,18 +116,6 @@
faster than the first one.
</p>
</details>
<details>
<summary>What is the "Workers" setting?</summary>
<p>
This controls how many CPU cores will be used to solve the verification task. If your device gets too hot or slows
down too much during verification, reduce the number of workers.
</p>
<p>
For fastest verification, set this to the number of physical CPU cores in your device. Setting more workers than
you have actual cores will generally only slow down verification.
</p>
<p>If you don't understand what this means, leave it at the default setting.</p>
</details>
<details>
<summary>Other important information</summary>
<ul>
@ -134,15 +131,27 @@
</li>
</ul>
</details>
<details>
<summary>Settings</summary>
<div>
<label for="workers">Workers:</label>
<input type="number" id="workers" value="1" min="1" max="32" onchange="spawnWorkers()" />
</div>
<p>
This controls how many CPU cores will be used to solve the verification task. If your device gets too hot or slows
down too much during verification, reduce the number of workers.
</p>
<p>
For fastest verification, set this to the number of physical CPU cores in your device. Setting more workers than
you have actual cores will generally only slow down verification.
</p>
<p>If you don't understand what this means, leave it at the default setting.</p>
</details>
<form id="captcha-form" style="display: none">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input type="hidden" name="tokenLifetime" value="<%= tokenLifetime %>" />
</form>
<div id="captcha-control">
<div>
<label for="workers">Workers:</label>
<input type="number" id="workers" value="1" min="1" max="32" onchange="spawnWorkers()" />
</div>
<button id="worker-control" onclick="toggleWorker()">Start verification</button>
</div>
<div id="captcha-progress-container" style="display: none">
@ -185,6 +194,9 @@
function handleWorkerMessage(e) {
switch (e.data.type) {
case "progress":
if (solution) {
return;
}
totalHashes += e.data.hashes;
reports++;
break;
@ -206,13 +218,13 @@
}
workers.forEach((w, i) => {
w.postMessage({ type: "stop" });
setTimeout(() => w.terminate(), 1000 + i * 100)
setTimeout(() => w.terminate(), 1000 + i * 100);
});
workers = [];
active = false;
solution = e.data.nonce;
document.getElementById("captcha-result").textContent =
"Verification completed. Submitting solution for verification...";
"Solution found. Verifying with server...";
document.getElementById("captcha-control").style.display = "none";
submitVerification();
break;
@ -233,6 +245,21 @@
estimateProgress();
}
function copyToClipboard(text) {
if (!navigator.clipboard) {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand("copy");
textArea.remove();
} else {
navigator.clipboard.writeText(text);
}
alert("Copied to clipboard.");
}
function loadNewChallenge(c, s) {
const btn = document.getElementById("worker-control");
btn.textContent = "Start verification";
@ -248,6 +275,7 @@
startTime = 0;
lastUpdateTime = 0;
elapsedTime = 0;
totalHashes = 0;
const targetValue = challenge.d.slice(0, -1);
const hashLength = challenge.hl;
workFactor = Number(BigInt(2) ** BigInt(8 * hashLength) / BigInt(targetValue));
@ -329,52 +357,53 @@
document.getElementById("captcha-progress").style.display = "none";
document.getElementById("captcha-result").innerHTML = `
<p style="color: green">Verification complete</p>
<p>Your user token is: <code>${data.token}</code></p>
<p>Your user token is: <code>${data.token}</code> <button id="copy-token" onclick="copyToClipboard('${data.token}')">📋</button></p>
<p>Valid until: ${new Date(Date.now() + lifetime * 3600 * 1000).toLocaleString()}</p>
`;
}
});
}
function formatTime(time) {
if (time < 60) {
return time.toFixed(1) + "s";
} else if (time < 3600) {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return minutes + "m " + seconds + "s";
} else {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
return hours + "h " + minutes + "m";
}
}
function estimateProgress() {
if (reports % workers.length !== 0) {
// if (reports % workers.length !== 0) {
// return;
// }
if (Date.now() - lastUpdateTime < 500) {
return;
}
elapsedTime += (Date.now() - lastUpdateTime) / 1000;
lastUpdateTime = Date.now();
const hashRate = totalHashes / elapsedTime;
const timeRemaining = (workFactor - totalHashes) / hashRate;
const progress = 100 * (1 - Math.exp(-totalHashes / workFactor));
const formatTime = (time) => {
if (time < 60) {
return time.toFixed(1) + "s";
} else if (time < 3600) {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return minutes + "m " + seconds + "s";
} else {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
return hours + "h " + minutes + "m";
}
};
// const progress = 100 * (1 - Math.exp(-totalHashes / workFactor));
const p = 1 / workFactor;
const odds = ((1 - p) ** totalHashes * 100).toFixed(2);
const odds = ((1 - p) ** totalHashes * 100).toFixed(3);
const progress = 100 - odds;
let note = "";
if (odds < 33) {
note = " (" + odds + "% odds of no solution yet)";
}
// let note = " (" + odds + "% odds of no solution yet)";
document.getElementById("captcha-progress").style.width = Math.min(progress, 100) + "%";
document.querySelector("#captcha-progress>.progress").style.width = Math.min(progress, 100) + "%";
document.getElementById("captcha-progress-text").value = `
Solution probability: 1 in ${workFactor.toLocaleString()} hashes
Hashes computed: ${totalHashes.toLocaleString()}${note}
Hashes computed: ${totalHashes.toLocaleString()}
Luckiness: ${odds}%
Elapsed time: ${formatTime(elapsedTime)}
Hash rate: ${hashRate.toFixed(2)} H/s
Workers: ${workers.length}${isMobileWebkit ? " (iOS/iPadOS detected)" : ""}
${active ? `Average time remaining: ${formatTime(timeRemaining)}` : "Verification task stopped"}`.trim();
${active ? `Average time remaining: ${formatTime(timeRemaining)}` : "Verification stopped"}`.trim();
}
</script>

View File

@ -1,20 +1,27 @@
<%- include("partials/shared_header", { title: "Request User Token" }) %>
<style>
#request-buttons {
#request-container {
display: flex;
justify-content: space-between;
margin-top: 20px;
width: 400px;
flex-direction: column;
align-items: center;
margin: 20px 0;
width: 100%;
gap: 10px;
}
#request-buttons button {
margin: 0 10px;
#request-container button {
flex: 1;
width: 100%;
max-width: 300px;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
#refresh-token-input {
width: 100%;
}
</style>
<h1>Request User Token</h1>
@ -28,37 +35,70 @@
</div>
</div>
<% } %>
<div id="existing-token" style="display: none">
<p>It looks like you might have an older temporary user token. If it has expired, you can try to refresh it.</p>
<strong id="existing-token-value">Existing token:</strong>
<div id="request-container">
<button id="request-token" onclick="requestChallenge('new')">Get a new token</button>
<button id="refresh-token-toggle" onclick="switchSection('refresh')">Refresh an old token</button>
<h6 id="existing-token-value" style="display: none">Existing token:</h6>
</div>
<div id="request-buttons">
<button disabled id="refresh-token" onclick="requestChallenge('refresh')">Refresh old token</button>
<button id="request_token" onclick="requestChallenge('new')">Request new token</button>
<div id="back-to-menu" style="display: none">
<a href="#" onclick="switchSection('root')">« Back</a>
</div>
<div id="refresh-container" style="display: none">
<div id="existing-token">
<p>
If you have an existing or expired token, enter it here to try to refresh it by completing a shorter verification.
</p>
<div>
<label for="refresh-token-input">Existing token:</label>
<input type="text" id="refresh-token-input" />
<button id="refresh-token" onclick="requestChallenge('refresh')">Refresh</button>
</div>
</div>
</div>
<%- include("partials/user_challenge_widget") %>
<script>
function switchSection(sectionId) {
const backToMenu = document.getElementById("back-to-menu");
const captchaSection = document.getElementById("captcha-container");
const requestSection = document.getElementById("request-container");
const refreshSection = document.getElementById("refresh-container");
[backToMenu, captchaSection, requestSection, refreshSection].forEach((element) => (element.style.display = "none"));
switch (sectionId) {
case "root":
requestSection.style.display = "flex";
maybeLoadExistingToken();
break;
case "captcha":
captchaSection.style.display = "block";
backToMenu.style.display = "block";
break;
case "refresh":
refreshSection.style.display = "block";
backToMenu.style.display = "block";
document.getElementById("refresh-token-input").focus();
break;
}
}
function requestChallenge(action) {
const token = localStorage.getItem("captcha-temp-token");
if (token && action === "new") {
const data = JSON.parse(token);
const { expires } = data;
const expiresDate = new Date(expires);
const now = new Date();
if (expiresDate > now) {
if (!confirm("You already have an existing token. Are you sure you want to request a new one?")) {
return;
}
localStorage.removeItem("captcha-temp-token");
document.getElementById("existing-token").style.display = "none";
document.getElementById("refresh-token").disabled = true;
const savedToken = localStorage.getItem("captcha-temp-token");
const refreshInput = document.getElementById("refresh-token-input").value;
if (savedToken && action === "new") {
const confirmation = confirm(
"It looks like you might already have an existing token. Are you sure you want to request a new one?"
);
if (!confirmation) {
return;
}
} else if (!token && action === "refresh") {
alert("You don't have an existing token to refresh");
localStorage.removeItem("captcha-temp-token");
document.getElementById("existing-token").style.display = "none";
document.getElementById("refresh-token").disabled = true;
} else if (!refreshInput?.length && action === "refresh") {
alert("You need to provide a token to refresh.");
return;
}
const refreshToken = token && action === "refresh" ? JSON.parse(token).token : undefined;
const refreshToken = action === "refresh" ? refreshInput : undefined;
const keyInput = document.getElementById("proxy-key");
const proxyKey = (keyInput && keyInput.value) || undefined;
if (!proxyKey?.length) {
@ -79,7 +119,7 @@
}
const { challenge, signature } = data;
loadNewChallenge(challenge, signature);
document.getElementById("request-buttons").style.display = "none";
switchSection("captcha");
})
.catch(function (error) {
console.error(error);
@ -87,22 +127,26 @@
});
}
const existingToken = localStorage.getItem("captcha-temp-token");
if (existingToken) {
const data = JSON.parse(existingToken);
const { token, expires } = data;
const expiresDate = new Date(expires);
document.getElementById(
"existing-token-value"
).textContent = `Your token: ${token} (valid until ${expiresDate.toLocaleString()})`;
document.getElementById("existing-token").style.display = "block";
document.getElementById("refresh-token").disabled = false;
function maybeLoadExistingToken() {
const existingToken = localStorage.getItem("captcha-temp-token");
if (existingToken) {
const data = JSON.parse(existingToken);
const { token, expires } = data;
const expiresDate = new Date(expires);
document.getElementById(
"existing-token-value"
).textContent = `User token: ${token} (valid until ${expiresDate.toLocaleString()})`;
document.getElementById("existing-token-value").style.display = "block";
document.getElementById("refresh-token-input").value = token;
}
const proxyKey = localStorage.getItem("captcha-proxy-key");
if (proxyKey && document.getElementById("proxy-key")) {
document.getElementById("proxy-key").value = proxyKey;
}
}
const proxyKey = localStorage.getItem("captcha-proxy-key");
if (proxyKey && document.getElementById("proxy-key")) {
document.getElementById("proxy-key").value = proxyKey;
}
switchSection("root");
</script>
<%- include("partials/user_footer") %>