Temporary usertokens via proof-of-work challenge (khanon/oai-reverse-proxy!68)
This commit is contained in:
+15
-2
@@ -1,17 +1,30 @@
|
||||
import express, { Router } from "express";
|
||||
import { authorize } from "./auth";
|
||||
import { createWhitelistMiddleware } from "../shared/cidr";
|
||||
import { HttpError } from "../shared/errors";
|
||||
import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf";
|
||||
import { injectLocals } from "../shared/inject-locals";
|
||||
import { withSession } from "../shared/with-session";
|
||||
import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf";
|
||||
import { config } from "../config";
|
||||
import { renderPage } from "../info-page";
|
||||
import { buildInfo } from "../service-info";
|
||||
import { authorize } from "./auth";
|
||||
import { loginRouter } from "./login";
|
||||
import { usersApiRouter as apiRouter } from "./api/users";
|
||||
import { usersWebRouter as webRouter } from "./web/manage";
|
||||
import { logger } from "../logger";
|
||||
|
||||
const adminRouter = Router();
|
||||
|
||||
const whitelist = createWhitelistMiddleware(
|
||||
"ADMIN_WHITELIST",
|
||||
config.adminWhitelist
|
||||
);
|
||||
|
||||
if (!whitelist.ranges.length && config.adminKey?.length) {
|
||||
logger.error("ADMIN_WHITELIST is empty. No admin requests will be allowed. Set 0.0.0.0/0 to allow all.");
|
||||
}
|
||||
|
||||
adminRouter.use(whitelist);
|
||||
adminRouter.use(
|
||||
express.json({ limit: "20mb" }),
|
||||
express.urlencoded({ extended: true, limit: "20mb" })
|
||||
|
||||
+190
-5
@@ -1,4 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import ipaddr from "ipaddr.js";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../config";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
UserTokenCounts,
|
||||
} from "../../shared/users/schema";
|
||||
import { getLastNImages } from "../../shared/file-storage/image-history";
|
||||
import { blacklists, parseCidrs, whitelists } from "../../shared/cidr";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -40,6 +42,74 @@ router.get("/create-user", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/anti-abuse", (_req, res) => {
|
||||
const wl = [...whitelists.entries()];
|
||||
const bl = [...blacklists.entries()];
|
||||
|
||||
res.render("admin_anti-abuse", {
|
||||
captchaMode: config.captchaMode,
|
||||
difficulty: config.powDifficultyLevel,
|
||||
whitelists: wl.map((w) => ({
|
||||
name: w[0],
|
||||
mode: "whitelist",
|
||||
ranges: w[1].ranges,
|
||||
})),
|
||||
blacklists: bl.map((b) => ({
|
||||
name: b[0],
|
||||
mode: "blacklist",
|
||||
ranges: b[1].ranges,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/cidr", (req, res) => {
|
||||
const body = req.body;
|
||||
const valid = z
|
||||
.object({
|
||||
action: z.enum(["add", "remove"]),
|
||||
mode: z.enum(["whitelist", "blacklist"]),
|
||||
name: z.string().min(1),
|
||||
mask: z.string().min(1),
|
||||
})
|
||||
.safeParse(body);
|
||||
|
||||
if (!valid.success) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
valid.error.issues.flatMap((issue) => issue.message).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
const { mode, name, mask } = valid.data;
|
||||
const list = (mode === "whitelist" ? whitelists : blacklists).get(name);
|
||||
if (!list) {
|
||||
throw new HttpError(404, "List not found");
|
||||
}
|
||||
if (valid.data.action === "remove") {
|
||||
const newRanges = new Set(list.ranges);
|
||||
newRanges.delete(mask);
|
||||
list.updateRanges([...newRanges]);
|
||||
req.session.flash = {
|
||||
type: "success",
|
||||
message: `${mode} ${name} updated`,
|
||||
};
|
||||
return res.redirect("/admin/manage/anti-abuse");
|
||||
} else if (valid.data.action === "add") {
|
||||
const result = parseCidrs(mask);
|
||||
if (result.length === 0) {
|
||||
throw new HttpError(400, "Invalid CIDR mask");
|
||||
}
|
||||
|
||||
const newRanges = new Set([...list.ranges, mask]);
|
||||
list.updateRanges([...newRanges]);
|
||||
req.session.flash = {
|
||||
type: "success",
|
||||
message: `${mode} ${name} updated`,
|
||||
};
|
||||
return res.redirect("/admin/manage/anti-abuse");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/create-user", (req, res) => {
|
||||
const body = req.body;
|
||||
|
||||
@@ -223,10 +293,14 @@ router.post("/maintenance", (req, res) => {
|
||||
break;
|
||||
}
|
||||
case "downloadImageMetadata": {
|
||||
const data = JSON.stringify({
|
||||
exportedAt: new Date().toISOString(),
|
||||
generations: getLastNImages()
|
||||
}, null, 2);
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
generations: getLastNImages(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename=image-metadata-${new Date().toISOString()}.json`
|
||||
@@ -234,14 +308,125 @@ router.post("/maintenance", (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.send(data);
|
||||
}
|
||||
case "expireTempTokens": {
|
||||
const users = userStore.getUsers();
|
||||
const temps = users.filter((u) => u.type === "temporary");
|
||||
temps.forEach((user) => {
|
||||
user.expiresAt = Date.now();
|
||||
userStore.upsertUser(user);
|
||||
});
|
||||
flash.type = "success";
|
||||
flash.message = `${temps.length} temporary users marked for expiration.`;
|
||||
break;
|
||||
}
|
||||
case "cleanTempTokens": {
|
||||
const users = userStore.getUsers();
|
||||
const disabledTempUsers = users.filter(
|
||||
(u) => u.type === "temporary" && u.expiresAt && u.expiresAt < Date.now()
|
||||
);
|
||||
disabledTempUsers.forEach((user) => {
|
||||
user.disabledAt = 1; //will be cleaned up by the next cron job
|
||||
userStore.upsertUser(user);
|
||||
});
|
||||
flash.type = "success";
|
||||
flash.message = `${disabledTempUsers.length} disabled temporary users marked for cleanup.`;
|
||||
break;
|
||||
}
|
||||
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);
|
||||
}
|
||||
config.powDifficultyLevel = selected;
|
||||
break;
|
||||
}
|
||||
case "generateTempIpReport": {
|
||||
const tempUsers = userStore
|
||||
.getUsers()
|
||||
.filter((u) => u.type === "temporary");
|
||||
const ipv4RangeMap: Map<string, Set<string>> = new Map<
|
||||
string,
|
||||
Set<string>
|
||||
>();
|
||||
const ipv6RangeMap: Map<string, Set<string>> = new Map<
|
||||
string,
|
||||
Set<string>
|
||||
>();
|
||||
|
||||
tempUsers.forEach((u) => {
|
||||
u.ip.forEach((ip) => {
|
||||
try {
|
||||
const parsed = ipaddr.parse(ip);
|
||||
if (parsed.kind() === "ipv4") {
|
||||
const subnet =
|
||||
parsed.toNormalizedString().split(".").slice(0, 3).join(".") +
|
||||
".0/24";
|
||||
const userSet = ipv4RangeMap.get(subnet) || new Set<string>();
|
||||
userSet.add(u.token);
|
||||
ipv4RangeMap.set(subnet, userSet);
|
||||
} else if (parsed.kind() === "ipv6") {
|
||||
const subnet =
|
||||
parsed.toNormalizedString().split(":").slice(0, 3).join(":") +
|
||||
"::/56";
|
||||
const userSet = ipv6RangeMap.get(subnet) || new Set<string>();
|
||||
userSet.add(u.token);
|
||||
ipv6RangeMap.set(subnet, userSet);
|
||||
}
|
||||
} catch (e) {
|
||||
req.log.warn(
|
||||
{ ip, error: e.message },
|
||||
"Invalid IP address; skipping"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const ipv4Ranges = Array.from(ipv4RangeMap.entries())
|
||||
.map(([subnet, userSet]) => ({
|
||||
subnet,
|
||||
distinctTokens: userSet.size,
|
||||
}))
|
||||
.sort((a, b) => b.distinctTokens - a.distinctTokens);
|
||||
|
||||
const ipv6Ranges = Array.from(ipv6RangeMap.entries())
|
||||
.map(([subnet, userSet]) => ({
|
||||
subnet,
|
||||
distinctTokens: userSet.size,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.distinctTokens === b.distinctTokens) {
|
||||
return a.subnet.localeCompare(b.subnet);
|
||||
}
|
||||
return b.distinctTokens - a.distinctTokens;
|
||||
});
|
||||
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
ipv4Ranges,
|
||||
ipv6Ranges,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename=temp-ip-report-${new Date().toISOString()}.json`
|
||||
);
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.send(data);
|
||||
}
|
||||
default: {
|
||||
throw new HttpError(400, "Invalid action");
|
||||
}
|
||||
}
|
||||
|
||||
req.session.flash = flash;
|
||||
const referer = req.get("referer");
|
||||
|
||||
return res.redirect(`/admin/manage`);
|
||||
return res.redirect(referer || "/admin/manage");
|
||||
});
|
||||
|
||||
router.get("/download-stats", (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<%- include("partials/shared_header", { title: "Proof of Work Verification Settings - OAI Reverse Proxy Admin" }) %>
|
||||
<style>
|
||||
details {
|
||||
margin-top: 1em;
|
||||
}
|
||||
details summary {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
details p {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
#token-manage {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
#token-manage button {
|
||||
padding: 0.5em;
|
||||
margin: 0 0.5em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Abuse Mitigation Settings</h1>
|
||||
<div>
|
||||
<h2>Proof-of-Work Verification</h2>
|
||||
<p>
|
||||
The Proof-of-Work difficulty level is used to determine how much work a client must perform to earn a temporary user
|
||||
token. Higher difficulty levels require more work, which can help mitigate abuse by making it more expensive for
|
||||
attackers to generate tokens. However, higher difficulty levels can also make it more difficult for legitimate users
|
||||
to generate tokens. Refer to documentation for guidance.
|
||||
</p>
|
||||
<%if (captchaMode === "none") { %>
|
||||
<p>
|
||||
<strong>PoW verification is not enabled. Set <code>CAPTCHA_MODE=proof_of_work</code> to enable.</strong>
|
||||
</p>
|
||||
<% } else { %>
|
||||
<h3>Difficulty Level</h3>
|
||||
<div>
|
||||
<label for="difficulty">Difficulty Level:</label>
|
||||
<span id="currentDifficulty">Current: <%= difficulty %></span>
|
||||
<select name="difficulty" id="difficulty">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="extreme">Extreme</option>
|
||||
</select>
|
||||
<button onclick='doAction("setDifficulty")'>Update Difficulty</button>
|
||||
</div>
|
||||
<% } %>
|
||||
<form id="maintenanceForm" action="/admin/manage/maintenance" method="post">
|
||||
<input id="_csrf" type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
<input id="hiddenAction" type="hidden" name="action" value="" />
|
||||
<input id="hiddenDifficulty" type="hidden" name="pow-difficulty" value="" />
|
||||
</form>
|
||||
<h3>Manage Temporary User Tokens</h3>
|
||||
<div id="token-manage">
|
||||
<p><button onclick='doAction("expireTempTokens")'>🕒 Expire All Temp Tokens</button></p>
|
||||
<p><button onclick='doAction("cleanTempTokens")'>🧹 Delete Expired Temp Tokens</button></p>
|
||||
<p><button onclick='doAction("generateTempIpReport")'>📊 Generate Temp Token IP Report</button></p>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
<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>
|
||||
<% for (let i = 0; i < whitelists.length; i++) { %>
|
||||
<%- include("partials/admin-cidr-widget", { list: whitelists[i] }) %>
|
||||
<% } %>
|
||||
<% for (let i = 0; i < blacklists.length; i++) { %>
|
||||
<%- include("partials/admin-cidr-widget", { list: blacklists[i] }) %>
|
||||
<% } %>
|
||||
<form action="/admin/manage/cidr" method="post" id="cidrForm">
|
||||
<input id="_csrf" type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
<input type="hidden" name="action" value="add" />
|
||||
<input type="hidden" name="name" value="" />
|
||||
<input type="hidden" name="mode" value="" />
|
||||
<input type="hidden" name="mask" value="" />
|
||||
</form>
|
||||
<details>
|
||||
<summary>Copy environment variables</summary>
|
||||
<p>
|
||||
If you have made changes with the UI, you can copy the values below to your deployment configuration to persist
|
||||
them across server restarts.
|
||||
</p>
|
||||
<pre>
|
||||
<% for (let i = 0; i < whitelists.length; i++) { %><%= whitelists[i].name %>=<%= whitelists[i].ranges.join(",") %><% } %>
|
||||
<% for (let i = 0; i < blacklists.length; i++) { %><%= blacklists[i].name %>=<%= blacklists[i].ranges.join(",") %><% } %>
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function doAction(action) {
|
||||
document.getElementById("hiddenAction").value = action;
|
||||
if (action === "setDifficulty") {
|
||||
document.getElementById("hiddenDifficulty").value = document.getElementById("difficulty").value;
|
||||
}
|
||||
document.getElementById("maintenanceForm").submit();
|
||||
}
|
||||
|
||||
function onAddCidr(event) {
|
||||
const list = event.target.dataset;
|
||||
const newMask = prompt("Enter the IP or CIDR range to add to the list:");
|
||||
if (!newMask) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById("cidrForm");
|
||||
form["action"].value = "add";
|
||||
form["name"].value = list.name;
|
||||
form["mode"].value = list.mode;
|
||||
form["mask"].value = newMask;
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function onRemoveCidr(event) {
|
||||
const list = event.target.dataset;
|
||||
const removeMask = event.target.dataset.mask;
|
||||
if (!removeMask) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById("cidrForm");
|
||||
form["action"].value = "remove";
|
||||
form["name"].value = list.name;
|
||||
form["mode"].value = list.mode;
|
||||
form["mask"].value = removeMask;
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
<%- include("partials/admin-footer") %>
|
||||
@@ -25,6 +25,7 @@
|
||||
<li><a href="/admin/manage/import-users">Import Users</a></li>
|
||||
<li><a href="/admin/manage/export-users">Export Users</a></li>
|
||||
<li><a href="/admin/manage/download-stats">Download Rentry Stats</a>
|
||||
<li><a href="/admin/manage/anti-abuse">Abuse Mitigation Settings</a></li>
|
||||
<li><a href="/admin/service-info">Service Info</a></li>
|
||||
</ul>
|
||||
<h3>Maintenance</h3>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<% } else { %>
|
||||
<input type="checkbox" id="toggle-nicknames" onchange="toggleNicknames()" />
|
||||
<label for="toggle-nicknames">Show Nicknames</label>
|
||||
<table class="striped">
|
||||
<table class="striped" style="width: calc(100vw - 3em)">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
|
||||
@@ -55,8 +55,9 @@
|
||||
<td><%- user.disabledReason %></td>
|
||||
<% if (user.disabledAt) { %>
|
||||
<td class="actions">
|
||||
<a title="Edit" id="edit-disabledReason" href="#" data-field="disabledReason"
|
||||
data-token="<%= user.token %>">✏️</a>
|
||||
<a title="Edit" id="edit-disabledReason" href="#" data-field="disabledReason" data-token="<%= user.token %>"
|
||||
>✏️</a
|
||||
>
|
||||
</td>
|
||||
<% } %>
|
||||
</tr>
|
||||
@@ -72,7 +73,8 @@
|
||||
<td colspan="2"><%- include("partials/shared_user_ip_list", { user, shouldRedact: false }) %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Admin Note <span title="Unlike nickname, this is not visible to or editable by the user">🔒</span>
|
||||
<th scope="row">
|
||||
Admin Note <span title="Unlike nickname, this is not visible to or editable by the user">🔒</span>
|
||||
</th>
|
||||
<td><%- user.adminNote ?? "none" %></td>
|
||||
<td class="actions">
|
||||
@@ -85,10 +87,16 @@
|
||||
<td colspan="2"><%- user.expiresAt %></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% if (user.meta) { %>
|
||||
<tr>
|
||||
<th scope="row">Meta</th>
|
||||
<td colspan="2"><%- JSON.stringify(user.meta) %></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form style="display:none" id="current-values">
|
||||
<form style="display: none" id="current-values">
|
||||
<input type="hidden" name="token" value="<%- user.token %>" />
|
||||
<% ["nickname", "type", "disabledAt", "disabledReason", "maxIps", "adminNote"].forEach(function (key) { %>
|
||||
<input type="hidden" name="<%- key %>" value="<%- user[key] %>" />
|
||||
@@ -102,7 +110,8 @@
|
||||
<input type="hidden" name="_csrf" value="<%- csrfToken %>" />
|
||||
<button type="submit" class="btn btn-primary">Refresh Quotas for User</button>
|
||||
</form>
|
||||
<% } %> <%- include("partials/shared_quota-info", { quota, user }) %>
|
||||
<% } %>
|
||||
<%- include("partials/shared_quota-info", { quota, user }) %>
|
||||
|
||||
<p><a href="/admin/manage/list-users">Back to User List</a></p>
|
||||
|
||||
@@ -144,4 +153,5 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<%- include("partials/admin-ban-xhr-script") %> <%- include("partials/admin-footer") %>
|
||||
<%- include("partials/admin-ban-xhr-script") %>
|
||||
<%- include("partials/admin-footer") %>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<h3>
|
||||
<%= list.name %>
|
||||
(<%= list.mode %>)
|
||||
</h3>
|
||||
<ul>
|
||||
<% list.ranges.forEach(function(mask) { %>
|
||||
<li>
|
||||
<%= mask %>
|
||||
<button class="remove" data-mode="<%= list.mode %>" data-name="<%= list.name %>" data-mask="<%= mask %>" onclick="onRemoveCidr(event)">Remove</button>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
<button class="add" data-mode="<%= list.mode %>" data-name="<%= list.name %>" onclick="onAddCidr(event)">Add</button>
|
||||
+77
-12
@@ -1,3 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
import dotenv from "dotenv";
|
||||
import type firebase from "firebase-admin";
|
||||
import path from "path";
|
||||
@@ -107,9 +108,28 @@ type Config = {
|
||||
* `maxIpsPerUser` limit, or if only connections from new IPs are be rejected.
|
||||
*/
|
||||
maxIpsAutoBan: boolean;
|
||||
/** Per-IP limit for requests per minute to text and chat models. */
|
||||
/**
|
||||
* Which captcha verification mode to use. Requires `user_token` gatekeeper.
|
||||
* Allows users to automatically obtain a token by solving a captcha.
|
||||
* - `none`: No captcha verification; tokens are issued manually.
|
||||
* - `proof_of_work`: Users must solve an Argon2 proof of work to obtain a
|
||||
* temporary usertoken valid for a limited period.
|
||||
*/
|
||||
captchaMode: "none" | "proof_of_work";
|
||||
/**
|
||||
* Duration in hours for which a PoW-issued temporary user token is valid.
|
||||
*/
|
||||
powTokenHours: number;
|
||||
/** Maximum number of IPs allowed per PoW-issued temporary user token. */
|
||||
powTokenMaxIps: number;
|
||||
/**
|
||||
* Difficulty level for the proof-of-work. Refer to docs/pow-captcha.md for
|
||||
* details on the available modes.
|
||||
*/
|
||||
powDifficultyLevel: "low" | "medium" | "high" | "extreme";
|
||||
/** Per-user limit for requests per minute to text and chat models. */
|
||||
textModelRateLimit: number;
|
||||
/** Per-IP limit for requests per minute to image generation models. */
|
||||
/** Per-user limit for requests per minute to image generation models. */
|
||||
imageModelRateLimit: number;
|
||||
/**
|
||||
* For OpenAI, the maximum number of context tokens (prompt + max output) a
|
||||
@@ -264,6 +284,20 @@ type Config = {
|
||||
* A leading slash is required.
|
||||
*/
|
||||
proxyEndpointRoute: string;
|
||||
/**
|
||||
* If set, only requests from these IP addresses will be permitted to use the
|
||||
* admin API and UI. Provide a comma-separated list of IP addresses or CIDR
|
||||
* ranges. If not set, the admin API and UI will be open to all requests.
|
||||
*/
|
||||
adminWhitelist: string[];
|
||||
/**
|
||||
* If set, requests from these IP addresses will be blocked from using the
|
||||
* application. Provide a comma-separated list of IP addresses or CIDR ranges.
|
||||
* If not set, no IP addresses will be blocked.
|
||||
*
|
||||
* Takes precedence over the adminWhitelist.
|
||||
*/
|
||||
ipBlacklist: string[];
|
||||
};
|
||||
|
||||
// To change configs, create a file called .env in the root directory.
|
||||
@@ -283,7 +317,11 @@ export const config: Config = {
|
||||
gatekeeper: getEnvWithDefault("GATEKEEPER", "none"),
|
||||
gatekeeperStore: getEnvWithDefault("GATEKEEPER_STORE", "memory"),
|
||||
maxIpsPerUser: getEnvWithDefault("MAX_IPS_PER_USER", 0),
|
||||
maxIpsAutoBan: getEnvWithDefault("MAX_IPS_AUTO_BAN", true),
|
||||
maxIpsAutoBan: getEnvWithDefault("MAX_IPS_AUTO_BAN", false),
|
||||
captchaMode: getEnvWithDefault("CAPTCHA_MODE", "none"),
|
||||
powTokenHours: getEnvWithDefault("POW_TOKEN_HOURS", 24),
|
||||
powTokenMaxIps: getEnvWithDefault("POW_TOKEN_MAX_IPS", 2),
|
||||
powDifficultyLevel: getEnvWithDefault("POW_DIFFICULTY_LEVEL", "low"),
|
||||
firebaseRtdbUrl: getEnvWithDefault("FIREBASE_RTDB_URL", undefined),
|
||||
firebaseKey: getEnvWithDefault("FIREBASE_KEY", undefined),
|
||||
textModelRateLimit: getEnvWithDefault("TEXT_MODEL_RATE_LIMIT", 4),
|
||||
@@ -320,7 +358,7 @@ export const config: Config = {
|
||||
"azure-gpt4",
|
||||
"azure-gpt4-32k",
|
||||
"azure-gpt4-turbo",
|
||||
"azure-gpt4o"
|
||||
"azure-gpt4o",
|
||||
]),
|
||||
rejectPhrases: parseCsv(getEnvWithDefault("REJECT_PHRASES", "")),
|
||||
rejectMessage: getEnvWithDefault(
|
||||
@@ -367,19 +405,44 @@ export const config: Config = {
|
||||
allowOpenAIToolUsage: getEnvWithDefault("ALLOW_OPENAI_TOOL_USAGE", false),
|
||||
allowImagePrompts: getEnvWithDefault("ALLOW_IMAGE_PROMPTS", false),
|
||||
proxyEndpointRoute: getEnvWithDefault("PROXY_ENDPOINT_ROUTE", "/proxy"),
|
||||
adminWhitelist: parseCsv(getEnvWithDefault("ADMIN_WHITELIST", "0.0.0.0/0")),
|
||||
ipBlacklist: parseCsv(getEnvWithDefault("IP_BLACKLIST", "")),
|
||||
} as const;
|
||||
|
||||
function generateCookieSecret() {
|
||||
function generateSigningKey() {
|
||||
if (process.env.COOKIE_SECRET !== undefined) {
|
||||
// legacy, replaced by SIGNING_KEY
|
||||
return process.env.COOKIE_SECRET;
|
||||
} else if (process.env.SIGNING_KEY !== undefined) {
|
||||
return process.env.SIGNING_KEY;
|
||||
}
|
||||
|
||||
const seed = "" + config.adminKey + config.openaiKey + config.anthropicKey;
|
||||
const crypto = require("crypto");
|
||||
const secrets = [
|
||||
config.adminKey,
|
||||
config.openaiKey,
|
||||
config.anthropicKey,
|
||||
config.googleAIKey,
|
||||
config.mistralAIKey,
|
||||
config.awsCredentials,
|
||||
config.azureCredentials,
|
||||
];
|
||||
if (secrets.filter((s) => s).length === 0) {
|
||||
startupLogger.warn(
|
||||
"No SIGNING_KEY or secrets are set. All sessions, cookies, and proofs of work will be invalidated on restart."
|
||||
);
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
startupLogger.info("No SIGNING_KEY set; one will be generated from secrets.");
|
||||
startupLogger.info(
|
||||
"It's recommended to set SIGNING_KEY explicitly to ensure users' sessions and cookies always persist across restarts."
|
||||
);
|
||||
const seed = secrets.map((s) => s || "n/a").join("");
|
||||
return crypto.createHash("sha256").update(seed).digest("hex");
|
||||
}
|
||||
|
||||
export const COOKIE_SECRET = generateCookieSecret();
|
||||
const signingKey = generateSigningKey();
|
||||
export const COOKIE_SECRET = signingKey;
|
||||
|
||||
export async function assertConfigIsValid() {
|
||||
if (process.env.MODEL_RATE_LIMIT !== undefined) {
|
||||
@@ -413,15 +476,15 @@ export async function assertConfigIsValid() {
|
||||
);
|
||||
}
|
||||
|
||||
if (config.gatekeeper === "proxy_key" && !config.proxyKey) {
|
||||
if (config.captchaMode === "proof_of_work" && config.gatekeeper !== "user_token") {
|
||||
throw new Error(
|
||||
"`proxy_key` gatekeeper mode requires a `PROXY_KEY` to be set."
|
||||
"Captcha mode 'proof_of_work' requires gatekeeper mode 'user_token'."
|
||||
);
|
||||
}
|
||||
|
||||
if (config.gatekeeper !== "proxy_key" && config.proxyKey) {
|
||||
if (config.gatekeeper === "proxy_key" && !config.proxyKey) {
|
||||
throw new Error(
|
||||
"`PROXY_KEY` is set, but gatekeeper mode is not `proxy_key`. Make sure to set `GATEKEEPER=proxy_key`."
|
||||
"`proxy_key` gatekeeper mode requires a `PROXY_KEY` to be set."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -494,6 +557,8 @@ export const OMITTED_KEYS = [
|
||||
"allowedModelFamilies",
|
||||
"trustedProxies",
|
||||
"proxyEndpointRoute",
|
||||
"adminWhitelist",
|
||||
"ipBlacklist",
|
||||
] satisfies (keyof Config)[];
|
||||
type OmitKeys = (typeof OMITTED_KEYS)[number];
|
||||
|
||||
|
||||
+6
-2
@@ -90,9 +90,9 @@ export function renderPage(info: ServiceInfo) {
|
||||
<body>
|
||||
${headerHtml}
|
||||
<hr />
|
||||
${getSelfServiceLinks()}
|
||||
<h2>Service Info</h2>
|
||||
<pre>${JSON.stringify(info, null, 2)}</pre>
|
||||
${getSelfServiceLinks()}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -146,7 +146,11 @@ This proxy keeps full logs of all prompts and AI responses. Prompt logs are anon
|
||||
|
||||
function getSelfServiceLinks() {
|
||||
if (config.gatekeeper !== "user_token") return "";
|
||||
return `<footer style="font-size: 0.8em;"><hr /><a target="_blank" href="/user/lookup">Check your user token info</a></footer>`;
|
||||
const links = [
|
||||
["Request a user token", "/user/captcha",],
|
||||
["Check your user token", "/user/lookup",]
|
||||
]
|
||||
return `<div style="font-size: 0.8em;">${links.map(([text, link]) => `<a target="_blank" href="${link}">${text}</a>`).join(" / ")}</div><hr />`;
|
||||
}
|
||||
|
||||
function getServerTitle() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import pinoHttp from "pino-http";
|
||||
import os from "os";
|
||||
import childProcess from "child_process";
|
||||
import { logger } from "./logger";
|
||||
import { createBlacklistMiddleware } from "./shared/cidr";
|
||||
import { setupAssetsDir } from "./shared/file-storage/setup-assets-dir";
|
||||
import { keyPool } from "./shared/key-management";
|
||||
import { adminRouter } from "./admin/routes";
|
||||
@@ -62,9 +63,17 @@ app.set("views", [
|
||||
]);
|
||||
|
||||
app.use("/user_content", express.static(USER_ASSETS_DIR, { maxAge: "2h" }));
|
||||
app.use(
|
||||
"/res",
|
||||
express.static(path.join(__dirname, "..", "public"), { etag: true })
|
||||
);
|
||||
|
||||
app.get("/health", (_req, res) => res.sendStatus(200));
|
||||
app.use(cors());
|
||||
|
||||
const blacklist = createBlacklistMiddleware("IP_BLACKLIST", config.ipBlacklist);
|
||||
app.use(blacklist);
|
||||
|
||||
app.use(checkOrigin);
|
||||
|
||||
app.use("/admin", adminRouter);
|
||||
|
||||
+2
-2
@@ -399,7 +399,7 @@ function addKeyToAggregates(k: KeyPoolKey) {
|
||||
// Ignore revoked keys for aws logging stats, but include keys where the
|
||||
// logging status is unknown.
|
||||
const countAsLogged =
|
||||
k.lastChecked && !k.isDisabled && k.awsLoggingStatus !== "disabled";
|
||||
k.lastChecked && !k.isDisabled && k.awsLoggingStatus === "enabled";
|
||||
increment(modelStats, `aws-claude__awsLogged`, countAsLogged ? 1 : 0);
|
||||
break;
|
||||
}
|
||||
@@ -448,7 +448,7 @@ function getInfoForFamily(family: ModelFamily): BaseFamilyInfo {
|
||||
const logged = modelStats.get(`${family}__awsLogged`) || 0;
|
||||
if (logged > 0) {
|
||||
info.privacy = config.allowAwsLogging
|
||||
? `${logged} active keys are potentially logged.`
|
||||
? `AWS logging verification inactive. Prompts could be logged.`
|
||||
: `${logged} active keys are potentially logged and can't be used. Set ALLOW_AWS_LOGGING=true to override.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import ipaddr, { IPv4, IPv6 } from "ipaddr.js";
|
||||
import { logger } from "../logger";
|
||||
|
||||
const log = logger.child({ module: "cidr" });
|
||||
|
||||
type IpCheckMiddleware = ((
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => void) & {
|
||||
ranges: string[];
|
||||
updateRanges: (ranges: string[] | string) => void;
|
||||
};
|
||||
|
||||
export const whitelists = new Map<string, IpCheckMiddleware>();
|
||||
export const blacklists = new Map<string, IpCheckMiddleware>();
|
||||
|
||||
export function parseCidrs(cidrs: string[] | string): [IPv4 | IPv6, number][] {
|
||||
const list = Array.isArray(cidrs)
|
||||
? cidrs
|
||||
: cidrs.split(",").map((s) => s.trim());
|
||||
return list
|
||||
.map((input) => {
|
||||
try {
|
||||
if (input.includes("/")) {
|
||||
return ipaddr.parseCIDR(input);
|
||||
} else {
|
||||
const ip = ipaddr.parse(input);
|
||||
return ipaddr.parseCIDR(
|
||||
`${input}/${ip.kind() === "ipv4" ? 32 : 128}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error({ input, error: e.message }, "Invalid CIDR mask; skipping");
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((cidr): cidr is [IPv4 | IPv6, number] => cidr !== null);
|
||||
}
|
||||
|
||||
export function createWhitelistMiddleware(
|
||||
name: string,
|
||||
base: string[] | string
|
||||
) {
|
||||
let cidrs: string[] = [];
|
||||
let matchers: [IPv4 | IPv6, number][] = [];
|
||||
|
||||
const middleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const ip = ipaddr.process(req.ip);
|
||||
const allowed = matchers.some((cidr) => ip.match(cidr));
|
||||
if (allowed) {
|
||||
return next();
|
||||
}
|
||||
req.log.warn({ ip: req.ip, list: name }, "Request denied by whitelist");
|
||||
res.status(403).json({ error: `Forbidden (by ${name})` });
|
||||
};
|
||||
middleware.ranges = [] as string[];
|
||||
middleware.updateRanges = (ranges: string[] | string) => {
|
||||
cidrs = Array.isArray(ranges) ? ranges.slice() : [ranges];
|
||||
matchers = parseCidrs(cidrs);
|
||||
log.info({ list: name, matchers }, "IP whitelist configured");
|
||||
middleware.ranges = cidrs;
|
||||
};
|
||||
|
||||
middleware.updateRanges(base);
|
||||
|
||||
whitelists.set(name, middleware);
|
||||
return middleware;
|
||||
}
|
||||
|
||||
export function createBlacklistMiddleware(
|
||||
name: string,
|
||||
base: string[] | string
|
||||
) {
|
||||
let cidrs: string[] = [];
|
||||
let matchers: [IPv4 | IPv6, number][] = [];
|
||||
|
||||
const middleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const ip = ipaddr.process(req.ip);
|
||||
const denied = matchers.some((cidr) => ip.match(cidr));
|
||||
if (denied) {
|
||||
req.log.warn({ ip: req.ip, list: name }, "Request denied by blacklist");
|
||||
return res.status(403).json({ error: `Forbidden (by ${name})` });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
middleware.ranges = [] as string[];
|
||||
middleware.updateRanges = (ranges: string[] | string) => {
|
||||
cidrs = Array.isArray(ranges) ? ranges.slice() : [ranges];
|
||||
matchers = parseCidrs(cidrs);
|
||||
log.info({ list: name, matchers }, "IP blacklist configured");
|
||||
middleware.ranges = cidrs;
|
||||
};
|
||||
middleware.updateRanges(base);
|
||||
|
||||
blacklists.set(name, middleware);
|
||||
return middleware;
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import { URL } from "url";
|
||||
import { KeyCheckerBase } from "../key-checker-base";
|
||||
import type { AwsBedrockKey, AwsBedrockKeyProvider } from "./provider";
|
||||
import { AwsBedrockModelFamily } from "../../models";
|
||||
import { config } from "../../../config";
|
||||
|
||||
const MIN_CHECK_INTERVAL = 3 * 1000; // 3 seconds
|
||||
const KEY_CHECK_PERIOD = 30 * 60 * 1000; // 30 minutes
|
||||
const KEY_CHECK_PERIOD = 90 * 60 * 1000; // 90 minutes
|
||||
const AMZ_HOST =
|
||||
process.env.AMZ_HOST || "bedrock-runtime.%REGION%.amazonaws.com";
|
||||
const GET_CALLER_IDENTITY_URL = `https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15`;
|
||||
@@ -66,6 +67,15 @@ export class AwsKeyChecker extends KeyCheckerBase<AwsBedrockKey> {
|
||||
const families: AwsBedrockModelFamily[] = [];
|
||||
if (claudeV2 || sonnet || haiku) families.push("aws-claude");
|
||||
if (opus) families.push("aws-claude-opus");
|
||||
|
||||
if (families.length === 0) {
|
||||
this.log.warn(
|
||||
{ key: key.hash },
|
||||
"Key does not have access to any models; disabling."
|
||||
);
|
||||
return this.updateKey(key.hash, { isDisabled: true });
|
||||
}
|
||||
|
||||
this.updateKey(key.hash, {
|
||||
sonnetEnabled: sonnet,
|
||||
haikuEnabled: haiku,
|
||||
@@ -190,13 +200,14 @@ export class AwsKeyChecker extends KeyCheckerBase<AwsBedrockKey> {
|
||||
const correctErrorType = errorType === "ValidationException";
|
||||
const correctErrorMessage = errorMessage?.match(/max_tokens/);
|
||||
if (!correctErrorType || !correctErrorMessage) {
|
||||
throw new AxiosError(
|
||||
`Unexpected error when invoking model ${model}: ${errorMessage}`,
|
||||
"AWS_ERROR",
|
||||
response.config,
|
||||
response.request,
|
||||
response
|
||||
);
|
||||
return false;
|
||||
// throw new AxiosError(
|
||||
// `Unexpected error when invoking model ${model}: ${errorMessage}`,
|
||||
// "AWS_ERROR",
|
||||
// response.config,
|
||||
// response.request,
|
||||
// response
|
||||
// );
|
||||
}
|
||||
|
||||
this.log.debug(
|
||||
@@ -207,16 +218,22 @@ export class AwsKeyChecker extends KeyCheckerBase<AwsBedrockKey> {
|
||||
}
|
||||
|
||||
private async checkLoggingConfiguration(key: AwsBedrockKey) {
|
||||
if (config.allowAwsLogging) {
|
||||
// Don't check logging status if we're allowing it to reduce API calls.
|
||||
this.updateKey(key.hash, { awsLoggingStatus: "unknown" });
|
||||
return true;
|
||||
}
|
||||
|
||||
const creds = AwsKeyChecker.getCredentialsFromKey(key);
|
||||
const config: AxiosRequestConfig = {
|
||||
const req: AxiosRequestConfig = {
|
||||
method: "GET",
|
||||
url: GET_INVOCATION_LOGGING_CONFIG_URL(creds.region),
|
||||
headers: { accept: "application/json" },
|
||||
validateStatus: () => true,
|
||||
};
|
||||
await AwsKeyChecker.signRequestForAws(config, key);
|
||||
await AwsKeyChecker.signRequestForAws(req, key);
|
||||
const { data, status, headers } =
|
||||
await axios.request<GetLoggingConfigResponse>(config);
|
||||
await axios.request<GetLoggingConfigResponse>(req);
|
||||
|
||||
let result: AwsBedrockKey["awsLoggingStatus"] = "unknown";
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ export class AwsBedrockKeyProvider implements KeyProvider<AwsBedrockKey> {
|
||||
|
||||
public get(model: string) {
|
||||
const availableKeys = this.keys.filter((k) => {
|
||||
const isNotLogged = k.awsLoggingStatus === "disabled";
|
||||
const isNotLogged = k.awsLoggingStatus !== "enabled";
|
||||
const neededFamily = getAwsBedrockModelFamily(model);
|
||||
const needsSonnet =
|
||||
model.includes("sonnet") && neededFamily === "aws-claude";
|
||||
|
||||
@@ -120,13 +120,16 @@ export abstract class KeyCheckerBase<TKey extends Key> {
|
||||
this.lastCheck + this.minCheckInterval
|
||||
);
|
||||
|
||||
const delay = nextCheck - Date.now();
|
||||
const baseDelay = nextCheck - Date.now();
|
||||
const jitter = (Math.random() - 0.5) * baseDelay * 0.5;
|
||||
const jitteredDelay = Math.max(1000, baseDelay + jitter);
|
||||
|
||||
this.timeout = setTimeout(
|
||||
() => this.checkKey(oldestKey).then(() => this.scheduleNextCheck()),
|
||||
delay
|
||||
jitteredDelay
|
||||
);
|
||||
checkLog.debug(
|
||||
{ key: oldestKey.hash, nextCheck: new Date(nextCheck), delay },
|
||||
{ key: oldestKey.hash, nextCheck: new Date(nextCheck), jitteredDelay },
|
||||
"Scheduled next recurring check."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export const UserSchema = z
|
||||
maxIps: z.coerce.number().int().min(0).optional(),
|
||||
/** Private note about the user. */
|
||||
adminNote: z.string().optional(),
|
||||
meta: z.record(z.any()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export function createUser(createOptions?: {
|
||||
tokenCounts: { ...INITIAL_TOKENS },
|
||||
tokenLimits: createOptions?.tokenLimits ?? { ...config.tokenQuota },
|
||||
createdAt: Date.now(),
|
||||
meta: {},
|
||||
};
|
||||
|
||||
if (createOptions?.type === "temporary") {
|
||||
@@ -123,6 +124,7 @@ export function upsertUser(user: UserUpdate) {
|
||||
tokenCounts: { ...INITIAL_TOKENS },
|
||||
tokenLimits: { ...config.tokenQuota },
|
||||
createdAt: Date.now(),
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const updates: Partial<User> = {};
|
||||
@@ -274,6 +276,10 @@ export function disableUser(token: string, reason?: string) {
|
||||
if (!user) return;
|
||||
user.disabledAt = Date.now();
|
||||
user.disabledReason = reason;
|
||||
if (user.meta) {
|
||||
// manually banned tokens cannot be refreshed
|
||||
user.meta.refreshable = false;
|
||||
}
|
||||
usersToFlush.add(token);
|
||||
}
|
||||
|
||||
@@ -295,6 +301,10 @@ function cleanupExpiredTokens() {
|
||||
if (user.type !== "temporary") continue;
|
||||
if (user.expiresAt && user.expiresAt < now && !user.disabledAt) {
|
||||
disableUser(user.token, "Temporary token expired.");
|
||||
if (!user.meta) {
|
||||
user.meta = {};
|
||||
}
|
||||
user.meta.refreshable = config.captchaMode !== "none";
|
||||
disabled++;
|
||||
}
|
||||
if (user.disabledAt && user.disabledAt + 72 * 60 * 60 * 1000 < now) {
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="csrf-token" content="<%= csrfToken %>">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="csrf-token" content="<%= csrfToken %>" />
|
||||
<!-- prettier-ignore -->
|
||||
<title><%= title %></title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
padding: 1em;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background-color: #e0e6f6;
|
||||
}
|
||||
|
||||
|
||||
a:visited:hover {
|
||||
background-color: #e7e0f6;
|
||||
}
|
||||
|
||||
|
||||
.pagination {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
@@ -35,15 +37,16 @@
|
||||
background-color: #58739c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
table.striped tr:nth-child(even) {
|
||||
background-color: #eaeaea
|
||||
background-color: #eaeaea;
|
||||
}
|
||||
table td, table th {
|
||||
table td,
|
||||
table th {
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
@@ -65,12 +68,17 @@
|
||||
td.actions:hover {
|
||||
background-color: #e0e6f6;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
@media (max-width: 800px) {
|
||||
body {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
table td, table th {
|
||||
table td,
|
||||
table th {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -82,11 +90,13 @@
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
a:link, a:visited {
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #bbe;
|
||||
}
|
||||
|
||||
a:link:hover, a:visited:hover {
|
||||
a:link:hover,
|
||||
a:visited:hover {
|
||||
background-color: #446;
|
||||
}
|
||||
|
||||
@@ -102,5 +112,5 @@
|
||||
</head>
|
||||
<body>
|
||||
<%- include("partials/shared_flash", { flashData: flash }) %>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-2
@@ -2,6 +2,7 @@ import express, { Router } from "express";
|
||||
import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf";
|
||||
import { browseImagesRouter } from "./web/browse-images";
|
||||
import { selfServiceRouter } from "./web/self-service";
|
||||
import { powRouter } from "./web/pow-captcha";
|
||||
import { injectLocals } from "../shared/inject-locals";
|
||||
import { withSession } from "../shared/with-session";
|
||||
import { config } from "../config";
|
||||
@@ -18,17 +19,25 @@ userRouter.use(injectLocals);
|
||||
if (config.showRecentImages) {
|
||||
userRouter.use(browseImagesRouter);
|
||||
}
|
||||
if (config.captchaMode !== "none") {
|
||||
userRouter.use("/captcha", powRouter);
|
||||
}
|
||||
userRouter.use(selfServiceRouter);
|
||||
|
||||
userRouter.use(
|
||||
(
|
||||
err: Error,
|
||||
_req: express.Request,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
const data: any = { message: err.message, stack: err.stack, status: 500 };
|
||||
res.status(500).render("user_error", { ...data, flash: null });
|
||||
|
||||
if (req.accepts("json", "html") === "json") {
|
||||
return res.status(500).json({ error: err.message });
|
||||
} else {
|
||||
return res.status(500).render("user_error", { ...data, flash: null });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import crypto from "crypto";
|
||||
import express from "express";
|
||||
import argon2 from "@node-rs/argon2";
|
||||
import { z } from "zod";
|
||||
import { createUser, getUser, upsertUser } from "../../shared/users/user-store";
|
||||
import { config } from "../../config";
|
||||
|
||||
/** HMAC key for signing challenges; regenerated on startup */
|
||||
const HMAC_KEY = crypto.randomBytes(32).toString("hex");
|
||||
/** Expiry time for a challenge in milliseconds */
|
||||
const POW_EXPIRY = 1000 * 60 * 30; // 30 minutes
|
||||
/** Lockout time after verification in milliseconds */
|
||||
const LOCKOUT_TIME = 1000 * 60; // 60 seconds
|
||||
|
||||
const argon2Params = {
|
||||
ARGON2_TIME_COST: parseInt(process.env.ARGON2_TIME_COST || "8"),
|
||||
ARGON2_MEMORY_KB: parseInt(process.env.ARGON2_MEMORY_KB || String(1024 * 64)),
|
||||
ARGON2_PARALLELISM: parseInt(process.env.ARGON2_PARALLELISM || "1"),
|
||||
ARGON2_HASH_LENGTH: parseInt(process.env.ARGON2_HASH_LENGTH || "32"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Work factor for each difficulty. This is the expected number of hashes that
|
||||
* will be computed to solve the challenge, on average. The actual number of
|
||||
* hashes will vary due to randomness.
|
||||
*/
|
||||
const workFactors = { extreme: 4000, high: 1900, medium: 900, low: 200 };
|
||||
|
||||
type Challenge = {
|
||||
/** Salt */
|
||||
s: string;
|
||||
/** Argon2 hash length */
|
||||
hl: number;
|
||||
/** Argon2 time cost */
|
||||
t: number;
|
||||
/** Argon2 memory cost */
|
||||
m: number;
|
||||
/** Argon2 parallelism */
|
||||
p: number;
|
||||
/** Challenge target value (difficulty) */
|
||||
d: string;
|
||||
/** Expiry time in milliseconds */
|
||||
e: number;
|
||||
/** IP address of the client */
|
||||
ip?: string;
|
||||
/** Challenge version */
|
||||
v?: number;
|
||||
/** Usertoken for refreshing */
|
||||
token?: string;
|
||||
};
|
||||
|
||||
const verifySchema = z.object({
|
||||
challenge: z.object({
|
||||
s: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[0-9a-f]+$/),
|
||||
hl: z.number().int().positive().max(64),
|
||||
t: z.number().int().positive().min(2).max(10),
|
||||
m: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(1024 * 1024 * 2),
|
||||
p: z.number().int().positive().max(16),
|
||||
d: z.string().regex(/^[0-9]+n$/),
|
||||
e: z.number().int().positive(),
|
||||
ip: z.string().min(1).max(64).optional(),
|
||||
v: z.literal(1).optional(),
|
||||
token: z.string().min(1).max(64).optional(),
|
||||
}),
|
||||
solution: z.string().min(1).max(64),
|
||||
signature: z.string().min(1),
|
||||
proxyKey: z.string().min(1).max(1024).optional(),
|
||||
});
|
||||
|
||||
const challengeSchema = z.object({
|
||||
action: z.union([z.literal("new"), z.literal("refresh")]),
|
||||
refreshToken: z.string().min(1).max(64).optional(),
|
||||
proxyKey: z.string().min(1).max(1024).optional(),
|
||||
});
|
||||
|
||||
/** Solutions by timestamp */
|
||||
const solves = new Map<string, number>();
|
||||
/** Recent attempts by IP address */
|
||||
const recentAttempts = new Map<string, number>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, timestamp] of recentAttempts) {
|
||||
if (now - timestamp > LOCKOUT_TIME) {
|
||||
recentAttempts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, timestamp] of solves) {
|
||||
if (now - timestamp > POW_EXPIRY) {
|
||||
solves.delete(key);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
function generateChallenge(clientIp?: string, token?: string): Challenge {
|
||||
let workFactor = workFactors[config.powDifficultyLevel];
|
||||
if (token) {
|
||||
// Challenge difficulty is reduced for token refreshes
|
||||
workFactor = Math.floor(workFactor / 2);
|
||||
}
|
||||
const hashBits = BigInt(argon2Params.ARGON2_HASH_LENGTH) * 8n;
|
||||
const hashMax = 2n ** hashBits;
|
||||
const targetValue = hashMax / BigInt(workFactor);
|
||||
|
||||
return {
|
||||
s: crypto.randomBytes(32).toString("hex"),
|
||||
hl: argon2Params.ARGON2_HASH_LENGTH,
|
||||
t: argon2Params.ARGON2_TIME_COST,
|
||||
m: argon2Params.ARGON2_MEMORY_KB,
|
||||
p: argon2Params.ARGON2_PARALLELISM,
|
||||
d: targetValue.toString() + "n",
|
||||
e: Date.now() + POW_EXPIRY,
|
||||
ip: clientIp,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
function signMessage(msg: any): string {
|
||||
const hmac = crypto.createHmac("sha256", HMAC_KEY);
|
||||
if (typeof msg === "object") {
|
||||
hmac.update(JSON.stringify(msg));
|
||||
} else {
|
||||
hmac.update(msg);
|
||||
}
|
||||
return hmac.digest("hex");
|
||||
}
|
||||
|
||||
async function verifySolution(
|
||||
challenge: Challenge,
|
||||
solution: string,
|
||||
logger: any
|
||||
): Promise<boolean> {
|
||||
logger.info({ solution, challenge }, "Verifying solution");
|
||||
const hash = await argon2.hashRaw(String(solution), {
|
||||
salt: Buffer.from(challenge.s, "hex"),
|
||||
outputLen: challenge.hl,
|
||||
timeCost: challenge.t,
|
||||
memoryCost: challenge.m,
|
||||
parallelism: challenge.p,
|
||||
algorithm: argon2.Algorithm.Argon2id,
|
||||
});
|
||||
const hashStr = hash.toString("hex");
|
||||
const target = BigInt(challenge.d.slice(0, -1));
|
||||
const hashValue = BigInt("0x" + hashStr);
|
||||
const result = hashValue <= target;
|
||||
logger.info({ hashStr, target, hashValue, result }, "Solution verified");
|
||||
return result;
|
||||
}
|
||||
|
||||
function verifyTokenRefreshable(token?: string, logger?: any): boolean {
|
||||
if (!token) {
|
||||
logger?.warn("No token provided for refresh");
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = getUser(token);
|
||||
if (!user) {
|
||||
logger?.warn({ token }, "No user found for token");
|
||||
return false;
|
||||
}
|
||||
if (user.type !== "temporary") {
|
||||
logger?.warn({ token }, "User is not temporary");
|
||||
return false;
|
||||
}
|
||||
logger?.info(
|
||||
{ token, refreshable: user.meta?.refreshable },
|
||||
"Token refreshable"
|
||||
);
|
||||
return user.meta?.refreshable;
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
router.post("/challenge", (req, res) => {
|
||||
const data = challengeSchema.safeParse(req.body);
|
||||
if (!data.success) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid challenge request", details: data.error });
|
||||
return;
|
||||
}
|
||||
const { action, refreshToken, proxyKey } = data.data;
|
||||
if (config.proxyKey && proxyKey !== config.proxyKey) {
|
||||
res.status(400).json({ error: "Invalid proxy password" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "refresh") {
|
||||
if (verifyTokenRefreshable(refreshToken, req.log)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({
|
||||
error: "Not allowed to refresh that token; request a new one",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const challenge = generateChallenge(req.ip, refreshToken);
|
||||
const signature = signMessage(challenge);
|
||||
res.json({ challenge, signature });
|
||||
} else {
|
||||
const challenge = generateChallenge(req.ip);
|
||||
const signature = signMessage(challenge);
|
||||
res.json({ challenge, signature });
|
||||
}
|
||||
});
|
||||
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = verifySchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid verify request", details: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { challenge, signature, solution } = result.data;
|
||||
if (signMessage(challenge) !== signature) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
"Invalid signature; server may have restarted since challenge was issued. Please request a new challenge.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.proxyKey && result.data.proxyKey !== config.proxyKey) {
|
||||
res.status(401).json({ error: "Invalid proxy password" });
|
||||
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",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (solves.has(signature)) {
|
||||
req.log.warn("Attempt to reuse signature");
|
||||
res.status(400).json({ error: "Reused signature" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() > challenge.e) {
|
||||
req.log.warn("Verification took too long");
|
||||
res.status(400).json({ error: "Verification took too long" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (challenge.token && !verifyTokenRefreshable(challenge.token, req.log)) {
|
||||
res.status(400).json({ error: "Not allowed to refresh that usertoken" });
|
||||
return;
|
||||
}
|
||||
|
||||
recentAttempts.set(ip, Date.now());
|
||||
try {
|
||||
const success = await verifySolution(challenge, solution, req.log);
|
||||
if (!success) {
|
||||
req.log.warn("Solution failed verification");
|
||||
res.status(400).json({ error: "Solution failed verification" });
|
||||
return;
|
||||
}
|
||||
solves.set(signature, Date.now());
|
||||
} catch (err) {
|
||||
req.log.error(err, "Error verifying proof-of-work");
|
||||
res.status(500).json({ error: "Internal error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (challenge.token) {
|
||||
const user = getUser(challenge.token);
|
||||
if (user) {
|
||||
user.expiresAt = Date.now() + config.powTokenHours * 60 * 60 * 1000;
|
||||
upsertUser(user);
|
||||
req.log.info(
|
||||
{ token: `...${challenge.token.slice(-5)}` },
|
||||
"Token refreshed"
|
||||
);
|
||||
return res.json({ success: true, token: challenge.token });
|
||||
}
|
||||
} else {
|
||||
const token = createUser({
|
||||
type: "temporary",
|
||||
expiresAt: Date.now() + config.powTokenHours * 60 * 60 * 1000,
|
||||
});
|
||||
upsertUser({
|
||||
token,
|
||||
ip: [ip],
|
||||
maxIps: config.powTokenMaxIps,
|
||||
meta: { refreshable: true },
|
||||
});
|
||||
req.log.info(
|
||||
{ ip, token: `...${token.slice(-5)}` },
|
||||
"Proof-of-work token issued"
|
||||
);
|
||||
return res.json({ success: true, token });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.render("user_request_token", {
|
||||
keyRequired: !!config.proxyKey,
|
||||
difficultyLevel: config.powDifficultyLevel,
|
||||
tokenLifetime: config.powTokenHours,
|
||||
tokenMaxIps: config.powTokenMaxIps,
|
||||
});
|
||||
});
|
||||
|
||||
export { router as powRouter };
|
||||
@@ -0,0 +1,353 @@
|
||||
<noscript>
|
||||
<p style="color: darkorange; background-color: #ffeecc; padding: 1em">
|
||||
JavaScript needs to be enabled to complete verification.
|
||||
</p>
|
||||
</noscript>
|
||||
<style>
|
||||
#captcha-container {
|
||||
max-width: 500px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
#captcha-container {
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
#captcha-container p {
|
||||
padding: 2px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#captcha-container details {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#captcha-container details p {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#captcha-container details li {
|
||||
margin: 2px 0 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#captcha-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#captcha-control button {
|
||||
flex-grow: 1;
|
||||
margin: 10px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
#captcha-progress-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
#captcha-progress-container textarea {
|
||||
margin-top: 5px;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-color: #e0e6f6;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background-color: #76c7c0;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
</style>
|
||||
<div style="display: none" id="captcha-container">
|
||||
<p>
|
||||
Your device needs to perform a verification task before a user token will be issued. This verification 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>
|
||||
This is an anti-abuse measure to slow down automated requests. It requires your device's CPU to find a solution to
|
||||
a cryptographic puzzle, after which a user token will be issued.
|
||||
</p>
|
||||
<p>
|
||||
Your browser may slow down during the verification process. If you want to do something else while waiting, reduce
|
||||
the number of workers to reduce the load on your device's CPU.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>How long does verification take?</summary>
|
||||
<p>
|
||||
The exact time depends on the device you're using and the server's difficulty setting (currently
|
||||
<strong><%= difficultyLevel %></strong>). It could take anywhere from a few seconds to a few minutes.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>How often do I need to do this?</summary>
|
||||
<p>Once you've earned a user token, you can use it for <%= tokenLifetime %> hours before it expires.</p>
|
||||
<p>
|
||||
You can refresh an expired token by returning to this page and completing the verification again, which will be
|
||||
faster than the first time.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Other important information</summary>
|
||||
<ul>
|
||||
<li>Verification must be submitted from the same device and IP address that started the verification.</li>
|
||||
<li>Don't close this tab until verification is complete or you will need to start over.</li>
|
||||
<li>You can pause the task, but verification must be finished <strong>within 30 minutes</strong> of issuance.</li>
|
||||
<li>
|
||||
Up to <strong><%= tokenMaxIps || "unlimited" %></strong> IP addresses can be associated with a user token at
|
||||
once.
|
||||
</li>
|
||||
<li>JavaScript is required to complete verification.</li>
|
||||
<li>If the proxy is restarted, any verification tasks currently in progress will be invalidated.</li>
|
||||
</ul>
|
||||
</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="16" onchange="spawnWorkers()" />
|
||||
</div>
|
||||
<button id="worker-control" onclick="toggleWorker()">Start verification</button>
|
||||
</div>
|
||||
<div id="captcha-progress-container" style="display: none">
|
||||
<label for="captcha-progress-text">Status:</label>
|
||||
<div id="captcha-progress" class="progress-bar">
|
||||
<div class="progress"></div>
|
||||
</div>
|
||||
<textarea disabled id="captcha-progress-text" style="width: 100%; height: 100px; resize: none"></textarea>
|
||||
</div>
|
||||
<div id="captcha-result"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let workers = [];
|
||||
let challenge = null;
|
||||
let signature = null;
|
||||
let solution = null;
|
||||
let totalHashes = 0;
|
||||
let startTime = 0;
|
||||
let lastUpdateTime = 0;
|
||||
let reports = 0;
|
||||
let elapsedTime = 0;
|
||||
let workFactor = 0;
|
||||
let active = false;
|
||||
|
||||
// Safari is all kinds of fucked and throws WASM Memory errors when memory
|
||||
// pressure is high. Batch size and worker count need to be reduced to prevent
|
||||
// this.
|
||||
function isIOSiPadOSWebKit() {
|
||||
const userAgent = navigator.userAgent;
|
||||
const isWebKit = userAgent.includes("Safari") && !userAgent.includes("Chrome") && !userAgent.includes("Android");
|
||||
const isIOS =
|
||||
/iPad|iPhone|iPod/.test(navigator.platform) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
|
||||
return isWebKit && isIOS;
|
||||
}
|
||||
let isMobileWebkit = isIOSiPadOSWebKit();
|
||||
|
||||
function handleWorkerMessage(e) {
|
||||
switch (e.data.type) {
|
||||
case "progress":
|
||||
totalHashes += e.data.hashes;
|
||||
reports++;
|
||||
break;
|
||||
case "started":
|
||||
active = true;
|
||||
document.getElementById("worker-control").textContent = "Pause verification";
|
||||
startTime = Date.now();
|
||||
lastUpdateTime = startTime;
|
||||
document.getElementById("captcha-progress-container").style.display = "block";
|
||||
break;
|
||||
case "paused":
|
||||
active = false;
|
||||
document.getElementById("worker-control").textContent = "Start verification";
|
||||
document.getElementById("workers").disabled = false;
|
||||
break;
|
||||
case "solved":
|
||||
if (solution) {
|
||||
return;
|
||||
}
|
||||
workers.forEach((w) => w.postMessage({ type: "stop" }));
|
||||
active = false;
|
||||
solution = e.data.nonce;
|
||||
document.getElementById("captcha-result").textContent =
|
||||
"Verification completed. Submitting solution for verification...";
|
||||
document.getElementById("captcha-control").style.display = "none";
|
||||
submitVerification();
|
||||
break;
|
||||
case "error":
|
||||
workers.forEach((w) => w.postMessage({ type: "stop" }));
|
||||
active = false;
|
||||
const msg = e.data.error || "An unknown error occurred.";
|
||||
const debug = e.data.debug || "";
|
||||
document.getElementById("captcha-result").innerHTML = `
|
||||
<p style="color:red">Error: ${msg}</p>
|
||||
<pre style="color: red">${debug.stack}</pre>
|
||||
<pre style="color: red">${debug.lastNonce}, ${String(debug.targetValue)}</pre>
|
||||
<p>Refresh the page and try again. Use another device or browser if the problem persists, or lower the number of workers.</p>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
estimateProgress();
|
||||
}
|
||||
|
||||
function loadNewChallenge(c, s) {
|
||||
const btn = document.getElementById("worker-control");
|
||||
btn.textContent = "Start verification";
|
||||
document.getElementById("captcha-container").style.display = "block";
|
||||
document.getElementById("workers").disabled = false;
|
||||
const maxWorkers = isMobileWebkit ? 6 : 16;
|
||||
document.getElementById("workers").value = Math.min(maxWorkers, navigator.hardwareConcurrency || 4).toString();
|
||||
|
||||
challenge = c;
|
||||
signature = s;
|
||||
solution = null;
|
||||
nonce = 0;
|
||||
startTime = 0;
|
||||
lastUpdateTime = 0;
|
||||
elapsedTime = 0;
|
||||
const targetValue = challenge.d.slice(0, -1);
|
||||
const hashLength = challenge.hl;
|
||||
workFactor = Number(BigInt(2) ** BigInt(8 * hashLength) / BigInt(targetValue));
|
||||
spawnWorkers();
|
||||
}
|
||||
|
||||
function spawnWorkers() {
|
||||
for (const worker of workers) {
|
||||
worker.terminate();
|
||||
}
|
||||
workers = [];
|
||||
|
||||
const selectedWorkers = document.getElementById("workers").value;
|
||||
const workerCount = Math.min(16, Math.max(1, parseInt(selectedWorkers)));
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
const worker = new Worker("/res/js/hash-worker.js");
|
||||
worker.onmessage = handleWorkerMessage;
|
||||
workers.push(worker);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWorker() {
|
||||
if (active) {
|
||||
workers.forEach((w) => w.postMessage({ type: "stop" }));
|
||||
} else {
|
||||
const workerCount = workers.length;
|
||||
const hashSpace = BigInt(challenge.hl * 8) ** BigInt(2);
|
||||
const workerSpace = hashSpace / BigInt(workerCount);
|
||||
const alreadyHashed = Math.floor(totalHashes / workerCount);
|
||||
document.getElementById("workers").disabled = true;
|
||||
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
const startNonce = workerSpace * BigInt(i) + BigInt(alreadyHashed);
|
||||
workers[i].postMessage({
|
||||
type: "start",
|
||||
challenge: challenge,
|
||||
signature: signature,
|
||||
nonce: startNonce,
|
||||
isMobileWebkit,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function submitVerification() {
|
||||
if (!solution) {
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
challenge: challenge,
|
||||
signature: signature,
|
||||
solution: String(solution),
|
||||
_csrf: document.querySelector("meta[name=csrf-token]").getAttribute("content"),
|
||||
};
|
||||
|
||||
fetch("/user/captcha/verify", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
document.getElementById("captcha-result").textContent = "Error: " + data.error;
|
||||
} else {
|
||||
const lifetime = document.getElementById("captcha-form").querySelector('input[name="tokenLifetime"]').value;
|
||||
window.localStorage.setItem(
|
||||
"captcha-temp-token",
|
||||
JSON.stringify({
|
||||
token: data.token,
|
||||
expires: Date.now() + lifetime * 3600 * 1000,
|
||||
})
|
||||
);
|
||||
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>Valid until: ${new Date(Date.now() + lifetime * 3600 * 1000).toLocaleString()}</p>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function estimateProgress() {
|
||||
if (reports % workers.length !== 0) {
|
||||
return;
|
||||
}
|
||||
elapsedTime += (Date.now() - lastUpdateTime) / 1000;
|
||||
lastUpdateTime = Date.now();
|
||||
const hashRate = totalHashes / elapsedTime;
|
||||
const expectedTimeRemaining = (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 p = 1 / workFactor;
|
||||
const odds = ((1 - p) ** totalHashes * 100).toFixed(2);
|
||||
|
||||
let note = "";
|
||||
if (odds < 33) {
|
||||
note = " (" + odds + "% odds of no solution yet)";
|
||||
}
|
||||
|
||||
document.getElementById("captcha-progress").style.width = Math.min(progress, 100) + "%";
|
||||
document.getElementById("captcha-progress-text").value = `
|
||||
Average hashes needed: ${workFactor.toLocaleString()}
|
||||
Hashes computed: ${totalHashes.toLocaleString()}${note}
|
||||
Elapsed time: ${formatTime(elapsedTime)}
|
||||
Hash rate: ${hashRate.toFixed(2)} H/s
|
||||
Workers: ${workers.length}${isMobileWebkit ? " (iOS/iPadOS detected)" : ""}
|
||||
${active ? `Approx. time remaining: ${formatTime(expectedTimeRemaining)}` : "Verification task stopped"}`.trim();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<%- include("partials/shared_header", { title: "Request User Token" }) %>
|
||||
|
||||
<style>
|
||||
#request-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
#request-buttons button {
|
||||
margin: 0 10px;
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Request User Token</h1>
|
||||
<p>
|
||||
You can request a temporary user token to use this proxy. The token will be valid for <%= tokenLifetime %> hours.
|
||||
</p>
|
||||
<% if (keyRequired) { %>
|
||||
<div>
|
||||
<p>You need to supply the proxy password to request or refresh a token.</p>
|
||||
<div>
|
||||
<label for="proxy-key">Proxy password:</label>
|
||||
<input type="password" id="proxy-key" />
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<div id="existing-token" style="display: none">
|
||||
<p>
|
||||
It looks like you might have an older temporary user token. You can refresh its expiration by completing a
|
||||
faster verification challenge.
|
||||
</p>
|
||||
<strong id="existing-token-value">Existing token:</strong>
|
||||
</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>
|
||||
<%- include("partials/user_challenge_widget") %>
|
||||
<script>
|
||||
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;
|
||||
}
|
||||
} else if (!token && action === "refresh") {
|
||||
alert("You don't have an existing token to refresh");
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshToken = token && action === "refresh" ? JSON.parse(token).token : undefined;
|
||||
const keyInput = document.getElementById("proxy-key");
|
||||
const proxyKey = (keyInput && keyInput.value) || undefined;
|
||||
localStorage.setItem("captcha-proxy-key", proxyKey);
|
||||
|
||||
fetch("/user/captcha/challenge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, proxyKey, refreshToken, _csrf: "<%= csrfToken %>" }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then(function (data) {
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
const { challenge, signature } = data;
|
||||
loadNewChallenge(challenge, signature);
|
||||
document.getElementById("request-buttons").style.display = "none";
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error(error);
|
||||
alert(`Error getting verification - ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
const existingToken = localStorage.getItem("captcha-temp-token");
|
||||
if (existingToken) {
|
||||
const data = JSON.parse(existingToken);
|
||||
const { token, expires } = data;
|
||||
const expiresDate = new Date(expires);
|
||||
const now = new Date();
|
||||
if (expiresDate > now) {
|
||||
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;
|
||||
} else {
|
||||
localStorage.removeItem("captcha-temp-token");
|
||||
}
|
||||
}
|
||||
|
||||
const proxyKey = localStorage.getItem("captcha-proxy-key");
|
||||
if (proxyKey && document.getElementById("proxy-key")) {
|
||||
document.getElementById("proxy-key").value = proxyKey;
|
||||
}
|
||||
</script>
|
||||
|
||||
<%- include("partials/user_footer") %>
|
||||
Reference in New Issue
Block a user