feat: speculative challenges, jwt challenge tokens, standalone
optimizations, format releases widget@0.1.38; standalone@2.2.0
This commit is contained in:
@@ -4,7 +4,6 @@ about: Open a bug report
|
||||
title: "[bug] "
|
||||
labels: bug
|
||||
assignees: tiagozip
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -16,6 +15,7 @@ issues that don't meet these requirements will be closed automatically.
|
||||
a clear and concise description of what the bug is and how it happened.
|
||||
|
||||
**affects:**
|
||||
|
||||
- [] standalone
|
||||
- [] widget (ui)
|
||||
- [] widget (wasm solver)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: tiagozip
|
||||
|
||||
---
|
||||
|
||||
**what problem would this feature solve, if any?**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Cap is a lightweight, modern open-source CAPTCHA alternative using <a href="https://capjs.js.org/guide/effectiveness.html">SHA-256 proof-of-work</a>. It's fast, private, and extremely simple to integrate.
|
||||
|
||||
<a href="https://capjs.js.org/guide/demo.html"><img src="./assets/captcha.svg" alt="Cap widget" width="250"></a>
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
**[Read the docs](https://capjs.js.org)**, try the [demo](https://capjs.js.org/guide/demo.html) or read the [feature comparison](https://capjs.js.org/guide/alternatives.html)
|
||||
@@ -48,8 +48,7 @@ This project is licensed under the Apache-2.0 License, please see the [LICENSE](
|
||||
|
||||
Copyright ©2025 - present [tiago](https://tiago.zip)
|
||||
|
||||
<!--
|
||||
---
|
||||
## <!--
|
||||
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_icon_blue.svg" width="30px">
|
||||
@@ -60,4 +59,4 @@ Cap's free instance is supported by DigitalOcean for open-source. <a href="https
|
||||
|
||||
---
|
||||
|
||||
[](https://www.bestpractices.dev/projects/9920) [](https://www.jsdelivr.com/package/npm/@cap.js/wasm)
|
||||
[](https://www.bestpractices.dev/projects/9920) [](https://www.jsdelivr.com/package/npm/@cap.js/wasm)
|
||||
|
||||
Vendored
+32
-13
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont",
|
||||
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
|
||||
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
|
||||
font-family:
|
||||
system,
|
||||
-apple-system,
|
||||
"BlinkMacSystemFont",
|
||||
".SFNSText-Regular",
|
||||
"San Francisco",
|
||||
"Roboto",
|
||||
"Segoe UI",
|
||||
"Helvetica Neue",
|
||||
"Lucida Grande",
|
||||
"Ubuntu",
|
||||
"arial",
|
||||
sans-serif;
|
||||
background-color: rgba(20, 20, 25);
|
||||
border: 1px solid rgba(60, 60, 70);
|
||||
border-radius: 12px;
|
||||
@@ -29,7 +39,10 @@
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, background-color 0.2s, border-color 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
background-color 0.2s,
|
||||
border-color 0.2s;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -47,9 +60,19 @@
|
||||
}
|
||||
.source {
|
||||
color: black;
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont",
|
||||
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
|
||||
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
|
||||
font-family:
|
||||
system,
|
||||
-apple-system,
|
||||
"BlinkMacSystemFont",
|
||||
".SFNSText-Regular",
|
||||
"San Francisco",
|
||||
"Roboto",
|
||||
"Segoe UI",
|
||||
"Helvetica Neue",
|
||||
"Lucida Grande",
|
||||
"Ubuntu",
|
||||
"arial",
|
||||
sans-serif;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -73,11 +96,7 @@
|
||||
Trigger floating mode
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://github.com/tiagozip/cap/tree/main/demo"
|
||||
target="_blank"
|
||||
class="source"
|
||||
>
|
||||
<a href="https://github.com/tiagozip/cap/tree/main/demo" target="_blank" class="source">
|
||||
Source code
|
||||
</a>
|
||||
</body>
|
||||
|
||||
Vendored
+32
-13
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont",
|
||||
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
|
||||
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
|
||||
font-family:
|
||||
system,
|
||||
-apple-system,
|
||||
"BlinkMacSystemFont",
|
||||
".SFNSText-Regular",
|
||||
"San Francisco",
|
||||
"Roboto",
|
||||
"Segoe UI",
|
||||
"Helvetica Neue",
|
||||
"Lucida Grande",
|
||||
"Ubuntu",
|
||||
"arial",
|
||||
sans-serif;
|
||||
background-color: rgba(20, 20, 25);
|
||||
border: 1px solid rgba(60, 60, 70);
|
||||
border-radius: 12px;
|
||||
@@ -29,7 +39,10 @@
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, background-color 0.2s, border-color 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
background-color 0.2s,
|
||||
border-color 0.2s;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -47,9 +60,19 @@
|
||||
}
|
||||
.source {
|
||||
color: black;
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont",
|
||||
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
|
||||
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
|
||||
font-family:
|
||||
system,
|
||||
-apple-system,
|
||||
"BlinkMacSystemFont",
|
||||
".SFNSText-Regular",
|
||||
"San Francisco",
|
||||
"Roboto",
|
||||
"Segoe UI",
|
||||
"Helvetica Neue",
|
||||
"Lucida Grande",
|
||||
"Ubuntu",
|
||||
"arial",
|
||||
sans-serif;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -77,11 +100,7 @@
|
||||
Trigger floating mode
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://github.com/tiagozip/cap/tree/main/demo"
|
||||
target="_blank"
|
||||
class="source"
|
||||
>
|
||||
<a href="https://github.com/tiagozip/cap/tree/main/demo" target="_blank" class="source">
|
||||
Source code
|
||||
</a>
|
||||
</body>
|
||||
|
||||
Vendored
+32
-13
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont",
|
||||
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
|
||||
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
|
||||
font-family:
|
||||
system,
|
||||
-apple-system,
|
||||
"BlinkMacSystemFont",
|
||||
".SFNSText-Regular",
|
||||
"San Francisco",
|
||||
"Roboto",
|
||||
"Segoe UI",
|
||||
"Helvetica Neue",
|
||||
"Lucida Grande",
|
||||
"Ubuntu",
|
||||
"arial",
|
||||
sans-serif;
|
||||
background-color: rgba(20, 20, 25);
|
||||
border: 1px solid rgba(60, 60, 70);
|
||||
border-radius: 12px;
|
||||
@@ -29,7 +39,10 @@
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, background-color 0.2s, border-color 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
background-color 0.2s,
|
||||
border-color 0.2s;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -47,9 +60,19 @@
|
||||
}
|
||||
.source {
|
||||
color: black;
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont",
|
||||
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
|
||||
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
|
||||
font-family:
|
||||
system,
|
||||
-apple-system,
|
||||
"BlinkMacSystemFont",
|
||||
".SFNSText-Regular",
|
||||
"San Francisco",
|
||||
"Roboto",
|
||||
"Segoe UI",
|
||||
"Helvetica Neue",
|
||||
"Lucida Grande",
|
||||
"Ubuntu",
|
||||
"arial",
|
||||
sans-serif;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -73,11 +96,7 @@
|
||||
Trigger floating mode
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://github.com/tiagozip/cap/tree/main/demo"
|
||||
target="_blank"
|
||||
class="source"
|
||||
>
|
||||
<a href="https://github.com/tiagozip/cap/tree/main/demo" target="_blank" class="source">
|
||||
Source code
|
||||
</a>
|
||||
</body>
|
||||
|
||||
Vendored
+5
-5
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "solver",
|
||||
"module": "index.js",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "index.js",
|
||||
"dependencies": {
|
||||
"@cap.js/solver": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cap.js/solver": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,18 +27,14 @@ const doBenchmark = async (i, total) => {
|
||||
JSON.stringify({
|
||||
challenge: Array.from({ length: challenges.value }, () => [
|
||||
Array.from(
|
||||
browserCrypto.getRandomValues(
|
||||
new Uint8Array(Math.ceil(challengeSize.value / 2))
|
||||
)
|
||||
browserCrypto.getRandomValues(new Uint8Array(Math.ceil(challengeSize.value / 2))),
|
||||
)
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, challengeSize.value),
|
||||
|
||||
Array.from(
|
||||
browserCrypto.getRandomValues(
|
||||
new Uint8Array(Math.ceil(difficulty.value / 2))
|
||||
)
|
||||
browserCrypto.getRandomValues(new Uint8Array(Math.ceil(difficulty.value / 2))),
|
||||
)
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
@@ -50,7 +46,7 @@ const doBenchmark = async (i, total) => {
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url === "/api/redeem") {
|
||||
@@ -63,7 +59,7 @@ const doBenchmark = async (i, total) => {
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return await window.fetch(url, options);
|
||||
@@ -84,7 +80,7 @@ const doBenchmark = async (i, total) => {
|
||||
progressValue.value = Math.min(100, Math.max(0, overallProgress));
|
||||
|
||||
resultMessage.value = `Running benchmark ${i}/${total}... ${progress.toFixed(
|
||||
2
|
||||
2,
|
||||
)}% (Overall: ${overallProgress.toFixed(2)}%)`;
|
||||
});
|
||||
|
||||
@@ -95,9 +91,7 @@ const doBenchmark = async (i, total) => {
|
||||
|
||||
progressValue.value = (i / total) * 100;
|
||||
|
||||
resultMessage.value = `Benchmark ${i}/${total} completed in ${Math.round(
|
||||
time
|
||||
)} ms.`;
|
||||
resultMessage.value = `Benchmark ${i}/${total} completed in ${Math.round(time)} ms.`;
|
||||
return time;
|
||||
} catch (error) {
|
||||
console.error(`Benchmark ${i}/${total} failed:`, error);
|
||||
@@ -132,7 +126,7 @@ async function runBenchmark() {
|
||||
const averageTime = totalTime / times.length;
|
||||
|
||||
resultMessage.value = `Average benchmark time: ${Math.round(
|
||||
averageTime
|
||||
averageTime,
|
||||
)} ms over ${total} runs.`;
|
||||
|
||||
try {
|
||||
@@ -159,10 +153,7 @@ async function runBenchmark() {
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<div
|
||||
class="benchmark-form"
|
||||
:style="{ '--progress-percent': progressValue }"
|
||||
>
|
||||
<div class="benchmark-form" :style="{ '--progress-percent': progressValue }">
|
||||
<div class="benchmark-field">
|
||||
<label for="difficulty">Challenge difficulty</label>
|
||||
<input
|
||||
@@ -242,12 +233,9 @@ async function runBenchmark() {
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
|
||||
border: 1px solid transparent;
|
||||
background: linear-gradient(var(--vp-c-bg-soft), var(--vp-c-bg-soft))
|
||||
padding-box,
|
||||
conic-gradient(
|
||||
var(--vp-c-brand-1) calc(var(--progress-percent, 0) * 1%),
|
||||
var(--vp-c-divider) 0
|
||||
)
|
||||
background:
|
||||
linear-gradient(var(--vp-c-bg-soft), var(--vp-c-bg-soft)) padding-box,
|
||||
conic-gradient(var(--vp-c-brand-1) calc(var(--progress-percent, 0) * 1%), var(--vp-c-divider) 0)
|
||||
border-box;
|
||||
|
||||
transition: background 0.2s ease;
|
||||
@@ -289,7 +277,10 @@ async function runBenchmark() {
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease, opacity 0.2s ease, filter 0.2s ease;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
filter 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -13,16 +13,12 @@ onMounted(() => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
challenge: Array.from({ length: 50 }, () => [
|
||||
Array.from(
|
||||
browserCrypto.getRandomValues(new Uint8Array(Math.ceil(32 / 2)))
|
||||
)
|
||||
Array.from(browserCrypto.getRandomValues(new Uint8Array(Math.ceil(32 / 2))))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, 32),
|
||||
|
||||
Array.from(
|
||||
browserCrypto.getRandomValues(new Uint8Array(Math.ceil(4 / 2)))
|
||||
)
|
||||
Array.from(browserCrypto.getRandomValues(new Uint8Array(Math.ceil(4 / 2))))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, 4),
|
||||
@@ -33,7 +29,7 @@ onMounted(() => {
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url === "/api/redeem") {
|
||||
@@ -50,7 +46,7 @@ onMounted(() => {
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return await window.fetch(url, options);
|
||||
|
||||
@@ -8,14 +8,14 @@ import "./style.css";
|
||||
|
||||
/** @type {import('vitepress').Theme} */
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: () => {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
// https://vitepress.dev/guide/extending-default-theme#layout-slots
|
||||
});
|
||||
},
|
||||
enhanceApp({ app }) {
|
||||
app.component("Benchmark", Benchmark);
|
||||
app.component("Demo", Demo);
|
||||
},
|
||||
extends: DefaultTheme,
|
||||
Layout: () => {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
// https://vitepress.dev/guide/extending-default-theme#layout-slots
|
||||
});
|
||||
},
|
||||
enhanceApp({ app }) {
|
||||
app.component("Benchmark", Benchmark);
|
||||
app.component("Demo", Demo);
|
||||
},
|
||||
};
|
||||
|
||||
Vendored
+6
-13
@@ -97,11 +97,7 @@
|
||||
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(
|
||||
280deg,
|
||||
#38c2fe,
|
||||
#0279db
|
||||
);
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(280deg, #38c2fe, #0279db);
|
||||
/*
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(
|
||||
120deg,
|
||||
@@ -109,11 +105,7 @@
|
||||
#41d1ff
|
||||
);
|
||||
*/
|
||||
--vp-home-hero-image-background-image: -webkit-linear-gradient(
|
||||
-45deg,
|
||||
#38c2fe52,
|
||||
#0279db33
|
||||
);
|
||||
--vp-home-hero-image-background-image: -webkit-linear-gradient(-45deg, #38c2fe52, #0279db33);
|
||||
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
@@ -166,8 +158,8 @@ img {
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-font-family-mono: mono, ui-monospace, "Menlo", "Monaco", "Consolas",
|
||||
"Liberation Mono", "Courier New", monospace !important;
|
||||
--vp-font-family-mono:
|
||||
mono, ui-monospace, "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace !important;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -209,6 +201,7 @@ img {
|
||||
scrollbar-color: hsla(255 0 255 / 15%) #1b1b1f;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,10 @@ Cap is free, self-hosted, and open-source, while GeeTest is a paid service. Cap
|
||||
|
||||
Arkose's CAPTCHA is known for being hard, slow, and annoying for humans to solve. It is also a paid, closed-source service mostly available to big enterprises.
|
||||
|
||||
They also only operate in the US, Canada, Argentina, India, Israel and a small set of other countries, excluding many EU countries.
|
||||
|
||||
### Anubis
|
||||
|
||||
While Anubis is a great scraper deterrent and uses the same proof-of-work concept as Cap, it uses a low difficulty by default (which is easier for bots to solve) and does not provide a standalone CAPTCHA server.
|
||||
|
||||
Cap also implements dynamic instrumentation challenges, which make it harder for bots to finish the process after solving PoW.
|
||||
Cap also implements dynamic instrumentation challenges, which make it harder for bots to finish the process after solving PoW.
|
||||
|
||||
@@ -17,4 +17,4 @@ A challenge difficulty of 4 with 50 challenges was used to run these benchmarks.
|
||||
| Mid-range | Google Pixel 7 | 1.027s | - |
|
||||
| Mid-range | iPad (9th gen) | – | 1.401s |
|
||||
| High-end | Google Pixel 9 | 0.894s | – |
|
||||
| High-end | M3 Macbook | 0.412s | 0.423s |
|
||||
| High-end | M3 Macbook | 0.412s | 0.423s |
|
||||
|
||||
@@ -16,4 +16,4 @@ Cap's goal is to make automated abuse expensive while keeping the experience fas
|
||||
|
||||
Imagine sending 10,000 spam messages costs $1, potentially earning $10 – a profitable venture. If Cap increases the computational cost so that sending those messages now costs $100, the spammer loses $90. This eliminates the financial incentive.
|
||||
|
||||
Cap's proof-of-work is heavily inspired by [Hashcash](https://www.researchgate.net/publication/2482110_Hashcash_-_A_Denial_of_Service_Counter-Measure).
|
||||
Cap's proof-of-work is heavily inspired by [Hashcash](https://www.researchgate.net/publication/2482110_Hashcash_-_A_Denial_of_Service_Counter-Measure).
|
||||
|
||||
@@ -25,11 +25,12 @@ Or from the standalone server:
|
||||
|
||||
```html
|
||||
<script src="https://<server url>/assets/widget.js"></script>
|
||||
<script src="https://<server url>/assets/floating.js"></script> <!-- [!code ++] -->
|
||||
<script src="https://<server url>/assets/floating.js"></script>
|
||||
<!-- [!code ++] -->
|
||||
```
|
||||
|
||||
The following attributes are supported:
|
||||
|
||||
- `data-cap-floating`: The CSS selector of the `cap-widget` element you want to use.
|
||||
- `data-cap-floating-position`: The position of the floating widget. Can be `top` or `bottom`.
|
||||
- `data-cap-floating-offset`: The offset of the floating widget from the trigger element.
|
||||
- `data-cap-floating-offset`: The offset of the floating widget from the trigger element.
|
||||
|
||||
+11
-4
@@ -4,7 +4,7 @@ You can use `new Cap({ ... })` in your client-side JavaScript to create a new Ca
|
||||
|
||||
```js
|
||||
const cap = new Cap({
|
||||
apiEndpoint: "/api/"
|
||||
apiEndpoint: "/api/",
|
||||
});
|
||||
const solution = await cap.solve();
|
||||
|
||||
@@ -15,10 +15,11 @@ You can also set up [event listeners](widget.md#supported-events):
|
||||
|
||||
```js
|
||||
const cap = new Cap({
|
||||
apiEndpoint: "/api/"
|
||||
apiEndpoint: "/api/",
|
||||
});
|
||||
|
||||
cap.addEventListener("progress", (event) => { // [!code focus]
|
||||
cap.addEventListener("progress", (event) => {
|
||||
// [!code focus]
|
||||
console.log(`Solving... ${event.detail.progress}% done`);
|
||||
});
|
||||
```
|
||||
@@ -30,9 +31,11 @@ Behind the scenes, Cap creates a hidden `cap-widget` element and uses it to solv
|
||||
The following methods are supported:
|
||||
|
||||
#### `new Cap({ ... })`
|
||||
|
||||
Creates a new Cap instance. If a 2nd argument is provided, it will use that element instead of creating a new one in memory.
|
||||
|
||||
**Arguments**
|
||||
|
||||
```json
|
||||
{
|
||||
apiEndpoint: ..., // api endpoint, similar to the widget `data-cap-api-endpoint` attribute
|
||||
@@ -41,15 +44,19 @@ Creates a new Cap instance. If a 2nd argument is provided, it will use that elem
|
||||
```
|
||||
|
||||
#### `cap.solve()`
|
||||
|
||||
Requests and solves a challenge.
|
||||
|
||||
**Output:** `{ token }`
|
||||
|
||||
#### `cap.token`
|
||||
|
||||
Returns the token from the latest solve
|
||||
|
||||
#### `cap.reset()`
|
||||
|
||||
Resets `cap.token`
|
||||
|
||||
#### `cap.addEventListener(..., function () { ... })`
|
||||
Listens for an event for the cap widget. See [supported events](widget.md#supported-events)
|
||||
|
||||
Listens for an event for the cap widget. See [supported events](widget.md#supported-events)
|
||||
|
||||
@@ -20,12 +20,9 @@ new Elysia()
|
||||
token_validity_hours: 32, // how long the token is valid for
|
||||
tokens_store_path: ".data/tokensList.json",
|
||||
token_size: 16, // token size in bytes
|
||||
verification_template_path: join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"./index.html"
|
||||
),
|
||||
verification_template_path: join(dirname(fileURLToPath(import.meta.url)), "./index.html"),
|
||||
scoping: "scoped", // 'global' | 'scoped'
|
||||
})
|
||||
}),
|
||||
)
|
||||
.get("/", () => "Hello Elysia!")
|
||||
.listen(3000);
|
||||
|
||||
@@ -30,7 +30,7 @@ app.use(
|
||||
token_size: 16,
|
||||
verification_template_path: join(__dirname, "./index.html"),
|
||||
*/
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
|
||||
@@ -21,11 +21,8 @@ app.use(
|
||||
token_validity_hours: 32, // how long the token is valid for
|
||||
tokens_store_path: ".data/tokensList.json",
|
||||
token_size: 16, // token size in bytes
|
||||
verification_template_path: join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"./index.html"
|
||||
),
|
||||
})
|
||||
verification_template_path: join(dirname(fileURLToPath(import.meta.url)), "./index.html"),
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/", (c) => c.text("Hello Hono!"));
|
||||
|
||||
@@ -66,9 +66,7 @@ const cap = new Cap({
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
return row
|
||||
? { challenge: row.data, expires: Number(row.expires) }
|
||||
: null;
|
||||
return row ? { challenge: row.data, expires: Number(row.expires) } : null;
|
||||
},
|
||||
|
||||
delete: async (token) => {
|
||||
@@ -237,7 +235,7 @@ if (!success) throw new Error("invalid cap token");
|
||||
"state": {
|
||||
"challengesList": {},
|
||||
"tokensList": {}
|
||||
},
|
||||
}
|
||||
|
||||
// deprecated:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Standalone server
|
||||
|
||||
> [!WARNING]
|
||||
> **You are viewing the docs for a legacy version of Cap's Standalone server.** It is no longer actively maintained and will only receive critical security updates. While v1 is still tagged and pullable, certain commands below might not work anymore.
|
||||
> **You are viewing the docs for a legacy version of Cap's Standalone server.** It is no longer actively maintained and will only receive critical security updates. While v1 is still tagged and pullable, certain commands below might not work anymore.
|
||||
> [New docs](standalone/index.md)
|
||||
|
||||
## Installation
|
||||
@@ -62,9 +62,7 @@ Make sure to replace:
|
||||
Example:
|
||||
|
||||
```html
|
||||
<cap-widget
|
||||
data-cap-api-endpoint="https://cap.example.com/d9256640cb53/api/"
|
||||
></cap-widget>
|
||||
<cap-widget data-cap-api-endpoint="https://cap.example.com/d9256640cb53/api/"></cap-widget>
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
|
||||
@@ -29,12 +29,12 @@ volumes:
|
||||
|
||||
::: tip Tips
|
||||
|
||||
* Make sure to add an admin key to log into the web UI. It should be at least 12 characters long.
|
||||
- Make sure to add an admin key to log into the web UI. It should be at least 12 characters long.
|
||||
|
||||
* If your port 3000 is already in use, feel free to change it to something else.
|
||||
- If your port 3000 is already in use, feel free to change it to something else.
|
||||
|
||||
* If you're having trouble accessing the dashboard, add `network_mode: "host"` under the Cap service.
|
||||
:::
|
||||
- If you're having trouble accessing the dashboard, add `network_mode: "host"` under the Cap service.
|
||||
:::
|
||||
|
||||
Start the container:
|
||||
|
||||
@@ -66,9 +66,7 @@ Make sure to replace:
|
||||
Example:
|
||||
|
||||
```html
|
||||
<cap-widget
|
||||
data-cap-api-endpoint="https://cap.example.com/d9256640cb53/"
|
||||
></cap-widget>
|
||||
<cap-widget data-cap-api-endpoint="https://cap.example.com/d9256640cb53/"></cap-widget>
|
||||
```
|
||||
|
||||
### Server-side
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Installation
|
||||
|
||||
This section has moved to [Standalone](/guide/standalone/index.md).
|
||||
This section has moved to [Standalone](/guide/standalone/index.md).
|
||||
|
||||
@@ -68,4 +68,4 @@ Error messages are redacted by default and instead logged to the console. To dis
|
||||
|
||||
Cap standalone supports our JavaScript instrumentation challenges to defeat proof-of-work solvers, along with options to try stop headless browsers from solving them.
|
||||
|
||||
You can turn on instrumentation challenges by toggling them on in your site key config. To block headless browsers, turn on "Attempt to block headless browsers"
|
||||
You can turn on instrumentation challenges by toggling them on in your site key config. To block headless browsers, turn on "Attempt to block headless browsers"
|
||||
|
||||
@@ -64,4 +64,4 @@ AI agent browsers are also blocked — make sure you're using a standard browser
|
||||
|
||||
---
|
||||
|
||||
If you've tried all of the above and still can't get through, consider reaching out to the site owner for further assistance. You may also alternatively [file an issue](https://github.com/tiagozip/cap/issues).
|
||||
If you've tried all of the above and still can't get through, consider reaching out to the site owner for further assistance. You may also alternatively [file an issue](https://github.com/tiagozip/cap/issues).
|
||||
|
||||
+20
-18
@@ -111,24 +111,6 @@ You can change the text on each label of the widget by setting the `data-cap-i18
|
||||
|
||||
## Customizing the widget
|
||||
|
||||
### fetch function
|
||||
|
||||
You can override the default browser fetch implementation by setting `window.CAP_CUSTOM_FETCH` to a custom function. This function will receive the URL and options as arguments and should return a promise that resolves to the response.
|
||||
|
||||
```js
|
||||
window.CAP_CUSTOM_FETCH = function (url, options) {
|
||||
// … add your custom fetch implementation
|
||||
|
||||
return fetch(url, options);
|
||||
};
|
||||
```
|
||||
|
||||
## WASM URL
|
||||
|
||||
You can override the default WASM URL by setting `window.CAP_CUSTOM_WASM_URL` to a custom URL. This URL will be used to load the WASM module. This defaults to `https://cdn.jsdelivr.net/npm/@cap.js/wasm@0.0.4/browser/cap_wasm.min.js`
|
||||
|
||||
## Customizing
|
||||
|
||||
You can fully change how the widget looks by setting various CSS variables on the `cap-widget` element. The following CSS variables are supported:
|
||||
|
||||
```css
|
||||
@@ -159,6 +141,26 @@ cap-widget {
|
||||
|
||||
<small>Note: you _can_ technically hide the "Cap" label, but we kindly ask you to leave it visible. It's unobtrusive, doesn't track users, lightweight, and helps Cap grow.</small>
|
||||
|
||||
### fetch function
|
||||
|
||||
You can override the default browser fetch implementation by setting `window.CAP_CUSTOM_FETCH` to a custom function. This function will receive the URL and options as arguments and should return a promise that resolves to the response.
|
||||
|
||||
```js
|
||||
window.CAP_CUSTOM_FETCH = function (url, options) {
|
||||
// … add your custom fetch implementation
|
||||
|
||||
return fetch(url, options);
|
||||
};
|
||||
```
|
||||
|
||||
### Speculative challenges
|
||||
|
||||
By default, Cap will sometimes automatically generate, solve and redeem a challenge while the user is active to make solving significantly faster. This is intentionally much slower than the normal solving speed and is designed to not impact user experience while still providing a significant speed boost.
|
||||
|
||||
### WASM URL
|
||||
|
||||
You can override the default WASM URL by setting `window.CAP_CUSTOM_WASM_URL` to a custom URL. This URL will be used to load the WASM module. This defaults to `https://cdn.jsdelivr.net/npm/@cap.js/wasm@0.0.4/browser/cap_wasm.min.js`
|
||||
|
||||
## Types
|
||||
|
||||
Cap's widget is fully typed. You can find the type definitions in the `cap.d.ts` file.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
By the way, this is a more technical explanation of how Cap works. If you're looking for a more general overview, check out the [Effectiveness](./effectiveness.md) page.
|
||||
|
||||
***
|
||||
---
|
||||
|
||||
1. When Cap is initialized, it automatically registers a custom element for the widget in the browser
|
||||
2. The widget creates a shadow DOM and appends all necessary elements to it
|
||||
|
||||
+1
-1
@@ -45,4 +45,4 @@ features:
|
||||
- icon: 🌳
|
||||
title: Open-source
|
||||
details: Completely free & open-source under the Apache 2.0 license
|
||||
---
|
||||
---
|
||||
|
||||
+4
-4
@@ -5,13 +5,13 @@
|
||||
"build": "vitepress build",
|
||||
"preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cap.js/widget": "^0.1.18",
|
||||
"vitepress": "^1.6.3",
|
||||
"vitepress-plugin-llms": "^1.1.1",
|
||||
"vitepress-plugin-mermaid": "^2.0.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vue": "^3.5.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
Server-side challenge generator and verifier for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.
|
||||
|
||||
**[Learn more](https://github.com/tiagozip/cap)**
|
||||
**[Learn more](https://github.com/tiagozip/cap)**
|
||||
|
||||
Vendored
+232
-215
@@ -4,111 +4,128 @@ export = Cap;
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
declare class Cap extends EventEmitter<any> {
|
||||
/**
|
||||
* Creates a new Cap instance
|
||||
* @param {Partial<CapConfig>} [configObj] - Configuration object
|
||||
*/
|
||||
constructor(configObj?: Partial<CapConfig>);
|
||||
/** @type {Promise<void>|null} */
|
||||
_cleanupPromise: Promise<void> | null;
|
||||
/** @type {number} */
|
||||
_lastCleanup: number;
|
||||
/** @type {CapConfig} */
|
||||
config: CapConfig;
|
||||
/**
|
||||
* Performs cleanup if enough time has passed since last cleanup
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _lazyCleanup;
|
||||
/**
|
||||
* Retrieves challenge data from storage
|
||||
* @private
|
||||
* @param {string} token - Challenge token
|
||||
* @returns {Promise<ChallengeData|null>} Challenge data or null if not found
|
||||
*/
|
||||
private _getChallenge;
|
||||
/**
|
||||
* Deletes challenge from storage
|
||||
* @private
|
||||
* @param {string} token - Challenge token
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _deleteChallenge;
|
||||
/**
|
||||
* Generates a new challenge
|
||||
* @param {ChallengeConfig} [conf] - Challenge configuration
|
||||
* @returns {Promise<{ challenge: {c: number, s: number, d: number}, token?: string, expires: number }>} Challenge data
|
||||
*/
|
||||
createChallenge(conf?: ChallengeConfig): Promise<{
|
||||
challenge: {
|
||||
c: number;
|
||||
s: number;
|
||||
d: number;
|
||||
};
|
||||
token?: string;
|
||||
expires: number;
|
||||
}>;
|
||||
/**
|
||||
* Redeems a challenge solution in exchange for a token
|
||||
* @param {Solution} param0 - Challenge solution data
|
||||
* @returns {Promise<{success: boolean, message?: string, token?: string, expires?: number}>}
|
||||
*/
|
||||
redeemChallenge({ token, solutions }: Solution): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
token?: string;
|
||||
expires?: number;
|
||||
}>;
|
||||
/**
|
||||
* Retrieves token expiration from storage
|
||||
* @private
|
||||
* @param {string} tokenKey - Token key
|
||||
* @returns {Promise<number|null>} Token expiration or null if not found
|
||||
*/
|
||||
private _getToken;
|
||||
/**
|
||||
* Deletes token from storage
|
||||
* @private
|
||||
* @param {string} tokenKey - Token key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _deleteToken;
|
||||
/**
|
||||
* Validates a token
|
||||
* @param {string} token - The token to validate
|
||||
* @param {TokenConfig} [conf] - Validation configuration
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
*/
|
||||
validateToken(token: string, conf?: TokenConfig): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
/**
|
||||
* Loads tokens from the storage file
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _loadTokens;
|
||||
/**
|
||||
* Removes expired tokens and challenges from memory and storage
|
||||
* @private
|
||||
* @returns {Promise<boolean>} - True if any tokens were changed/removed
|
||||
*/
|
||||
private _cleanExpiredTokens;
|
||||
/**
|
||||
* Waits for the tokens list to be initialized
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _waitForTokensList;
|
||||
/**
|
||||
* Cleans up expired tokens and syncs state
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
cleanup(): Promise<void>;
|
||||
/**
|
||||
* Creates a new Cap instance
|
||||
* @param {Partial<CapConfig>} [configObj] - Configuration object
|
||||
*/
|
||||
constructor(configObj?: Partial<CapConfig>);
|
||||
/** @type {Promise<void>|null} */
|
||||
_cleanupPromise: Promise<void> | null;
|
||||
/** @type {number} */
|
||||
_lastCleanup: number;
|
||||
/** @type {CapConfig} */
|
||||
config: CapConfig;
|
||||
/**
|
||||
* Performs cleanup if enough time has passed since last cleanup
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _lazyCleanup;
|
||||
/**
|
||||
* Retrieves challenge data from storage
|
||||
* @private
|
||||
* @param {string} token - Challenge token
|
||||
* @returns {Promise<ChallengeData|null>} Challenge data or null if not found
|
||||
*/
|
||||
private _getChallenge;
|
||||
/**
|
||||
* Deletes challenge from storage
|
||||
* @private
|
||||
* @param {string} token - Challenge token
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _deleteChallenge;
|
||||
/**
|
||||
* Generates a new challenge
|
||||
* @param {ChallengeConfig} [conf] - Challenge configuration
|
||||
* @returns {Promise<{ challenge: {c: number, s: number, d: number}, token?: string, expires: number }>} Challenge data
|
||||
*/
|
||||
createChallenge(conf?: ChallengeConfig): Promise<{
|
||||
challenge: {
|
||||
c: number;
|
||||
s: number;
|
||||
d: number;
|
||||
};
|
||||
token?: string;
|
||||
expires: number;
|
||||
}>;
|
||||
/**
|
||||
* Redeems a challenge solution in exchange for a token
|
||||
* @param {Solution} param0 - Challenge solution data
|
||||
* @returns {Promise<{success: boolean, message?: string, token?: string, expires?: number}>}
|
||||
*/
|
||||
redeemChallenge({ token, solutions }: Solution): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
token?: string;
|
||||
expires?: number;
|
||||
}>;
|
||||
/**
|
||||
* Retrieves token expiration from storage
|
||||
* @private
|
||||
* @param {string} tokenKey - Token key
|
||||
* @returns {Promise<number|null>} Token expiration or null if not found
|
||||
*/
|
||||
private _getToken;
|
||||
/**
|
||||
* Deletes token from storage
|
||||
* @private
|
||||
* @param {string} tokenKey - Token key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _deleteToken;
|
||||
/**
|
||||
* Validates a token
|
||||
* @param {string} token - The token to validate
|
||||
* @param {TokenConfig} [conf] - Validation configuration
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
*/
|
||||
validateToken(
|
||||
token: string,
|
||||
conf?: TokenConfig,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
/**
|
||||
* Loads tokens from the storage file
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _loadTokens;
|
||||
/**
|
||||
* Removes expired tokens and challenges from memory and storage
|
||||
* @private
|
||||
* @returns {Promise<boolean>} - True if any tokens were changed/removed
|
||||
*/
|
||||
private _cleanExpiredTokens;
|
||||
/**
|
||||
* Waits for the tokens list to be initialized
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private _waitForTokensList;
|
||||
/**
|
||||
* Cleans up expired tokens and syncs state
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
declare namespace Cap {
|
||||
export { Crypto, FsPromises, PathLike, ChallengeTuple, ChallengeData, ChallengeState, ChallengeConfig, TokenConfig, Solution, ChallengeStorage, TokenStorage, StorageHooks, CapConfig };
|
||||
export {
|
||||
Crypto,
|
||||
FsPromises,
|
||||
PathLike,
|
||||
ChallengeTuple,
|
||||
ChallengeData,
|
||||
ChallengeState,
|
||||
ChallengeConfig,
|
||||
TokenConfig,
|
||||
Solution,
|
||||
ChallengeStorage,
|
||||
TokenStorage,
|
||||
StorageHooks,
|
||||
CapConfig,
|
||||
};
|
||||
}
|
||||
import { EventEmitter } from "node:events";
|
||||
type Crypto = typeof import("node:crypto");
|
||||
@@ -116,132 +133,132 @@ type FsPromises = typeof import("node:fs/promises");
|
||||
type PathLike = import("fs").PathLike;
|
||||
type ChallengeTuple = [string, string];
|
||||
type ChallengeData = {
|
||||
/**
|
||||
* - Challenge configuration object
|
||||
*/
|
||||
challenge: {
|
||||
c: number;
|
||||
s: number;
|
||||
d: number;
|
||||
};
|
||||
/**
|
||||
* - Expiration timestamp
|
||||
*/
|
||||
expires: number;
|
||||
/**
|
||||
* - Challenge configuration object
|
||||
*/
|
||||
challenge: {
|
||||
c: number;
|
||||
s: number;
|
||||
d: number;
|
||||
};
|
||||
/**
|
||||
* - Expiration timestamp
|
||||
*/
|
||||
expires: number;
|
||||
};
|
||||
type ChallengeState = {
|
||||
/**
|
||||
* - Map of challenge tokens to challenge data
|
||||
*/
|
||||
challengesList: Record<string, ChallengeData>;
|
||||
/**
|
||||
* - Map of token hashes to expiration timestamps
|
||||
*/
|
||||
tokensList: Record<string, number>;
|
||||
/**
|
||||
* - Map of challenge tokens to challenge data
|
||||
*/
|
||||
challengesList: Record<string, ChallengeData>;
|
||||
/**
|
||||
* - Map of token hashes to expiration timestamps
|
||||
*/
|
||||
tokensList: Record<string, number>;
|
||||
};
|
||||
type ChallengeConfig = {
|
||||
/**
|
||||
* - Number of challenges to generate
|
||||
*/
|
||||
challengeCount?: number | undefined;
|
||||
/**
|
||||
* - Size of each challenge in bytes
|
||||
*/
|
||||
challengeSize?: number | undefined;
|
||||
/**
|
||||
* - Difficulty level of the challenge
|
||||
*/
|
||||
challengeDifficulty?: number | undefined;
|
||||
/**
|
||||
* - Time in milliseconds until the challenge expires
|
||||
*/
|
||||
expiresMs?: number | undefined;
|
||||
/**
|
||||
* - Whether to store the challenge in memory
|
||||
*/
|
||||
store?: boolean | undefined;
|
||||
/**
|
||||
* - Number of challenges to generate
|
||||
*/
|
||||
challengeCount?: number | undefined;
|
||||
/**
|
||||
* - Size of each challenge in bytes
|
||||
*/
|
||||
challengeSize?: number | undefined;
|
||||
/**
|
||||
* - Difficulty level of the challenge
|
||||
*/
|
||||
challengeDifficulty?: number | undefined;
|
||||
/**
|
||||
* - Time in milliseconds until the challenge expires
|
||||
*/
|
||||
expiresMs?: number | undefined;
|
||||
/**
|
||||
* - Whether to store the challenge in memory
|
||||
*/
|
||||
store?: boolean | undefined;
|
||||
};
|
||||
type TokenConfig = {
|
||||
/**
|
||||
* - Whether to keep the token after validation
|
||||
*/
|
||||
keepToken?: boolean | undefined;
|
||||
/**
|
||||
* - Whether to keep the token after validation
|
||||
*/
|
||||
keepToken?: boolean | undefined;
|
||||
};
|
||||
type Solution = {
|
||||
/**
|
||||
* - Challenge token
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* - Array of challenge solutions
|
||||
*/
|
||||
solutions: number[];
|
||||
/**
|
||||
* - Challenge token
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* - Array of challenge solutions
|
||||
*/
|
||||
solutions: number[];
|
||||
};
|
||||
type ChallengeStorage = {
|
||||
/**
|
||||
* - Store challenge data
|
||||
*/
|
||||
store: (arg0: string, arg1: ChallengeData) => Promise<void>;
|
||||
/**
|
||||
* - Retrieve challenge data
|
||||
*/
|
||||
read: (arg0: string) => Promise<ChallengeData | null>;
|
||||
/**
|
||||
* - Delete challenge data
|
||||
*/
|
||||
delete: (arg0: string) => Promise<void>;
|
||||
/**
|
||||
* - Delete expired challenge tokens
|
||||
*/
|
||||
deleteExpired: () => Promise<void>;
|
||||
/**
|
||||
* - Store challenge data
|
||||
*/
|
||||
store: (arg0: string, arg1: ChallengeData) => Promise<void>;
|
||||
/**
|
||||
* - Retrieve challenge data
|
||||
*/
|
||||
read: (arg0: string) => Promise<ChallengeData | null>;
|
||||
/**
|
||||
* - Delete challenge data
|
||||
*/
|
||||
delete: (arg0: string) => Promise<void>;
|
||||
/**
|
||||
* - Delete expired challenge tokens
|
||||
*/
|
||||
deleteExpired: () => Promise<void>;
|
||||
};
|
||||
type TokenStorage = {
|
||||
/**
|
||||
* - Store token with expiration
|
||||
*/
|
||||
store: (arg0: string, arg1: number) => Promise<void>;
|
||||
/**
|
||||
* - Retrieve token expiration
|
||||
*/
|
||||
get: (arg0: string) => Promise<number | null>;
|
||||
/**
|
||||
* - Delete token
|
||||
*/
|
||||
delete: (arg0: string) => Promise<void>;
|
||||
/**
|
||||
* - Delete expired token keys
|
||||
*/
|
||||
deleteExpired: () => Promise<void>;
|
||||
/**
|
||||
* - Store token with expiration
|
||||
*/
|
||||
store: (arg0: string, arg1: number) => Promise<void>;
|
||||
/**
|
||||
* - Retrieve token expiration
|
||||
*/
|
||||
get: (arg0: string) => Promise<number | null>;
|
||||
/**
|
||||
* - Delete token
|
||||
*/
|
||||
delete: (arg0: string) => Promise<void>;
|
||||
/**
|
||||
* - Delete expired token keys
|
||||
*/
|
||||
deleteExpired: () => Promise<void>;
|
||||
};
|
||||
type StorageHooks = {
|
||||
/**
|
||||
* - Challenge storage hooks
|
||||
*/
|
||||
challenges?: ChallengeStorage | undefined;
|
||||
/**
|
||||
* - Token storage hooks
|
||||
*/
|
||||
tokens?: TokenStorage | undefined;
|
||||
/**
|
||||
* - Challenge storage hooks
|
||||
*/
|
||||
challenges?: ChallengeStorage | undefined;
|
||||
/**
|
||||
* - Token storage hooks
|
||||
*/
|
||||
tokens?: TokenStorage | undefined;
|
||||
};
|
||||
type CapConfig = {
|
||||
/**
|
||||
* - Path to store the tokens file
|
||||
*/
|
||||
tokens_store_path: string;
|
||||
/**
|
||||
* - State configuration
|
||||
*/
|
||||
state: ChallengeState;
|
||||
/**
|
||||
* - Whether to disable the state file
|
||||
*/
|
||||
noFSState: boolean;
|
||||
/**
|
||||
* - Whether to disable automatic cleanup of expired tokens and challenges
|
||||
*/
|
||||
disableAutoCleanup?: boolean | undefined;
|
||||
/**
|
||||
* - Custom storage hooks for challenges and tokens
|
||||
*/
|
||||
storage?: StorageHooks | undefined;
|
||||
/**
|
||||
* - Path to store the tokens file
|
||||
*/
|
||||
tokens_store_path: string;
|
||||
/**
|
||||
* - State configuration
|
||||
*/
|
||||
state: ChallengeState;
|
||||
/**
|
||||
* - Whether to disable the state file
|
||||
*/
|
||||
noFSState: boolean;
|
||||
/**
|
||||
* - Whether to disable automatic cleanup of expired tokens and challenges
|
||||
*/
|
||||
disableAutoCleanup?: boolean | undefined;
|
||||
/**
|
||||
* - Custom storage hooks for challenges and tokens
|
||||
*/
|
||||
storage?: StorageHooks | undefined;
|
||||
};
|
||||
|
||||
+12
-25
@@ -116,10 +116,7 @@ async function randomHex(bytesCount) {
|
||||
async function sha256(str) {
|
||||
if (crypto.webcrypto?.subtle) {
|
||||
const enc = new TextEncoder();
|
||||
const hash = await crypto.webcrypto.subtle.digest(
|
||||
"SHA-256",
|
||||
enc.encode(str)
|
||||
);
|
||||
const hash = await crypto.webcrypto.subtle.digest("SHA-256", enc.encode(str));
|
||||
return Buffer.from(hash).toString("hex");
|
||||
}
|
||||
|
||||
@@ -141,8 +138,7 @@ function prng(seed, length) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash +=
|
||||
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
@@ -332,7 +328,7 @@ class Cap extends EventEmitter {
|
||||
challenges.map(([salt, target], i) => {
|
||||
if (typeof solutions[i] !== "number") return null;
|
||||
return sha256(salt + solutions[i]).then((h) => [h, target]);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const isValid = hashes.every((pair) => pair?.[0].startsWith(pair[1]));
|
||||
@@ -356,7 +352,7 @@ class Cap extends EventEmitter {
|
||||
await fs.writeFile(
|
||||
this.config.tokens_store_path,
|
||||
JSON.stringify(this.config.state.tokensList),
|
||||
"utf8"
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -394,7 +390,7 @@ class Cap extends EventEmitter {
|
||||
await fs.writeFile(
|
||||
this.config.tokens_store_path,
|
||||
JSON.stringify(this.config.state.tokensList),
|
||||
"utf8"
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -445,10 +441,7 @@ class Cap extends EventEmitter {
|
||||
if (this.config.noFSState || this.config.storage?.tokens) return;
|
||||
|
||||
try {
|
||||
const dirPath = this.config.tokens_store_path
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.join("/");
|
||||
const dirPath = this.config.tokens_store_path.split("/").slice(0, -1).join("/");
|
||||
|
||||
if (dirPath) {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
@@ -465,9 +458,7 @@ class Cap extends EventEmitter {
|
||||
this.config.state.tokensList = {};
|
||||
}
|
||||
} catch {
|
||||
console.warn(
|
||||
`[cap] Couldn't load or write tokens file, using empty state`
|
||||
);
|
||||
console.warn(`[cap] Couldn't load or write tokens file, using empty state`);
|
||||
this.config.state.tokensList = {};
|
||||
}
|
||||
}
|
||||
@@ -491,11 +482,11 @@ class Cap extends EventEmitter {
|
||||
await Promise.all(
|
||||
expired.map(async (k) => {
|
||||
await this._deleteChallenge(k);
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[cap] challenge storage hooks provided but no deleteExpired, couldn't delete expired challenges"
|
||||
"[cap] challenge storage hooks provided but no deleteExpired, couldn't delete expired challenges",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -510,7 +501,7 @@ class Cap extends EventEmitter {
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"[cap] token storage hooks provided but no deleteExpired, couldn't delete expired tokens"
|
||||
"[cap] token storage hooks provided but no deleteExpired, couldn't delete expired tokens",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -548,15 +539,11 @@ class Cap extends EventEmitter {
|
||||
this._cleanupPromise = (async () => {
|
||||
const tokensChanged = await this._cleanExpiredTokens();
|
||||
|
||||
if (
|
||||
tokensChanged &&
|
||||
!this.config.noFSState &&
|
||||
!this.config.storage?.tokens?.store
|
||||
) {
|
||||
if (tokensChanged && !this.config.noFSState && !this.config.storage?.tokens?.store) {
|
||||
await fs.writeFile(
|
||||
this.config.tokens_store_path,
|
||||
JSON.stringify(this.config.state.tokensList),
|
||||
"utf8"
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
+27
-27
@@ -1,62 +1,62 @@
|
||||
{
|
||||
"name": "@cap.js/server",
|
||||
"version": "4.0.5",
|
||||
"author": "Tiago",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagozip/cap.git"
|
||||
},
|
||||
"main": "index.js",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagozip/cap/issues"
|
||||
},
|
||||
"description": "Server-side challenge generator and verifier for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.",
|
||||
"homepage": "https://github.com/tiagozip/cap#readme",
|
||||
"keywords": [
|
||||
"security",
|
||||
"detection",
|
||||
"prevention",
|
||||
"defense",
|
||||
"protection",
|
||||
"algorithm",
|
||||
"anti-abuse",
|
||||
"anti-automation",
|
||||
"anti-bot",
|
||||
"anti-ddos",
|
||||
"anti-dos",
|
||||
"anti-exploitation",
|
||||
"anti-spam",
|
||||
"anti-scraping",
|
||||
"anti-spam",
|
||||
"attack-mitigation",
|
||||
"protocol",
|
||||
"bots",
|
||||
"captcha-alternative",
|
||||
"client-server",
|
||||
"computational-puzzle",
|
||||
"complexity",
|
||||
"puzzle",
|
||||
"computational-puzzle",
|
||||
"crypto",
|
||||
"cryptographic",
|
||||
"algorithm",
|
||||
"cybersecurity",
|
||||
"ddos",
|
||||
"defense",
|
||||
"detection",
|
||||
"filtering",
|
||||
"hashcash",
|
||||
"hcaptcha",
|
||||
"captcha-alternative",
|
||||
"verification",
|
||||
"invisible",
|
||||
"pow",
|
||||
"prevention",
|
||||
"proof-of-work",
|
||||
"protection",
|
||||
"protocol",
|
||||
"puzzle",
|
||||
"recaptcha",
|
||||
"security",
|
||||
"spam",
|
||||
"bots",
|
||||
"filtering",
|
||||
"turing-test"
|
||||
"turing-test",
|
||||
"verification"
|
||||
],
|
||||
"homepage": "https://github.com/tiagozip/cap#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagozip/cap/issues"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Tiago",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagozip/cap.git"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"publish": "bunx tsc && bun publish --access public"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"types": "index.d.ts",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.2",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
+23
-23
@@ -1,33 +1,33 @@
|
||||
import solver from "../index.js";
|
||||
|
||||
const CHALLENGES = [
|
||||
["e455cea65e98bc3c36287f43769da211", "dceb"],
|
||||
["fb8d25f6abac5aa9b6360051f37e010b", "93f1"],
|
||||
["91ef47db578fbeb2565d3f9c82bb7960", "3698"],
|
||||
["b7ad7667486a691cda8ef297098f64a7", "d72a"],
|
||||
["1aca3fb7cef7a2be0dee563ed4136758", "3b58"],
|
||||
["d9ec39af92b430e5a329274d8aa58fa8", "e1d3"],
|
||||
["781a3cc9217d73c908a321d3fdabd62f", "22c6"],
|
||||
["e37a0752c9ac2f3d2517747fde373ac9", "f6f1"],
|
||||
["bba070197569f322beda5b240f639a95", "4751"],
|
||||
["89297515aeac646bee9653ba405e0beb", "a7de"],
|
||||
["444571a0d5039c15be6141d6cd8434f9", "a783"],
|
||||
["ba75f2bf8e9b92cc32caa17237a52d14", "7e30"],
|
||||
["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf"],
|
||||
["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5"],
|
||||
["308758072931bb3b254a7b1ed351d04a", "3e49"],
|
||||
["724f89bb167db4b881e1dc7b0949ac8f", "b82e"],
|
||||
["8b79506e4630de15be225c18623eff65", "f0e5"],
|
||||
["0c21ade6e63a4e37b13cb8b087f31863", "65c9"],
|
||||
["e455cea65e98bc3c36287f43769da211", "dceb"],
|
||||
["fb8d25f6abac5aa9b6360051f37e010b", "93f1"],
|
||||
["91ef47db578fbeb2565d3f9c82bb7960", "3698"],
|
||||
["b7ad7667486a691cda8ef297098f64a7", "d72a"],
|
||||
["1aca3fb7cef7a2be0dee563ed4136758", "3b58"],
|
||||
["d9ec39af92b430e5a329274d8aa58fa8", "e1d3"],
|
||||
["781a3cc9217d73c908a321d3fdabd62f", "22c6"],
|
||||
["e37a0752c9ac2f3d2517747fde373ac9", "f6f1"],
|
||||
["bba070197569f322beda5b240f639a95", "4751"],
|
||||
["89297515aeac646bee9653ba405e0beb", "a7de"],
|
||||
["444571a0d5039c15be6141d6cd8434f9", "a783"],
|
||||
["ba75f2bf8e9b92cc32caa17237a52d14", "7e30"],
|
||||
["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf"],
|
||||
["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5"],
|
||||
["308758072931bb3b254a7b1ed351d04a", "3e49"],
|
||||
["724f89bb167db4b881e1dc7b0949ac8f", "b82e"],
|
||||
["8b79506e4630de15be225c18623eff65", "f0e5"],
|
||||
["0c21ade6e63a4e37b13cb8b087f31863", "65c9"],
|
||||
];
|
||||
|
||||
const timeStart = Date.now();
|
||||
const solutions = await solver(CHALLENGES, {
|
||||
onProgress: (status) => {
|
||||
process.stdout.moveCursor(0, -1);
|
||||
process.stdout.clearScreenDown();
|
||||
console.log(`Progress: ${status.progress}%`);
|
||||
},
|
||||
onProgress: (status) => {
|
||||
process.stdout.moveCursor(0, -1);
|
||||
process.stdout.clearScreenDown();
|
||||
console.log(`Progress: ${status.progress}%`);
|
||||
},
|
||||
});
|
||||
|
||||
process.stdout.moveCursor(0, -1);
|
||||
|
||||
@@ -2,14 +2,14 @@ import solver from "../index.js";
|
||||
|
||||
const timeStart = Date.now();
|
||||
const solutions = await solver(Math.random().toString(), {
|
||||
onProgress: (status) => {
|
||||
process.stdout.moveCursor(0, -1);
|
||||
process.stdout.clearScreenDown();
|
||||
console.log(`Progress: ${status.progress}%`);
|
||||
},
|
||||
c: 50,
|
||||
s: 30,
|
||||
d: 4,
|
||||
onProgress: (status) => {
|
||||
process.stdout.moveCursor(0, -1);
|
||||
process.stdout.clearScreenDown();
|
||||
console.log(`Progress: ${status.progress}%`);
|
||||
},
|
||||
c: 50,
|
||||
s: 30,
|
||||
d: 4,
|
||||
});
|
||||
|
||||
process.stdout.moveCursor(0, -1);
|
||||
|
||||
+5
-15
@@ -6,8 +6,7 @@ function prng(seed, length) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash +=
|
||||
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
@@ -75,16 +74,12 @@ export default function solve(challenge, config = {}) {
|
||||
challenges = Array.from({ length: config.c }, () => {
|
||||
i = i + 1;
|
||||
|
||||
return [
|
||||
prng(`${challenges}${i}`, config.s),
|
||||
prng(`${challenges}${i}d`, config.d),
|
||||
];
|
||||
return [prng(`${challenges}${i}`, config.s), prng(`${challenges}${i}d`, config.d)];
|
||||
});
|
||||
}
|
||||
|
||||
const totalChallenges = challenges.length;
|
||||
const numWorkers =
|
||||
config?.workerCount || Math.min(totalChallenges, os.cpus().length);
|
||||
const numWorkers = config?.workerCount || Math.min(totalChallenges, os.cpus().length);
|
||||
|
||||
let challengesProcessed = 0;
|
||||
let nextChallengeIndex = 0;
|
||||
@@ -112,10 +107,7 @@ export default function solve(challenge, config = {}) {
|
||||
activeWorkers--;
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
`Error in worker for challenge ${currentChallengeIndex}:`,
|
||||
result.error,
|
||||
);
|
||||
console.error(`Error in worker for challenge ${currentChallengeIndex}:`, result.error);
|
||||
reject(new Error(`Worker error: ${result.error}`));
|
||||
return;
|
||||
}
|
||||
@@ -125,9 +117,7 @@ export default function solve(challenge, config = {}) {
|
||||
|
||||
if (config?.onProgress) {
|
||||
config.onProgress({
|
||||
progress: Math.floor(
|
||||
(challengesProcessed / totalChallenges) * 100,
|
||||
),
|
||||
progress: Math.floor((challengesProcessed / totalChallenges) * 100),
|
||||
currentChallenge: currentChallengeIndex,
|
||||
challengesProcessed,
|
||||
totalChallenges,
|
||||
|
||||
+27
-27
@@ -2,59 +2,59 @@
|
||||
"name": "@cap.js/solver",
|
||||
"version": "0.1.3",
|
||||
"description": "Server-side solver for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "bun ./example.js",
|
||||
"npm:publish": "bun publish --access public"
|
||||
},
|
||||
"author": "Tiago",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagozip/cap.git"
|
||||
},
|
||||
"keywords": [
|
||||
"security",
|
||||
"detection",
|
||||
"prevention",
|
||||
"defense",
|
||||
"protection",
|
||||
"algorithm",
|
||||
"anti-abuse",
|
||||
"anti-automation",
|
||||
"anti-bot",
|
||||
"anti-ddos",
|
||||
"anti-dos",
|
||||
"anti-exploitation",
|
||||
"anti-spam",
|
||||
"anti-scraping",
|
||||
"anti-spam",
|
||||
"attack-mitigation",
|
||||
"protocol",
|
||||
"bots",
|
||||
"captcha-alternative",
|
||||
"client-server",
|
||||
"computational-puzzle",
|
||||
"complexity",
|
||||
"puzzle",
|
||||
"computational-puzzle",
|
||||
"crypto",
|
||||
"cryptographic",
|
||||
"algorithm",
|
||||
"cybersecurity",
|
||||
"ddos",
|
||||
"defense",
|
||||
"detection",
|
||||
"filtering",
|
||||
"hashcash",
|
||||
"hcaptcha",
|
||||
"captcha-alternative",
|
||||
"verification",
|
||||
"invisible",
|
||||
"pow",
|
||||
"prevention",
|
||||
"proof-of-work",
|
||||
"protection",
|
||||
"protocol",
|
||||
"puzzle",
|
||||
"recaptcha",
|
||||
"security",
|
||||
"spam",
|
||||
"bots",
|
||||
"filtering",
|
||||
"turing-test"
|
||||
"turing-test",
|
||||
"verification"
|
||||
],
|
||||
"homepage": "https://github.com/tiagozip/cap#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagozip/cap/issues"
|
||||
},
|
||||
"homepage": "https://github.com/tiagozip/cap#readme",
|
||||
"license": "Apache-2.0",
|
||||
"author": "Tiago",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagozip/cap.git"
|
||||
},
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "bun ./example.js",
|
||||
"npm:publish": "bun publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cap.js/wasm": "^0.0.4"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cap-standalone",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"keywords": [
|
||||
"algorithm",
|
||||
"anti-abuse",
|
||||
|
||||
Vendored
+1
-1
@@ -545,7 +545,7 @@
|
||||
.save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(.3);
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
.danger-section {
|
||||
|
||||
Vendored
+1
-1
@@ -74,7 +74,7 @@
|
||||
<pre class="logs">No logs</pre>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/@cap.js/widget/cap.min.js"></script>
|
||||
<script src="https://unpkg.com/@cap.js/widget@latest/cap.min.js"></script>
|
||||
<script>
|
||||
const keyIdInput = document.getElementById("keyIdInput");
|
||||
const secretKeyInput = document.getElementById("secretKeyInput");
|
||||
|
||||
+356
-106
@@ -1,4 +1,4 @@
|
||||
import Cap from "@cap.js/server";
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { Elysia } from "elysia";
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
@@ -7,9 +7,148 @@ import { db } from "./db.js";
|
||||
import {
|
||||
generateInstrumentationChallenge,
|
||||
verifyInstrumentationResult,
|
||||
warmForConfigs,
|
||||
} from "./instrumentation.js";
|
||||
import { ratelimitGenerator } from "./ratelimit.js";
|
||||
|
||||
const CHALLENGE_TTL_MS = 15 * 60 * 1000; // 15min
|
||||
const TOKEN_TTL_MS = 2 * 60 * 60 * 1000; // 2h
|
||||
|
||||
// ── JWT helpers (HMAC-SHA256, compact JWS) ──────────────────────────────
|
||||
|
||||
const b64url = (buf) =>
|
||||
(buf instanceof Uint8Array ? Buffer.from(buf) : Buffer.from(buf, "utf8")).toString("base64url");
|
||||
|
||||
const b64urlDecode = (str) => Buffer.from(str, "base64url");
|
||||
|
||||
function jwtSign(payload, secret) {
|
||||
const header = b64url('{"alg":"HS256","typ":"JWT"}');
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const sigInput = `${header}.${body}`;
|
||||
const sig = createHmac("sha256", secret).update(sigInput).digest();
|
||||
return `${sigInput}.${b64url(sig)}`;
|
||||
}
|
||||
|
||||
function jwtVerify(token, secret) {
|
||||
if (!token || typeof token !== "string") return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [header, body, sig] = parts;
|
||||
const sigInput = `${header}.${body}`;
|
||||
const expected = createHmac("sha256", secret).update(sigInput).digest();
|
||||
const actual = b64urlDecode(sig);
|
||||
|
||||
if (actual.length !== expected.length) return null;
|
||||
|
||||
// Constant-time comparison
|
||||
const a = new Uint8Array(expected);
|
||||
const b = new Uint8Array(actual);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
if (diff !== 0) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(b64urlDecode(body).toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the raw signature bytes (last segment) for blocklisting */
|
||||
function jwtSigHex(token) {
|
||||
const lastDot = token.lastIndexOf(".");
|
||||
if (lastDot === -1) return null;
|
||||
return b64urlDecode(token.slice(lastDot + 1)).toString("hex");
|
||||
}
|
||||
|
||||
// ── AES-256-GCM helpers ─────────────────────────────────────────────────
|
||||
// Used to encrypt instrumentation metadata inside the JWT so that the
|
||||
// expected answers are completely opaque to the client.
|
||||
|
||||
/**
|
||||
* Derive a 32-byte encryption key from the site's jwtSecret.
|
||||
* We HMAC the secret with a fixed context label so the signing key
|
||||
* and the encryption key are always distinct even though they share
|
||||
* the same root secret.
|
||||
*/
|
||||
function deriveEncKey(jwtSecret) {
|
||||
return createHmac("sha256", jwtSecret).update("cap:instr-enc-v1").digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt arbitrary JSON-serialisable data with AES-256-GCM.
|
||||
* Returns a base64url string: iv (12 B) || authTag (16 B) || ciphertext
|
||||
*/
|
||||
function encrypt(data, jwtSecret) {
|
||||
const key = deriveEncKey(jwtSecret);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const plaintext = Buffer.from(JSON.stringify(data), "utf8");
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag(); // 16 bytes
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a blob produced by encrypt(). Returns the parsed object, or
|
||||
* null if decryption / authentication fails.
|
||||
*/
|
||||
function decrypt(blob, jwtSecret) {
|
||||
try {
|
||||
const buf = Buffer.from(blob, "base64url");
|
||||
if (buf.length < 28) return null; // iv(12) + tag(16)
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const ciphertext = buf.subarray(28);
|
||||
const key = deriveEncKey(jwtSecret);
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return JSON.parse(decrypted.toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── prng (must match @cap.js/server and the widget) ─────────────────────
|
||||
|
||||
function prng(seed, length) {
|
||||
function fnv1a(str) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
let state = fnv1a(seed);
|
||||
let result = "";
|
||||
|
||||
function next() {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return state >>> 0;
|
||||
}
|
||||
|
||||
while (result.length < length) {
|
||||
const rnd = next();
|
||||
result += rnd.toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
return result.substring(0, length);
|
||||
}
|
||||
|
||||
// ── sha256 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function sha256(str) {
|
||||
return new Bun.CryptoHasher("sha256").update(str).digest("hex");
|
||||
}
|
||||
|
||||
// ── Route definitions ───────────────────────────────────────────────────
|
||||
|
||||
export const capServer = new Elysia({
|
||||
detail: {
|
||||
tags: ["Challenges"],
|
||||
@@ -29,168 +168,279 @@ export const capServer = new Elysia({
|
||||
methods: ["POST"],
|
||||
}),
|
||||
)
|
||||
.post("/:siteKey/challenge", async ({ set, params }) => {
|
||||
const cap = new Cap({
|
||||
noFSState: true,
|
||||
});
|
||||
const [_keyConfig] = await db`SELECT (config) FROM keys WHERE siteKey = ${params.siteKey}`;
|
||||
|
||||
if (!_keyConfig) {
|
||||
// ── POST /:siteKey/challenge ────────────────────────────────────────
|
||||
// No DB writes — the challenge token IS a JWT signed with the site's
|
||||
// jwtSecret. The JWT payload carries all data needed by /redeem.
|
||||
// Instrumentation metadata is AES-256-GCM encrypted so the expected
|
||||
// answers are never visible to the client.
|
||||
.post("/:siteKey/challenge", async ({ set, params }) => {
|
||||
const [keyRow] = await db`SELECT config, jwtSecret FROM keys WHERE siteKey = ${params.siteKey}`;
|
||||
|
||||
if (!keyRow) {
|
||||
set.status = 404;
|
||||
return { error: "Invalid site key or secret" };
|
||||
}
|
||||
|
||||
const keyConfig = JSON.parse(_keyConfig.config);
|
||||
const keyConfig = JSON.parse(keyRow.config);
|
||||
const jwtSecret = keyRow.jwtsecret ?? keyRow.jwtSecret;
|
||||
|
||||
const challenge = await cap.createChallenge({
|
||||
challengeCount: keyConfig.challengeCount,
|
||||
challengeSize: keyConfig.saltSize,
|
||||
challengeDifficulty: keyConfig.difficulty,
|
||||
});
|
||||
if (!jwtSecret) {
|
||||
set.status = 500;
|
||||
return { error: "Site key is not configured for JWT challenges" };
|
||||
}
|
||||
|
||||
await db`
|
||||
INSERT INTO challenges (siteKey, token, data, expires)
|
||||
VALUES (${params.siteKey}, ${challenge.token}, ${`${challenge.challenge.c},${challenge.challenge.s},${challenge.challenge.d}`}, ${challenge.expires})
|
||||
`;
|
||||
const c = keyConfig.challengeCount ?? 80;
|
||||
const s = keyConfig.saltSize ?? 32;
|
||||
const d = keyConfig.difficulty ?? 4;
|
||||
const expires = Date.now() + CHALLENGE_TTL_MS;
|
||||
|
||||
// A random nonce for JWT uniqueness
|
||||
const nonce = randomBytes(25).toString("hex");
|
||||
|
||||
const jwtPayload = {
|
||||
sk: params.siteKey,
|
||||
n: nonce,
|
||||
c,
|
||||
s,
|
||||
d,
|
||||
exp: expires,
|
||||
};
|
||||
|
||||
// If instrumentation is enabled, encrypt its verification metadata
|
||||
// and embed the opaque ciphertext in the JWT. The cleartext
|
||||
// instrumentation script (what the browser executes) is returned
|
||||
// separately — outside the JWT — so the widget can run it.
|
||||
let instrBytes = null;
|
||||
if (keyConfig.instrumentation) {
|
||||
const instr = await generateInstrumentationChallenge(keyConfig);
|
||||
|
||||
const instrMeta = JSON.stringify({
|
||||
id: instr.id,
|
||||
validStates: instr.validStates,
|
||||
vars: instr.vars,
|
||||
blockAutomatedBrowsers: instr.blockAutomatedBrowsers,
|
||||
expires: instr.expires,
|
||||
});
|
||||
// This is the sensitive part: expected states, variable names, etc.
|
||||
// Encrypt it so the JWT payload doesn't leak the answers.
|
||||
jwtPayload.ei = encrypt(
|
||||
{
|
||||
id: instr.id,
|
||||
validStates: instr.validStates,
|
||||
vars: instr.vars,
|
||||
blockAutomatedBrowsers: instr.blockAutomatedBrowsers,
|
||||
expires: instr.expires,
|
||||
},
|
||||
jwtSecret,
|
||||
);
|
||||
|
||||
await db`
|
||||
INSERT INTO challenges (siteKey, token, data, expires)
|
||||
VALUES (
|
||||
${params.siteKey},
|
||||
${"instr_" + challenge.token},
|
||||
${instrMeta},
|
||||
${challenge.expires}
|
||||
)
|
||||
`;
|
||||
instrBytes = instr.instrumentation;
|
||||
|
||||
return {
|
||||
...challenge,
|
||||
instrumentation: instr.instrumentation,
|
||||
};
|
||||
// Keep the cache hot
|
||||
warmForConfigs([keyConfig]);
|
||||
}
|
||||
|
||||
return challenge;
|
||||
const token = jwtSign(jwtPayload, jwtSecret);
|
||||
|
||||
const response = {
|
||||
challenge: { c, s, d },
|
||||
token,
|
||||
expires,
|
||||
};
|
||||
|
||||
if (instrBytes) {
|
||||
response.instrumentation = instrBytes;
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
|
||||
// ── POST /:siteKey/redeem ──────────────────────────────────────────
|
||||
// Verifies the JWT, checks PoW solutions, blocklists the JWT sig,
|
||||
// then generates a redemption token and inserts it into the DB.
|
||||
.post("/:siteKey/redeem", async ({ body, set, params }) => {
|
||||
if (!body || !body.token || !body.solutions) {
|
||||
set.status = 400;
|
||||
return { error: "Missing required fields" };
|
||||
}
|
||||
|
||||
const instrTokenKey = "instr_" + body.token;
|
||||
// ── look up site key & jwt secret ───────────────────────────────
|
||||
const [keyRow] = await db`SELECT jwtSecret FROM keys WHERE siteKey = ${params.siteKey}`;
|
||||
|
||||
const deleted = await db`
|
||||
DELETE FROM challenges
|
||||
WHERE siteKey = ${params.siteKey}
|
||||
AND token IN (${body.token}, ${instrTokenKey})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const challenge = deleted.find((r) => r.token === body.token);
|
||||
const instrRow = deleted.find((r) => r.token === instrTokenKey);
|
||||
|
||||
if (!challenge) {
|
||||
if (!keyRow) {
|
||||
set.status = 404;
|
||||
return { error: "Challenge not found" };
|
||||
return { error: "Invalid site key" };
|
||||
}
|
||||
|
||||
if (challenge.expires < Date.now()) {
|
||||
const jwtSecret = keyRow.jwtsecret ?? keyRow.jwtSecret;
|
||||
|
||||
if (!jwtSecret) {
|
||||
set.status = 500;
|
||||
return { error: "Site key is not configured for JWT challenges" };
|
||||
}
|
||||
|
||||
// ── verify JWT ──────────────────────────────────────────────────
|
||||
const payload = jwtVerify(body.token, jwtSecret);
|
||||
|
||||
if (!payload) {
|
||||
set.status = 403;
|
||||
return { error: "Invalid challenge token" };
|
||||
}
|
||||
|
||||
if (payload.sk !== params.siteKey) {
|
||||
set.status = 403;
|
||||
return { error: "Challenge token does not match site key" };
|
||||
}
|
||||
|
||||
if (!payload.exp || payload.exp < Date.now()) {
|
||||
set.status = 403;
|
||||
return { error: "Challenge expired" };
|
||||
}
|
||||
|
||||
const cap = new Cap({
|
||||
noFSState: true,
|
||||
state: {
|
||||
challengesList: {
|
||||
[challenge.token]: {
|
||||
challenge: {
|
||||
c: Number(challenge.data.split(",")[0]),
|
||||
s: Number(challenge.data.split(",")[1]),
|
||||
d: Number(challenge.data.split(",")[2]),
|
||||
},
|
||||
expires: challenge.expires,
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── replay protection: check blocklist ──────────────────────────
|
||||
const sig = jwtSigHex(body.token);
|
||||
if (!sig) {
|
||||
set.status = 403;
|
||||
return { error: "Malformed challenge token" };
|
||||
}
|
||||
|
||||
const [existing] = await db`SELECT 1 FROM challenge_blocklist WHERE sig = ${sig}`;
|
||||
if (existing) {
|
||||
set.status = 403;
|
||||
return { error: "Challenge already redeemed" };
|
||||
}
|
||||
|
||||
// ── verify PoW solutions ────────────────────────────────────────
|
||||
const { c, s: size, d: difficulty } = payload;
|
||||
const solutions = body.solutions;
|
||||
|
||||
if (
|
||||
!Array.isArray(solutions) ||
|
||||
solutions.length !== c ||
|
||||
solutions.some((v) => typeof v !== "number")
|
||||
) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid solutions" };
|
||||
}
|
||||
|
||||
// The widget uses the full token string (the JWT) as the PRNG seed,
|
||||
// so the server must do the same to regenerate identical challenges.
|
||||
const prngSeed = body.token;
|
||||
|
||||
let idx = 0;
|
||||
const challenges = Array.from({ length: c }, () => {
|
||||
idx++;
|
||||
return [prng(`${prngSeed}${idx}`, size), prng(`${prngSeed}${idx}d`, difficulty)];
|
||||
});
|
||||
|
||||
const { success, token, expires } = await cap.redeemChallenge(body);
|
||||
const hashes = await Promise.all(
|
||||
challenges.map(([salt, target], i) => sha256(salt + solutions[i]).then((h) => [h, target])),
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
const isValid = hashes.every(([h, target]) => h.startsWith(target));
|
||||
|
||||
if (!isValid) {
|
||||
set.status = 403;
|
||||
return { error: "Invalid solution" };
|
||||
}
|
||||
|
||||
let instrResult = null;
|
||||
|
||||
if (instrRow) {
|
||||
let challengeMeta;
|
||||
try {
|
||||
challengeMeta = JSON.parse(instrRow.data);
|
||||
} catch {
|
||||
challengeMeta = null;
|
||||
}
|
||||
|
||||
if (challengeMeta && body.instr_blocked === true) {
|
||||
if (challengeMeta.blockAutomatedBrowsers) {
|
||||
set.status = 403;
|
||||
return { instr_error: true, error: "Blocked by instrumentation" };
|
||||
}
|
||||
|
||||
// ── instrumentation verification ────────────────────────────────
|
||||
// The `ei` field in the JWT is AES-256-GCM encrypted; decrypt it
|
||||
// to recover the expected instrumentation state.
|
||||
let instrMeta = null;
|
||||
if (payload.ei) {
|
||||
instrMeta = decrypt(payload.ei, jwtSecret);
|
||||
if (!instrMeta) {
|
||||
set.status = 403;
|
||||
return { instr_error: true, error: "Blocked by instrumentation" };
|
||||
} else if (challengeMeta && body.instr) {
|
||||
if (challengeMeta.expires && Date.now() > challengeMeta.expires) {
|
||||
return {
|
||||
instr_error: true,
|
||||
error: "Blocked by instrumentation",
|
||||
reason: "corrupted_instrumentation_data",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (instrMeta) {
|
||||
if (body.instr_blocked === true) {
|
||||
if (instrMeta.blockAutomatedBrowsers) {
|
||||
set.status = 403;
|
||||
return {
|
||||
instr_error: true,
|
||||
error: "Blocked by instrumentation",
|
||||
reason: "automated_browser_detected",
|
||||
};
|
||||
}
|
||||
// blockAutomatedBrowsers is disabled — allow through
|
||||
} else if (body.instr) {
|
||||
let instrResult;
|
||||
if (instrMeta.expires && Date.now() > instrMeta.expires) {
|
||||
instrResult = { valid: false, env: null, reason: "expired" };
|
||||
} else {
|
||||
instrResult = verifyInstrumentationResult(challengeMeta, body.instr);
|
||||
instrResult = verifyInstrumentationResult(instrMeta, body.instr);
|
||||
}
|
||||
|
||||
if (!instrResult.valid) {
|
||||
set.status = 403;
|
||||
return { instr_error: true, error: "Blocked by instrumentation" };
|
||||
return {
|
||||
instr_error: true,
|
||||
error: "Blocked by instrumentation",
|
||||
reason: instrResult.reason || "failed_challenge",
|
||||
};
|
||||
}
|
||||
} else if (challengeMeta && body.instr_timeout === true) {
|
||||
} else if (body.instr_timeout === true) {
|
||||
set.status = 429;
|
||||
return { instr_error: true, error: "Instrumentation timeout" };
|
||||
} else if (challengeMeta && !body.instr && !body.instr_blocked) {
|
||||
return { instr_error: true, error: "Instrumentation timeout", reason: "timeout" };
|
||||
} else {
|
||||
set.status = 403;
|
||||
return { instr_error: true, error: "Blocked by instrumentation" };
|
||||
} else if (!challengeMeta) {
|
||||
set.status = 403;
|
||||
return { instr_error: true, error: "Blocked by instrumentation" };
|
||||
return {
|
||||
instr_error: true,
|
||||
error: "Blocked by instrumentation",
|
||||
reason: "missing_instrumentation_response",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── blocklist the JWT signature so it cannot be replayed ─────────
|
||||
await db`
|
||||
INSERT INTO tokens (siteKey, token, expires)
|
||||
VALUES (${params.siteKey}, ${token}, ${expires})
|
||||
`;
|
||||
INSERT INTO challenge_blocklist (sig, expires)
|
||||
VALUES (${sig}, ${payload.exp})
|
||||
`;
|
||||
|
||||
// ── generate redemption token (NOT a JWT — opaque, DB-backed) ───
|
||||
const redeemId = randomBytes(8).toString("hex");
|
||||
const redeemSecret = randomBytes(15).toString("hex");
|
||||
const redeemToken = `${redeemId}:${redeemSecret}`;
|
||||
const tokenExpires = Date.now() + TOKEN_TTL_MS;
|
||||
|
||||
await db`
|
||||
INSERT INTO tokens (siteKey, token, expires)
|
||||
VALUES (${params.siteKey}, ${redeemToken}, ${tokenExpires})
|
||||
`;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hourlyBucket = Math.floor(now / 3600) * 3600;
|
||||
await db`
|
||||
INSERT INTO solutions (siteKey, bucket, count)
|
||||
VALUES (${params.siteKey}, ${hourlyBucket}, 1)
|
||||
ON CONFLICT (siteKey, bucket)
|
||||
DO UPDATE SET count = count + 1
|
||||
`;
|
||||
INSERT INTO solutions (siteKey, bucket, count)
|
||||
VALUES (${params.siteKey}, ${hourlyBucket}, 1)
|
||||
ON CONFLICT (siteKey, bucket)
|
||||
DO UPDATE SET count = count + 1
|
||||
`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
expires,
|
||||
token: redeemToken,
|
||||
expires: tokenExpires,
|
||||
};
|
||||
});
|
||||
|
||||
export async function prewarmInstrumentation() {
|
||||
try {
|
||||
const keys = await db`SELECT config FROM keys`;
|
||||
const instrConfigs = keys
|
||||
.map((k) => {
|
||||
try {
|
||||
return JSON.parse(k.config);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((c) => c?.instrumentation === true);
|
||||
if (instrConfigs.length > 0) warmForConfigs(instrConfigs);
|
||||
} catch (e) {
|
||||
console.warn("[cap] prewarmInstrumentation failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { SQL } from "bun";
|
||||
@@ -13,7 +14,7 @@ async function initDb() {
|
||||
process.env.DB_URL || `sqlite://${join(process.env.DATA_PATH || "./.data", "db.sqlite")}`;
|
||||
|
||||
db = new SQL(dbUrl);
|
||||
|
||||
|
||||
await db`PRAGMA journal_mode = WAL;`.simple();
|
||||
await db`PRAGMA synchronous = NORMAL;`.simple();
|
||||
await db`create table if not exists sessions (
|
||||
@@ -26,10 +27,23 @@ async function initDb() {
|
||||
siteKey text primary key not null,
|
||||
name text not null,
|
||||
secretHash text not null,
|
||||
jwtSecret text not null default '',
|
||||
config text not null,
|
||||
created integer not null
|
||||
)`.simple();
|
||||
|
||||
try {
|
||||
await db`SELECT jwtSecret FROM keys LIMIT 1`;
|
||||
} catch {
|
||||
await db`ALTER TABLE keys ADD COLUMN jwtSecret text not null default ''`.simple();
|
||||
}
|
||||
|
||||
const keysWithoutSecret = await db`SELECT siteKey FROM keys WHERE jwtSecret = ''`;
|
||||
for (const row of keysWithoutSecret) {
|
||||
const secret = randomBytes(32).toString("base64url");
|
||||
await db`UPDATE keys SET jwtSecret = ${secret} WHERE siteKey = ${row.siteKey || row.sitekey}`;
|
||||
}
|
||||
|
||||
await db`create table if not exists solutions (
|
||||
siteKey text not null,
|
||||
bucket integer not null,
|
||||
@@ -37,12 +51,9 @@ async function initDb() {
|
||||
primary key (siteKey, bucket)
|
||||
)`.simple();
|
||||
|
||||
await db`create table if not exists challenges (
|
||||
siteKey text not null,
|
||||
token text not null,
|
||||
data text not null,
|
||||
expires integer not null,
|
||||
primary key (siteKey, token)
|
||||
await db`create table if not exists challenge_blocklist (
|
||||
sig text primary key not null,
|
||||
expires integer not null
|
||||
)`.simple();
|
||||
|
||||
await db`create table if not exists tokens (
|
||||
@@ -72,10 +83,10 @@ async function initDb() {
|
||||
|
||||
await db`delete from sessions where expires < ${now}`;
|
||||
await db`delete from tokens where expires < ${now}`;
|
||||
await db`delete from challenges where expires < ${now}`;
|
||||
await db`delete from challenge_blocklist where expires < ${now}`;
|
||||
await db`delete from ip_bans where expires < ${now}`;
|
||||
} catch (e) {
|
||||
console.error("failed to cleanup:", e)
|
||||
console.error("failed to cleanup:", e);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
@@ -83,7 +94,7 @@ async function initDb() {
|
||||
|
||||
await db`delete from sessions where expires < ${now}`;
|
||||
await db`delete from tokens where expires < ${now}`;
|
||||
await db`delete from challenges where expires < ${now}`;
|
||||
await db`delete from challenge_blocklist where expires < ${now}`;
|
||||
await db`delete from ip_bans where expires < ${now}`;
|
||||
|
||||
return db;
|
||||
|
||||
+12
-11
@@ -3,7 +3,7 @@ import { swagger } from "@elysiajs/swagger";
|
||||
import { Elysia, file } from "elysia";
|
||||
import { assetsServer } from "./assets.js";
|
||||
import { auth } from "./auth.js";
|
||||
import { capServer } from "./cap.js";
|
||||
import { capServer, prewarmInstrumentation } from "./cap.js";
|
||||
import { server } from "./server.js";
|
||||
import { siteverifyServer } from "./siteverify.js";
|
||||
|
||||
@@ -88,19 +88,18 @@ new Elysia({
|
||||
return {
|
||||
success: false,
|
||||
error: error.code || "Internal server error",
|
||||
detail: process.env.SHOW_ERRORS === "true" ? error : {
|
||||
troubleshooting: "http://capjs.js.org/guide/standalone/options.html#error-messages",
|
||||
id: errorId
|
||||
}
|
||||
}
|
||||
detail:
|
||||
process.env.SHOW_ERRORS === "true"
|
||||
? error
|
||||
: {
|
||||
troubleshooting: "http://capjs.js.org/guide/standalone/options.html#error-messages",
|
||||
id: errorId,
|
||||
},
|
||||
};
|
||||
})
|
||||
.use(staticPlugin())
|
||||
.get("/", async ({ cookie }) => {
|
||||
return file(
|
||||
cookie.cap_authed?.value === "yes"
|
||||
? "./public/index.html"
|
||||
: "./public/login.html"
|
||||
);
|
||||
return file(cookie.cap_authed?.value === "yes" ? "./public/index.html" : "./public/login.html");
|
||||
})
|
||||
.use(auth)
|
||||
.use(server)
|
||||
@@ -110,3 +109,5 @@ new Elysia({
|
||||
.listen(serverPort);
|
||||
|
||||
console.log(`🧢 Cap running on http://${serverHostname}:${serverPort}`);
|
||||
|
||||
prewarmInstrumentation();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import { randomBytes, randomInt } from "node:crypto";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import JavaScriptObfuscator from "javascript-obfuscator";
|
||||
@@ -66,79 +65,38 @@ function buildClientScript({ id, vars, initVals, clientEqs, blockAutomatedBrowse
|
||||
`
|
||||
: "";
|
||||
|
||||
return `(function(){
|
||||
return ` (function(){
|
||||
'use strict';
|
||||
|
||||
var doVerification = function() {
|
||||
if (navigator.toString() !== '[object Navigator]' || window.toString() !== '[object Window]' || document.toString() !== '[object HTMLDocument]' || typeof process !== 'undefined' || navigator.mimeTypes["application/pdf"].type !== "application/pdf") {
|
||||
return -1
|
||||
var doVerification = async function() {
|
||||
if (navigator.toString() !== '[object Navigator]' || window.toString() !== '[object Window]' || document.toString() !== '[object HTMLDocument]' || typeof process !== 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (window.chrome) {
|
||||
if (JSON.stringify(window.chrome.csi()).length < 70) {
|
||||
return -1
|
||||
}
|
||||
if (typeof window.chrome.app.isInstalled !== "boolean") {
|
||||
return -1
|
||||
}
|
||||
if (window.chrome.app.getDetails.toString() !== 'function getDetails() { [native code] }') {
|
||||
return -1
|
||||
}
|
||||
} else if (window.Mozilla) {
|
||||
if (JSON.stringify(window.Mozilla).length < 300) {
|
||||
return -1
|
||||
}
|
||||
if (typeof window.Mozilla.dntEnabled() !== "boolean") {
|
||||
return -1
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unsupported browser")
|
||||
}
|
||||
|
||||
if (
|
||||
!navigator.userAgent ||
|
||||
!navigator.platform
|
||||
) return -1
|
||||
|
||||
if (
|
||||
navigator.plugins.length === 0 ||
|
||||
!navigator.mimeTypes.length
|
||||
) return -1
|
||||
|
||||
const nativeFns = ['alert','confirm','prompt','fetch']
|
||||
|
||||
if (!navigator.userAgent) return null
|
||||
|
||||
try {
|
||||
const nativeFns = ['fetch']
|
||||
for (const fn of nativeFns) {
|
||||
if (!window[fn] || window[fn].toString().indexOf('[native code]') === -1) return -1
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.fillText('1', 10, 10)
|
||||
const data = canvas.toDataURL()
|
||||
if (data.length < 50) return -1
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
|
||||
try {
|
||||
const gl = document.createElement('canvas').getContext('webgl') || document.createElement('canvas').getContext('experimental-webgl')
|
||||
const renderer = gl?.getParameter(gl.RENDERER) || ''
|
||||
if (!renderer || renderer.toLowerCase().includes('swiftshader') || renderer.toLowerCase().includes('llvmpipe')) return -1
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
|
||||
try {
|
||||
const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext
|
||||
let ctx
|
||||
if (AudioContext.length === 0) {
|
||||
ctx = new AudioContext()
|
||||
} else {
|
||||
ctx = new AudioContext(1, 44100, 44100)
|
||||
}
|
||||
} catch {
|
||||
return -1
|
||||
if (!window[fn] || window[fn].toString().indexOf('[native code]') === -1) return null
|
||||
}
|
||||
} catch { return null }
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.fillText('1', 10, 10)
|
||||
const data = canvas.toDataURL()
|
||||
if (data.length < 50) return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const gl = document.createElement('canvas').getContext('webgl') || document.createElement('canvas').getContext('experimental-webgl')
|
||||
const renderer = gl?.getParameter(gl.RENDERER) || ''
|
||||
if (renderer && (renderer.toLowerCase().includes('swiftshader') || renderer.toLowerCase().includes('llvmpipe'))) return null
|
||||
} catch {}
|
||||
|
||||
${blockChecks}
|
||||
var ${vars[0]} = ${initVals[0]};
|
||||
@@ -156,20 +114,19 @@ var doVerification = function() {
|
||||
return res;
|
||||
};
|
||||
|
||||
window.onload = function() {
|
||||
window.onload = async function() {
|
||||
try {
|
||||
var result = doVerification();
|
||||
if (!result) return;
|
||||
var result = await doVerification();
|
||||
if (!result || typeof result !== 'object') return;
|
||||
parent.postMessage({ type: 'cap:instr', nonce: ${JSON.stringify(id)}, result: { i: ${JSON.stringify(id)}, state: result, ts: Date.now() } }, '*');
|
||||
} catch(e) {
|
||||
parent.postMessage({ type: 'cap:error', reason: String(e) }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
})();`;
|
||||
}
|
||||
|
||||
const TTL = 90_000;
|
||||
const TTL = 5 * 60 * 1000;
|
||||
|
||||
function generateInstrumentationChallenge(keyConfig = {}) {
|
||||
const id = rHex(32);
|
||||
@@ -181,7 +138,7 @@ function generateInstrumentationChallenge(keyConfig = {}) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
initVals = Array.from({ length: 4 }, () => rnd(10, 250));
|
||||
|
||||
let wdTrueKey = rnd(1000, 9000);
|
||||
const wdTrueKey = rnd(1000, 9000);
|
||||
let wdFalseKey = rnd(1000, 9000);
|
||||
while (wdFalseKey === wdTrueKey) {
|
||||
wdFalseKey = rnd(1000, 9000);
|
||||
@@ -236,17 +193,22 @@ function generateInstrumentationChallenge(keyConfig = {}) {
|
||||
if (!states[0].vals.every((v, i) => v === states[1].vals[i])) break;
|
||||
}
|
||||
|
||||
const script = buildClientScript({ id, vars, initVals, clientEqs, blockAutomatedBrowsers });
|
||||
const script = buildClientScript({
|
||||
id,
|
||||
vars,
|
||||
initVals,
|
||||
clientEqs,
|
||||
blockAutomatedBrowsers,
|
||||
});
|
||||
|
||||
const obfuscated = JavaScriptObfuscator.obfuscate(script, {
|
||||
compact: true,
|
||||
controlFlowFlattening: true,
|
||||
controlFlowFlatteningThreshold: 0.6,
|
||||
deadCodeInjection: true,
|
||||
deadCodeInjectionThreshold: 0.4,
|
||||
controlFlowFlattening: false,
|
||||
controlFlowFlatteningThreshold: 0,
|
||||
deadCodeInjection: false,
|
||||
stringArray: true,
|
||||
stringArrayEncoding: ["rc4"],
|
||||
stringArrayThreshold: 0.8,
|
||||
stringArrayEncoding: [],
|
||||
stringArrayThreshold: 0.5,
|
||||
identifierNamesGenerator: "mangled-shuffled",
|
||||
splitStrings: true,
|
||||
splitStringsChunkLength: randomInt(5, 26),
|
||||
@@ -256,7 +218,9 @@ function generateInstrumentationChallenge(keyConfig = {}) {
|
||||
ignoreRequireImports: true,
|
||||
}).getObfuscatedCode();
|
||||
|
||||
const compressed = deflateRawSync(Buffer.from(obfuscated, "utf8"), { level: 1 });
|
||||
const compressed = deflateRawSync(Buffer.from(obfuscated, "utf8"), {
|
||||
level: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -268,11 +232,11 @@ function generateInstrumentationChallenge(keyConfig = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
parentPort.on("message", (msg) => {
|
||||
process.on("message", (msg) => {
|
||||
try {
|
||||
const result = generateInstrumentationChallenge(msg.keyConfig || {});
|
||||
parentPort.postMessage({ ok: true, result });
|
||||
process.send({ ok: true, result });
|
||||
} catch (err) {
|
||||
parentPort.postMessage({ ok: false, error: String(err) });
|
||||
process.send({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,48 +1,35 @@
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fork } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { cpus } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const WORKER_PATH = join(__dirname, "instrumentation-worker.js");
|
||||
|
||||
const POOL_SIZE = process.env.INSTRUMENTATION_WORKERS || Math.min(4, cpus().length || 4);
|
||||
const POOL_MAX = Number(process.env.INSTRUMENTATION_WORKERS) || 1;
|
||||
const WORKER_TIMEOUT = 15_000;
|
||||
const MAX_QUEUE_SIZE = 50;
|
||||
const QUEUE_ITEM_TTL = 20_000;
|
||||
|
||||
class InstrumentationWorkerPool {
|
||||
constructor(size) {
|
||||
this._size = size;
|
||||
this._workers = [];
|
||||
class InstrumentationProcessPool {
|
||||
constructor(maxSize) {
|
||||
this._maxSize = maxSize;
|
||||
this._busy = 0;
|
||||
this._queue = [];
|
||||
this._available = [];
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
this._spawnWorker(i);
|
||||
}
|
||||
}
|
||||
|
||||
_spawnWorker(index) {
|
||||
const worker = new Worker(WORKER_PATH);
|
||||
this._workers[index] = worker;
|
||||
this._available.push(index);
|
||||
|
||||
worker.on("error", (err) => {
|
||||
console.error(`[cap] instrumentation worker ${index} error:`, err);
|
||||
try {
|
||||
worker.terminate();
|
||||
} catch {}
|
||||
this._spawnWorker(index);
|
||||
_spawnOne() {
|
||||
const child = fork(WORKER_PATH, [], {
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
|
||||
worker.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn(`[cap] instrumentation worker ${index} exited with code ${code}, respawning`);
|
||||
this._spawnWorker(index);
|
||||
}
|
||||
});
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (chunk) => {
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
run(keyConfig) {
|
||||
@@ -51,93 +38,99 @@ class InstrumentationWorkerPool {
|
||||
return reject(new Error("Instrumentation queue is full, try again later"));
|
||||
}
|
||||
|
||||
const task = { keyConfig, resolve, reject, queuedAt: Date.now() };
|
||||
const task = { keyConfig, resolve, reject, ttlTimer: null };
|
||||
|
||||
if (this._available.length > 0) {
|
||||
this._dispatch(task);
|
||||
} else {
|
||||
const ttlTimer = setTimeout(() => {
|
||||
const idx = this._queue.indexOf(task);
|
||||
if (idx !== -1) {
|
||||
this._queue.splice(idx, 1);
|
||||
}
|
||||
reject(new Error("Instrumentation task expired while waiting in queue"));
|
||||
}, QUEUE_ITEM_TTL);
|
||||
task.ttlTimer = setTimeout(() => {
|
||||
const idx = this._queue.indexOf(task);
|
||||
if (idx !== -1) this._queue.splice(idx, 1);
|
||||
reject(new Error("Instrumentation task expired while waiting in queue"));
|
||||
}, QUEUE_ITEM_TTL);
|
||||
if (task.ttlTimer.unref) task.ttlTimer.unref();
|
||||
|
||||
if (ttlTimer.unref) ttlTimer.unref();
|
||||
|
||||
task.ttlTimer = ttlTimer;
|
||||
this._queue.push(task);
|
||||
}
|
||||
this._queue.push(task);
|
||||
this._drain();
|
||||
});
|
||||
}
|
||||
|
||||
_dispatch(task) {
|
||||
const index = this._available.shift();
|
||||
const worker = this._workers[index];
|
||||
_drain() {
|
||||
while (this._queue.length > 0 && this._busy < this._maxSize) {
|
||||
const task = this._queue.shift();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
try {
|
||||
worker.terminate();
|
||||
} catch {}
|
||||
this._spawnWorker(index);
|
||||
task.reject(new Error("Instrumentation worker timed out"));
|
||||
}, WORKER_TIMEOUT);
|
||||
|
||||
const onMessage = (msg) => {
|
||||
cleanup();
|
||||
this._release(index);
|
||||
|
||||
if (msg.ok) {
|
||||
task.resolve(msg.result);
|
||||
} else {
|
||||
task.reject(new Error(msg.error || "Worker generation failed"));
|
||||
if (task.ttlTimer) {
|
||||
clearTimeout(task.ttlTimer);
|
||||
task.ttlTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err) => {
|
||||
cleanup();
|
||||
this._release(index);
|
||||
this._busy++;
|
||||
this._runTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
_runTask(task) {
|
||||
let child;
|
||||
try {
|
||||
child = this._spawnOne();
|
||||
} catch (err) {
|
||||
this._busy = Math.max(0, this._busy - 1);
|
||||
task.reject(err);
|
||||
};
|
||||
this._drain();
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
worker.removeListener("message", onMessage);
|
||||
worker.removeListener("error", onError);
|
||||
child.removeAllListeners("message");
|
||||
child.removeAllListeners("error");
|
||||
child.removeAllListeners("exit");
|
||||
};
|
||||
|
||||
worker.on("message", onMessage);
|
||||
worker.on("error", onError);
|
||||
worker.postMessage({ keyConfig: task.keyConfig });
|
||||
}
|
||||
const finish = (err, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
|
||||
_release(index) {
|
||||
this._available.push(index);
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {}
|
||||
|
||||
if (this._queue.length > 0) {
|
||||
const next = this._queue.shift();
|
||||
this._busy = Math.max(0, this._busy - 1);
|
||||
|
||||
if (next.ttlTimer) {
|
||||
clearTimeout(next.ttlTimer);
|
||||
next.ttlTimer = null;
|
||||
if (err) task.reject(err);
|
||||
else task.resolve(result);
|
||||
|
||||
this._drain();
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
finish(new Error("Instrumentation worker timed out"), null);
|
||||
}, WORKER_TIMEOUT);
|
||||
|
||||
child.on("message", (msg) => {
|
||||
if (msg.ok) {
|
||||
finish(null, msg.result);
|
||||
} else {
|
||||
finish(new Error(msg.error || "Worker generation failed"), null);
|
||||
}
|
||||
});
|
||||
|
||||
this._dispatch(next);
|
||||
}
|
||||
child.on("error", (err) => {
|
||||
console.error("[cap] instrumentation child error:", err);
|
||||
finish(err, null);
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (!settled) {
|
||||
finish(new Error(`Instrumentation child exited unexpectedly with code ${code}`), null);
|
||||
}
|
||||
});
|
||||
|
||||
child.send({ keyConfig: task.keyConfig });
|
||||
}
|
||||
|
||||
async terminate() {
|
||||
for (const w of this._workers) {
|
||||
if (w) {
|
||||
try {
|
||||
await w.terminate();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this._workers = [];
|
||||
this._available = [];
|
||||
this._busy = 0;
|
||||
for (const task of this._queue) {
|
||||
if (task.ttlTimer) {
|
||||
clearTimeout(task.ttlTimer);
|
||||
@@ -149,10 +142,86 @@ class InstrumentationWorkerPool {
|
||||
}
|
||||
}
|
||||
|
||||
const pool = new InstrumentationWorkerPool(POOL_SIZE);
|
||||
const pool = new InstrumentationProcessPool(POOL_MAX);
|
||||
|
||||
const PREWARM_SIZE = 2;
|
||||
|
||||
function _configFingerprint(keyConfig) {
|
||||
return JSON.stringify({
|
||||
b: keyConfig.blockAutomatedBrowsers === true,
|
||||
});
|
||||
}
|
||||
|
||||
class InstrumentationChallengeCache {
|
||||
constructor() {
|
||||
this._buckets = new Map();
|
||||
}
|
||||
|
||||
_bucket(fp) {
|
||||
if (!this._buckets.has(fp)) {
|
||||
this._buckets.set(fp, { ready: [], filling: 0 });
|
||||
}
|
||||
return this._buckets.get(fp);
|
||||
}
|
||||
|
||||
fill(keyConfig) {
|
||||
const fp = _configFingerprint(keyConfig);
|
||||
const bucket = this._bucket(fp);
|
||||
|
||||
if (bucket.ready.length + bucket.filling >= PREWARM_SIZE) return;
|
||||
if (bucket.filling >= 1) return;
|
||||
|
||||
bucket.filling++;
|
||||
const p = pool
|
||||
.run(keyConfig)
|
||||
.then((result) => result)
|
||||
.catch((err) => {
|
||||
console.warn("[cap] instrumentation pre-warm failed:", err);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
bucket.filling--;
|
||||
this.fill(keyConfig);
|
||||
});
|
||||
bucket.ready.push(p);
|
||||
}
|
||||
|
||||
async get(keyConfig) {
|
||||
const fp = _configFingerprint(keyConfig);
|
||||
const bucket = this._bucket(fp);
|
||||
|
||||
this.fill(keyConfig);
|
||||
|
||||
while (bucket.ready.length > 0) {
|
||||
const p = bucket.ready.shift();
|
||||
this.fill(keyConfig);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await p;
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return pool.run(keyConfig);
|
||||
}
|
||||
}
|
||||
|
||||
const challengeCache = new InstrumentationChallengeCache();
|
||||
|
||||
export function warmForConfigs(keyConfigs) {
|
||||
for (const kc of keyConfigs) {
|
||||
challengeCache.fill(kc);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateInstrumentationChallenge(keyConfig = {}) {
|
||||
return pool.run(keyConfig);
|
||||
return challengeCache.get(keyConfig);
|
||||
}
|
||||
|
||||
export function verifyInstrumentationResult(challengeMeta, payload) {
|
||||
|
||||
+21
-21
@@ -1,29 +1,29 @@
|
||||
export const ratelimitGenerator = (req, server) => {
|
||||
if (process.env.RATELIMIT_IP_HEADER) {
|
||||
const header = process.env.RATELIMIT_IP_HEADER;
|
||||
const ip = req.headers.get(header) || req.headers.get(header.toLowerCase());
|
||||
if (process.env.RATELIMIT_IP_HEADER) {
|
||||
const header = process.env.RATELIMIT_IP_HEADER;
|
||||
const ip = req.headers.get(header) || req.headers.get(header.toLowerCase());
|
||||
|
||||
if (ip) {
|
||||
return ip.split(",")[0].trim();
|
||||
}
|
||||
if (ip) {
|
||||
return ip.split(",")[0].trim();
|
||||
}
|
||||
|
||||
console.error(
|
||||
`⚠️ [ratelimit] Unable to find the IP in the header "${header}". Make sure to set the RATELIMIT_IP_HEADER env variable \n to a header which returns the user's IP.`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
console.error(
|
||||
`⚠️ [ratelimit] Unable to find the IP in the header "${header}". Make sure to set the RATELIMIT_IP_HEADER env variable \n to a header which returns the user's IP.`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
const ip = server?.requestIP(req)?.address;
|
||||
const ip = server?.requestIP(req)?.address;
|
||||
|
||||
if (!server || !req || !ip) {
|
||||
if (process.env.HIDE_RATELIMIT_IP_WARNING !== "true") {
|
||||
console.warn(
|
||||
`⚠️ [ratelimit] Unable to determine client IP, rate limiting disabled. If you're running locally, it should be safe \n to ignore this warning. Otherwise, make sure to set the RATELIMIT_IP_HEADER env variable to a header \n which returns the user's IP. Hide this warning with env.HIDE_RATELIMIT_IP_WARNING=true`,
|
||||
);
|
||||
}
|
||||
if (!server || !req || !ip) {
|
||||
if (process.env.HIDE_RATELIMIT_IP_WARNING !== "true") {
|
||||
console.warn(
|
||||
`⚠️ [ratelimit] Unable to determine client IP, rate limiting disabled. If you're running locally, it should be safe \n to ignore this warning. Otherwise, make sure to set the RATELIMIT_IP_HEADER env variable to a header \n which returns the user's IP. Hide this warning with env.HIDE_RATELIMIT_IP_WARNING=true`,
|
||||
);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return ip ?? "";
|
||||
return ip ?? "";
|
||||
};
|
||||
|
||||
@@ -95,10 +95,11 @@ export const server = new Elysia({
|
||||
.replace(/\+/g, "")
|
||||
.replace(/\//g, "")
|
||||
.replace(/=+$/, "");
|
||||
const jwtSecret = randomBytes(32).toString("base64url");
|
||||
|
||||
await db`
|
||||
INSERT INTO keys (siteKey, name, secretHash, config, created)
|
||||
VALUES (${siteKey}, ${body?.name || siteKey}, ${await Bun.password.hash(secretKey)}, ${JSON.stringify(keyDefaults)}, ${Date.now()})
|
||||
INSERT INTO keys (siteKey, name, secretHash, jwtSecret, config, created)
|
||||
VALUES (${siteKey}, ${body?.name || siteKey}, ${await Bun.password.hash(secretKey)}, ${jwtSecret}, ${JSON.stringify(keyDefaults)}, ${Date.now()})
|
||||
`;
|
||||
|
||||
return {
|
||||
|
||||
+21
-21
@@ -10,41 +10,41 @@ const packageName = "cap_wasm";
|
||||
|
||||
console.log(`Cleaning old build directories...`);
|
||||
try {
|
||||
fs.rmdirSync(nodeOutDir);
|
||||
fs.rmdirSync(nodeOutDir);
|
||||
} catch {}
|
||||
try {
|
||||
fs.rmdirSync(browserOutDir);
|
||||
fs.rmdirSync(browserOutDir);
|
||||
} catch {}
|
||||
|
||||
console.log(`\n Building for Node...`);
|
||||
execSync(
|
||||
`wasm-pack build "${rustSrcDir}" --target nodejs --out-dir "${nodeOutDir}" --out-name "${packageName}"`,
|
||||
{ stdio: "inherit" },
|
||||
`wasm-pack build "${rustSrcDir}" --target nodejs --out-dir "${nodeOutDir}" --out-name "${packageName}"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
console.log(`\n Building for web...`);
|
||||
execSync(
|
||||
`wasm-pack build "${rustSrcDir}" --target web --out-dir "${browserOutDir}" --out-name "${packageName}"`,
|
||||
{ stdio: "inherit" },
|
||||
`wasm-pack build "${rustSrcDir}" --target web --out-dir "${browserOutDir}" --out-name "${packageName}"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
console.log(`\n Removing .gitignore...`);
|
||||
|
||||
[browserOutDir, nodeOutDir].forEach((dir) => {
|
||||
try {
|
||||
fs.rmSync(path.join(dir, ".gitignore"));
|
||||
} catch {}
|
||||
try {
|
||||
fs.rmSync(path.join(dir, ".gitignore"));
|
||||
} catch {}
|
||||
});
|
||||
|
||||
console.log(`\n Minifing loaders...`);
|
||||
|
||||
await Promise.all(
|
||||
[browserOutDir, nodeOutDir].map(async (dir) => {
|
||||
const loaderPath = path.join(dir, "cap_wasm.js");
|
||||
const code = fs.readFileSync(loaderPath, "utf8");
|
||||
const result = await minify(code);
|
||||
fs.writeFileSync(loaderPath, result.code);
|
||||
}),
|
||||
[browserOutDir, nodeOutDir].map(async (dir) => {
|
||||
const loaderPath = path.join(dir, "cap_wasm.js");
|
||||
const code = fs.readFileSync(loaderPath, "utf8");
|
||||
const result = await minify(code);
|
||||
fs.writeFileSync(loaderPath, result.code);
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`\n🎉 All builds finished successfully!\n`);
|
||||
@@ -52,7 +52,7 @@ console.log(`\n🎉 All builds finished successfully!\n`);
|
||||
const doTest = prompt("test build? (y/N):").toLowerCase() === "y";
|
||||
|
||||
if (!doTest) {
|
||||
process.exit(0);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n test...`);
|
||||
@@ -60,17 +60,17 @@ execSync(`bun ${path.join("test", "node.js")}`, { stdio: "inherit" });
|
||||
|
||||
console.log(`\n testing odd difficulty...`);
|
||||
execSync(`bun ${path.join("test", "node_odd_difficulty.js")}`, {
|
||||
stdio: "inherit",
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
console.log(`\n test finished!`);
|
||||
|
||||
const doPublish = prompt("publish build? (y/N):").toLowerCase() === "y";
|
||||
if (!doPublish) {
|
||||
process.exit(0);
|
||||
process.exit(0);
|
||||
}
|
||||
Bun.spawn({
|
||||
cmd: ["bun", "publish", "--access", "public"],
|
||||
cwd: "./src",
|
||||
stdout: "inherit",
|
||||
cmd: ["bun", "publish", "--access", "public"],
|
||||
cwd: "./src",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
"dependencies": {
|
||||
"terser": "^5.43.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
# Cap WASM
|
||||
# Cap WASM
|
||||
|
||||
Vendored
+20
-15
@@ -15,21 +15,26 @@ export interface InitOutput {
|
||||
|
||||
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||
/**
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init(
|
||||
module_or_path?:
|
||||
| { module_or_path: InitInput | Promise<InitInput> }
|
||||
| InitInput
|
||||
| Promise<InitInput>,
|
||||
): Promise<InitOutput>;
|
||||
|
||||
@@ -1 +1,138 @@
|
||||
let wasm,WASM_VECTOR_LEN=0,cachedUint8ArrayMemory0=null;function getUint8ArrayMemory0(){return null!==cachedUint8ArrayMemory0&&0!==cachedUint8ArrayMemory0.byteLength||(cachedUint8ArrayMemory0=new Uint8Array(wasm.memory.buffer)),cachedUint8ArrayMemory0}const cachedTextEncoder="undefined"!=typeof TextEncoder?new TextEncoder("utf-8"):{encode:()=>{throw Error("TextEncoder not available")}},encodeString="function"==typeof cachedTextEncoder.encodeInto?function(e,t){return cachedTextEncoder.encodeInto(e,t)}:function(e,t){const n=cachedTextEncoder.encode(e);return t.set(n),{read:e.length,written:n.length}};function passStringToWasm0(e,t,n){if(void 0===n){const n=cachedTextEncoder.encode(e),i=t(n.length,1)>>>0;return getUint8ArrayMemory0().subarray(i,i+n.length).set(n),WASM_VECTOR_LEN=n.length,i}let i=e.length,o=t(i,1)>>>0;const r=getUint8ArrayMemory0();let a=0;for(;a<i;a++){const t=e.charCodeAt(a);if(t>127)break;r[o+a]=t}if(a!==i){0!==a&&(e=e.slice(a)),o=n(o,i,i=a+3*e.length,1)>>>0;const t=getUint8ArrayMemory0().subarray(o+a,o+i);a+=encodeString(e,t).written,o=n(o,i,a,1)>>>0}return WASM_VECTOR_LEN=a,o}export function solve_pow(e,t){const n=passStringToWasm0(e,wasm.__wbindgen_malloc,wasm.__wbindgen_realloc),i=WASM_VECTOR_LEN,o=passStringToWasm0(t,wasm.__wbindgen_malloc,wasm.__wbindgen_realloc),r=WASM_VECTOR_LEN,a=wasm.solve_pow(n,i,o,r);return BigInt.asUintN(64,a)}async function __wbg_load(e,t){if("function"==typeof Response&&e instanceof Response){if("function"==typeof WebAssembly.instantiateStreaming)try{return await WebAssembly.instantiateStreaming(e,t)}catch(t){if("application/wasm"==e.headers.get("Content-Type"))throw t;console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",t)}const n=await e.arrayBuffer();return await WebAssembly.instantiate(n,t)}{const n=await WebAssembly.instantiate(e,t);return n instanceof WebAssembly.Instance?{instance:n,module:e}:n}}function __wbg_get_imports(){const e={wbg:{}};return e.wbg.__wbindgen_init_externref_table=function(){const e=wasm.__wbindgen_export_0,t=e.grow(4);e.set(0,void 0),e.set(t+0,void 0),e.set(t+1,null),e.set(t+2,!0),e.set(t+3,!1)},e}function __wbg_init_memory(e,t){}function __wbg_finalize_init(e,t){return wasm=e.exports,__wbg_init.__wbindgen_wasm_module=t,cachedUint8ArrayMemory0=null,wasm.__wbindgen_start(),wasm}function initSync(e){if(void 0!==wasm)return wasm;void 0!==e&&(Object.getPrototypeOf(e)===Object.prototype?({module:e}=e):console.warn("using deprecated parameters for `initSync()`; pass a single object instead"));const t=__wbg_get_imports();__wbg_init_memory(t),e instanceof WebAssembly.Module||(e=new WebAssembly.Module(e));return __wbg_finalize_init(new WebAssembly.Instance(e,t),e)}async function __wbg_init(e){if(void 0!==wasm)return wasm;void 0!==e&&(Object.getPrototypeOf(e)===Object.prototype?({module_or_path:e}=e):console.warn("using deprecated parameters for the initialization function; pass a single object instead")),void 0===e&&(e=new URL("cap_wasm_bg.wasm",import.meta.url));const t=__wbg_get_imports();("string"==typeof e||"function"==typeof Request&&e instanceof Request||"function"==typeof URL&&e instanceof URL)&&(e=fetch(e)),__wbg_init_memory(t);const{instance:n,module:i}=await __wbg_load(await e,t);return __wbg_finalize_init(n,i)}export{initSync};export default __wbg_init;
|
||||
let wasm,
|
||||
WASM_VECTOR_LEN = 0,
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
return (
|
||||
(null !== cachedUint8ArrayMemory0 && 0 !== cachedUint8ArrayMemory0.byteLength) ||
|
||||
(cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer)),
|
||||
cachedUint8ArrayMemory0
|
||||
);
|
||||
}
|
||||
const cachedTextEncoder =
|
||||
"undefined" != typeof TextEncoder
|
||||
? new TextEncoder("utf-8")
|
||||
: {
|
||||
encode: () => {
|
||||
throw Error("TextEncoder not available");
|
||||
},
|
||||
},
|
||||
encodeString =
|
||||
"function" == typeof cachedTextEncoder.encodeInto
|
||||
? function (e, t) {
|
||||
return cachedTextEncoder.encodeInto(e, t);
|
||||
}
|
||||
: function (e, t) {
|
||||
const n = cachedTextEncoder.encode(e);
|
||||
return (t.set(n), { read: e.length, written: n.length });
|
||||
};
|
||||
function passStringToWasm0(e, t, n) {
|
||||
if (void 0 === n) {
|
||||
const n = cachedTextEncoder.encode(e),
|
||||
i = t(n.length, 1) >>> 0;
|
||||
return (
|
||||
getUint8ArrayMemory0()
|
||||
.subarray(i, i + n.length)
|
||||
.set(n),
|
||||
(WASM_VECTOR_LEN = n.length),
|
||||
i
|
||||
);
|
||||
}
|
||||
let i = e.length,
|
||||
o = t(i, 1) >>> 0;
|
||||
const r = getUint8ArrayMemory0();
|
||||
let a = 0;
|
||||
for (; a < i; a++) {
|
||||
const t = e.charCodeAt(a);
|
||||
if (t > 127) break;
|
||||
r[o + a] = t;
|
||||
}
|
||||
if (a !== i) {
|
||||
(0 !== a && (e = e.slice(a)), (o = n(o, i, (i = a + 3 * e.length), 1) >>> 0));
|
||||
const t = getUint8ArrayMemory0().subarray(o + a, o + i);
|
||||
((a += encodeString(e, t).written), (o = n(o, i, a, 1) >>> 0));
|
||||
}
|
||||
return ((WASM_VECTOR_LEN = a), o);
|
||||
}
|
||||
export function solve_pow(e, t) {
|
||||
const n = passStringToWasm0(e, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc),
|
||||
i = WASM_VECTOR_LEN,
|
||||
o = passStringToWasm0(t, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc),
|
||||
r = WASM_VECTOR_LEN,
|
||||
a = wasm.solve_pow(n, i, o, r);
|
||||
return BigInt.asUintN(64, a);
|
||||
}
|
||||
async function __wbg_load(e, t) {
|
||||
if ("function" == typeof Response && e instanceof Response) {
|
||||
if ("function" == typeof WebAssembly.instantiateStreaming)
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(e, t);
|
||||
} catch (t) {
|
||||
if ("application/wasm" == e.headers.get("Content-Type")) throw t;
|
||||
console.warn(
|
||||
"`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",
|
||||
t,
|
||||
);
|
||||
}
|
||||
const n = await e.arrayBuffer();
|
||||
return await WebAssembly.instantiate(n, t);
|
||||
}
|
||||
{
|
||||
const n = await WebAssembly.instantiate(e, t);
|
||||
return n instanceof WebAssembly.Instance ? { instance: n, module: e } : n;
|
||||
}
|
||||
}
|
||||
function __wbg_get_imports() {
|
||||
const e = { wbg: {} };
|
||||
return (
|
||||
(e.wbg.__wbindgen_init_externref_table = function () {
|
||||
const e = wasm.__wbindgen_export_0,
|
||||
t = e.grow(4);
|
||||
(e.set(0, void 0),
|
||||
e.set(t + 0, void 0),
|
||||
e.set(t + 1, null),
|
||||
e.set(t + 2, !0),
|
||||
e.set(t + 3, !1));
|
||||
}),
|
||||
e
|
||||
);
|
||||
}
|
||||
function __wbg_init_memory(e, t) {}
|
||||
function __wbg_finalize_init(e, t) {
|
||||
return (
|
||||
(wasm = e.exports),
|
||||
(__wbg_init.__wbindgen_wasm_module = t),
|
||||
(cachedUint8ArrayMemory0 = null),
|
||||
wasm.__wbindgen_start(),
|
||||
wasm
|
||||
);
|
||||
}
|
||||
function initSync(e) {
|
||||
if (void 0 !== wasm) return wasm;
|
||||
void 0 !== e &&
|
||||
(Object.getPrototypeOf(e) === Object.prototype
|
||||
? ({ module: e } = e)
|
||||
: console.warn("using deprecated parameters for `initSync()`; pass a single object instead"));
|
||||
const t = __wbg_get_imports();
|
||||
(__wbg_init_memory(t), e instanceof WebAssembly.Module || (e = new WebAssembly.Module(e)));
|
||||
return __wbg_finalize_init(new WebAssembly.Instance(e, t), e);
|
||||
}
|
||||
async function __wbg_init(e) {
|
||||
if (void 0 !== wasm) return wasm;
|
||||
(void 0 !== e &&
|
||||
(Object.getPrototypeOf(e) === Object.prototype
|
||||
? ({ module_or_path: e } = e)
|
||||
: console.warn(
|
||||
"using deprecated parameters for the initialization function; pass a single object instead",
|
||||
)),
|
||||
void 0 === e && (e = new URL("cap_wasm_bg.wasm", import.meta.url)));
|
||||
const t = __wbg_get_imports();
|
||||
(("string" == typeof e ||
|
||||
("function" == typeof Request && e instanceof Request) ||
|
||||
("function" == typeof URL && e instanceof URL)) &&
|
||||
(e = fetch(e)),
|
||||
__wbg_init_memory(t));
|
||||
const { instance: n, module: i } = await __wbg_load(await e, t);
|
||||
return __wbg_finalize_init(n, i);
|
||||
}
|
||||
export { initSync };
|
||||
export default __wbg_init;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "cap_wasm",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"cap_wasm_bg.wasm",
|
||||
"cap_wasm.js",
|
||||
"cap_wasm.d.ts"
|
||||
],
|
||||
"main": "cap_wasm.js",
|
||||
"types": "cap_wasm.d.ts",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"main": "cap_wasm.js",
|
||||
"types": "cap_wasm.d.ts"
|
||||
}
|
||||
|
||||
@@ -1 +1,73 @@
|
||||
let wasm,imports={};imports.__wbindgen_placeholder__=module.exports;const{TextEncoder:TextEncoder}=require("util");let WASM_VECTOR_LEN=0,cachedUint8ArrayMemory0=null;function getUint8ArrayMemory0(){return null!==cachedUint8ArrayMemory0&&0!==cachedUint8ArrayMemory0.byteLength||(cachedUint8ArrayMemory0=new Uint8Array(wasm.memory.buffer)),cachedUint8ArrayMemory0}let cachedTextEncoder=new TextEncoder("utf-8");const encodeString="function"==typeof cachedTextEncoder.encodeInto?function(e,n){return cachedTextEncoder.encodeInto(e,n)}:function(e,n){const t=cachedTextEncoder.encode(e);return n.set(t),{read:e.length,written:t.length}};function passStringToWasm0(e,n,t){if(void 0===t){const t=cachedTextEncoder.encode(e),r=n(t.length,1)>>>0;return getUint8ArrayMemory0().subarray(r,r+t.length).set(t),WASM_VECTOR_LEN=t.length,r}let r=e.length,o=n(r,1)>>>0;const s=getUint8ArrayMemory0();let a=0;for(;a<r;a++){const n=e.charCodeAt(a);if(n>127)break;s[o+a]=n}if(a!==r){0!==a&&(e=e.slice(a)),o=t(o,r,r=a+3*e.length,1)>>>0;const n=getUint8ArrayMemory0().subarray(o+a,o+r);a+=encodeString(e,n).written,o=t(o,r,a,1)>>>0}return WASM_VECTOR_LEN=a,o}module.exports.solve_pow=function(e,n){const t=passStringToWasm0(e,wasm.__wbindgen_malloc,wasm.__wbindgen_realloc),r=WASM_VECTOR_LEN,o=passStringToWasm0(n,wasm.__wbindgen_malloc,wasm.__wbindgen_realloc),s=WASM_VECTOR_LEN,a=wasm.solve_pow(t,r,o,s);return BigInt.asUintN(64,a)},module.exports.__wbindgen_init_externref_table=function(){const e=wasm.__wbindgen_export_0,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)};const path=require("path").join(__dirname,"cap_wasm_bg.wasm"),bytes=require("fs").readFileSync(path),wasmModule=new WebAssembly.Module(bytes),wasmInstance=new WebAssembly.Instance(wasmModule,imports);wasm=wasmInstance.exports,module.exports.__wasm=wasm,wasm.__wbindgen_start();
|
||||
let wasm,
|
||||
imports = {};
|
||||
imports.__wbindgen_placeholder__ = module.exports;
|
||||
const { TextEncoder: TextEncoder } = require("util");
|
||||
let WASM_VECTOR_LEN = 0,
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
return (
|
||||
(null !== cachedUint8ArrayMemory0 && 0 !== cachedUint8ArrayMemory0.byteLength) ||
|
||||
(cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer)),
|
||||
cachedUint8ArrayMemory0
|
||||
);
|
||||
}
|
||||
let cachedTextEncoder = new TextEncoder("utf-8");
|
||||
const encodeString =
|
||||
"function" == typeof cachedTextEncoder.encodeInto
|
||||
? function (e, n) {
|
||||
return cachedTextEncoder.encodeInto(e, n);
|
||||
}
|
||||
: function (e, n) {
|
||||
const t = cachedTextEncoder.encode(e);
|
||||
return (n.set(t), { read: e.length, written: t.length });
|
||||
};
|
||||
function passStringToWasm0(e, n, t) {
|
||||
if (void 0 === t) {
|
||||
const t = cachedTextEncoder.encode(e),
|
||||
r = n(t.length, 1) >>> 0;
|
||||
return (
|
||||
getUint8ArrayMemory0()
|
||||
.subarray(r, r + t.length)
|
||||
.set(t),
|
||||
(WASM_VECTOR_LEN = t.length),
|
||||
r
|
||||
);
|
||||
}
|
||||
let r = e.length,
|
||||
o = n(r, 1) >>> 0;
|
||||
const s = getUint8ArrayMemory0();
|
||||
let a = 0;
|
||||
for (; a < r; a++) {
|
||||
const n = e.charCodeAt(a);
|
||||
if (n > 127) break;
|
||||
s[o + a] = n;
|
||||
}
|
||||
if (a !== r) {
|
||||
(0 !== a && (e = e.slice(a)), (o = t(o, r, (r = a + 3 * e.length), 1) >>> 0));
|
||||
const n = getUint8ArrayMemory0().subarray(o + a, o + r);
|
||||
((a += encodeString(e, n).written), (o = t(o, r, a, 1) >>> 0));
|
||||
}
|
||||
return ((WASM_VECTOR_LEN = a), o);
|
||||
}
|
||||
((module.exports.solve_pow = function (e, n) {
|
||||
const t = passStringToWasm0(e, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc),
|
||||
r = WASM_VECTOR_LEN,
|
||||
o = passStringToWasm0(n, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc),
|
||||
s = WASM_VECTOR_LEN,
|
||||
a = wasm.solve_pow(t, r, o, s);
|
||||
return BigInt.asUintN(64, a);
|
||||
}),
|
||||
(module.exports.__wbindgen_init_externref_table = function () {
|
||||
const e = wasm.__wbindgen_export_0,
|
||||
n = e.grow(4);
|
||||
(e.set(0, void 0),
|
||||
e.set(n + 0, void 0),
|
||||
e.set(n + 1, null),
|
||||
e.set(n + 2, !0),
|
||||
e.set(n + 3, !1));
|
||||
}));
|
||||
const path = require("path").join(__dirname, "cap_wasm_bg.wasm"),
|
||||
bytes = require("fs").readFileSync(path),
|
||||
wasmModule = new WebAssembly.Module(bytes),
|
||||
wasmInstance = new WebAssembly.Instance(wasmModule, imports);
|
||||
((wasm = wasmInstance.exports), (module.exports.__wasm = wasm), wasm.__wbindgen_start());
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
],
|
||||
"main": "cap_wasm.js",
|
||||
"types": "cap_wasm.d.ts"
|
||||
}
|
||||
}
|
||||
|
||||
+22
-22
@@ -2,56 +2,56 @@
|
||||
"name": "@cap.js/wasm",
|
||||
"version": "0.0.6",
|
||||
"description": "WASM solver for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.",
|
||||
"main": "./node/cap_wasm.js",
|
||||
"keywords": [
|
||||
"security",
|
||||
"detection",
|
||||
"prevention",
|
||||
"defense",
|
||||
"protection",
|
||||
"algorithm",
|
||||
"anti-abuse",
|
||||
"anti-automation",
|
||||
"anti-bot",
|
||||
"anti-ddos",
|
||||
"anti-dos",
|
||||
"anti-exploitation",
|
||||
"anti-spam",
|
||||
"anti-scraping",
|
||||
"anti-spam",
|
||||
"attack-mitigation",
|
||||
"protocol",
|
||||
"bots",
|
||||
"captcha-alternative",
|
||||
"client-server",
|
||||
"computational-puzzle",
|
||||
"complexity",
|
||||
"puzzle",
|
||||
"computational-puzzle",
|
||||
"crypto",
|
||||
"cryptographic",
|
||||
"algorithm",
|
||||
"cybersecurity",
|
||||
"ddos",
|
||||
"defense",
|
||||
"detection",
|
||||
"filtering",
|
||||
"hashcash",
|
||||
"hcaptcha",
|
||||
"captcha-alternative",
|
||||
"verification",
|
||||
"invisible",
|
||||
"pow",
|
||||
"prevention",
|
||||
"proof-of-work",
|
||||
"protection",
|
||||
"protocol",
|
||||
"puzzle",
|
||||
"recaptcha",
|
||||
"security",
|
||||
"spam",
|
||||
"bots",
|
||||
"filtering",
|
||||
"turing-test"
|
||||
"turing-test",
|
||||
"verification"
|
||||
],
|
||||
"author": "Tiago",
|
||||
"homepage": "https://github.com/tiagozip/cap#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagozip/cap/issues"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Tiago",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagozip/cap.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagozip/cap/issues"
|
||||
},
|
||||
"main": "./node/cap_wasm.js",
|
||||
"scripts": {
|
||||
"npm:publish": "bun publish --access public"
|
||||
},
|
||||
"homepage": "https://github.com/tiagozip/cap#readme"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,4 @@ crate-type = ["cdylib"]
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
hex = "0.4"
|
||||
|
||||
Vendored
+15
-40
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -12,9 +12,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4">
|
||||
<button id="solveButton" class="btn btn-primary mb-3">
|
||||
Solve challenges
|
||||
</button>
|
||||
<button id="solveButton" class="btn btn-primary mb-3">Solve challenges</button>
|
||||
<div id="progress" class="mb-3">Status: Idle</div>
|
||||
|
||||
<h2>Results:</h2>
|
||||
@@ -69,15 +67,13 @@
|
||||
let initError = null;
|
||||
let initPromise = null;
|
||||
|
||||
const absoluteWasmWrapperUrl = new URL(relativeWasmWrapperPath, baseURL)
|
||||
.href;
|
||||
const absoluteWasmWrapperUrl = new URL(relativeWasmWrapperPath, baseURL).href;
|
||||
|
||||
initPromise = import(absoluteWasmWrapperUrl)
|
||||
.then((wasmModule) => {
|
||||
return wasmModule.default().then((instance) => {
|
||||
solve_pow_function = (
|
||||
instance && instance.exports ? instance.exports : wasmModule
|
||||
).solve_pow;
|
||||
solve_pow_function = (instance && instance.exports ? instance.exports : wasmModule)
|
||||
.solve_pow;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -151,18 +147,14 @@
|
||||
data.challengeIndex === -1 ? "??" : data.challengeIndex + 1
|
||||
} errored</strong> ${
|
||||
data.salt ? `${data.salt}:${data.target}` : ""
|
||||
}<br><pre class="bg-secondary bg-opacity-10 p-2 rounded mt-2 mb-0">${
|
||||
data.error
|
||||
}</pre>`;
|
||||
}<br><pre class="bg-secondary bg-opacity-10 p-2 rounded mt-2 mb-0">${data.error}</pre>`;
|
||||
} else {
|
||||
item.classList.add("list-group-item-success");
|
||||
item.innerHTML = `<strong>Challenge #${
|
||||
data.challengeIndex + 1
|
||||
} solved</strong> with nonce <span class="text-success fw-bold">${
|
||||
data.nonce
|
||||
}</span><br><small>${data.salt}:${data.target}<br>Took ${
|
||||
data.durationMs
|
||||
} ms</small>`;
|
||||
}</span><br><small>${data.salt}:${data.target}<br>Took ${data.durationMs} ms</small>`;
|
||||
}
|
||||
|
||||
resultsDiv.appendChild(item);
|
||||
@@ -186,7 +178,7 @@
|
||||
console.log(
|
||||
`Assigning challenge index ${currentIndex} (Task ${
|
||||
currentIndex + 1
|
||||
}/${challenges.length}) to a worker.`
|
||||
}/${challenges.length}) to a worker.`,
|
||||
);
|
||||
|
||||
const challengeData = challenges[currentIndex];
|
||||
@@ -223,19 +215,13 @@
|
||||
try {
|
||||
let baseURL;
|
||||
const currentPath = window.location.pathname;
|
||||
const directoryPath = currentPath.substring(
|
||||
0,
|
||||
currentPath.lastIndexOf("/") + 1
|
||||
);
|
||||
const directoryPath = currentPath.substring(0, currentPath.lastIndexOf("/") + 1);
|
||||
baseURL = window.location.origin + directoryPath;
|
||||
if (!baseURL.endsWith("/")) {
|
||||
baseURL += "/";
|
||||
}
|
||||
|
||||
const finalWorkerCode = workerCodeTemplate.replace(
|
||||
/\$\{baseURL\}/g,
|
||||
baseURL
|
||||
);
|
||||
const finalWorkerCode = workerCodeTemplate.replace(/\$\{baseURL\}/g, baseURL);
|
||||
|
||||
const blob = new Blob([finalWorkerCode], {
|
||||
type: "application/javascript",
|
||||
@@ -246,20 +232,14 @@
|
||||
const worker = new Worker(workerBlobUrl, { type: "module" });
|
||||
|
||||
worker.onmessage = (event) => {
|
||||
if (
|
||||
event.data &&
|
||||
typeof event.data.challengeIndex !== "undefined"
|
||||
) {
|
||||
if (event.data && typeof event.data.challengeIndex !== "undefined") {
|
||||
challengesProcessed++;
|
||||
allResults[event.data.challengeIndex] = event.data;
|
||||
displayResult(event.data);
|
||||
updateProgress();
|
||||
|
||||
if (challengesProcessed === challenges.length) {
|
||||
const totalTime = (
|
||||
(performance.now() - startTime) /
|
||||
1000
|
||||
).toFixed(2);
|
||||
const totalTime = ((performance.now() - startTime) / 1000).toFixed(2);
|
||||
progressDiv.textContent = `Status: All ${challenges.length} challenges processed in ${totalTime} seconds.`;
|
||||
progressDiv.classList.remove("text-danger", "fw-bold");
|
||||
cleanupWorkers();
|
||||
@@ -274,7 +254,7 @@
|
||||
|
||||
console.error(
|
||||
`Main: Initialization/Setup error from Worker ${i + 1}:`,
|
||||
event.data.error
|
||||
event.data.error,
|
||||
);
|
||||
displayResult({
|
||||
challengeIndex: -1,
|
||||
@@ -288,17 +268,12 @@
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
console.error(
|
||||
`Main: Error reported from Worker ${i + 1}:`,
|
||||
error
|
||||
);
|
||||
console.error(`Main: Error reported from Worker ${i + 1}:`, error);
|
||||
displayResult({
|
||||
challengeIndex: -1,
|
||||
error: `Worker ${i + 1} ${error.message || "Unknown error"}`,
|
||||
});
|
||||
progressDiv.textContent = `Status: Error in Worker ${
|
||||
i + 1
|
||||
}. Stopping.`;
|
||||
progressDiv.textContent = `Status: Error in Worker ${i + 1}. Stopping.`;
|
||||
progressDiv.classList.add("text-danger", "fw-bold");
|
||||
cleanupWorkers();
|
||||
solveButton.disabled = false;
|
||||
|
||||
+27
-27
@@ -2,40 +2,40 @@ import { performance } from "node:perf_hooks";
|
||||
import { solve_pow } from "../src/node/cap_wasm.js";
|
||||
|
||||
const challenges = [
|
||||
["e455cea65e98bc3c36287f43769da211", "dceb"],
|
||||
["fb8d25f6abac5aa9b6360051f37e010b", "93f1"],
|
||||
["91ef47db578fbeb2565d3f9c82bb7960", "3698"],
|
||||
["b7ad7667486a691cda8ef297098f64a7", "d72a"],
|
||||
["1aca3fb7cef7a2be0dee563ed4136758", "3b58"],
|
||||
["d9ec39af92b430e5a329274d8aa58fa8", "e1d3"],
|
||||
["781a3cc9217d73c908a321d3fdabd62f", "22c6"],
|
||||
["e37a0752c9ac2f3d2517747fde373ac9", "f6f1"],
|
||||
["bba070197569f322beda5b240f639a95", "4751"],
|
||||
["89297515aeac646bee9653ba405e0beb", "a7de"],
|
||||
["444571a0d5039c15be6141d6cd8434f9", "a783"],
|
||||
["ba75f2bf8e9b92cc32caa17237a52d14", "7e30"],
|
||||
["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf"],
|
||||
["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5"],
|
||||
["308758072931bb3b254a7b1ed351d04a", "3e49"],
|
||||
["724f89bb167db4b881e1dc7b0949ac8f", "b82e"],
|
||||
["8b79506e4630de15be225c18623eff65", "f0e5"],
|
||||
["0c21ade6e63a4e37b13cb8b087f31863", "65c9"],
|
||||
["e455cea65e98bc3c36287f43769da211", "dceb"],
|
||||
["fb8d25f6abac5aa9b6360051f37e010b", "93f1"],
|
||||
["91ef47db578fbeb2565d3f9c82bb7960", "3698"],
|
||||
["b7ad7667486a691cda8ef297098f64a7", "d72a"],
|
||||
["1aca3fb7cef7a2be0dee563ed4136758", "3b58"],
|
||||
["d9ec39af92b430e5a329274d8aa58fa8", "e1d3"],
|
||||
["781a3cc9217d73c908a321d3fdabd62f", "22c6"],
|
||||
["e37a0752c9ac2f3d2517747fde373ac9", "f6f1"],
|
||||
["bba070197569f322beda5b240f639a95", "4751"],
|
||||
["89297515aeac646bee9653ba405e0beb", "a7de"],
|
||||
["444571a0d5039c15be6141d6cd8434f9", "a783"],
|
||||
["ba75f2bf8e9b92cc32caa17237a52d14", "7e30"],
|
||||
["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf"],
|
||||
["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5"],
|
||||
["308758072931bb3b254a7b1ed351d04a", "3e49"],
|
||||
["724f89bb167db4b881e1dc7b0949ac8f", "b82e"],
|
||||
["8b79506e4630de15be225c18623eff65", "f0e5"],
|
||||
["0c21ade6e63a4e37b13cb8b087f31863", "65c9"],
|
||||
];
|
||||
|
||||
async function runSolverTest() {
|
||||
const startTime = performance.now();
|
||||
const startTime = performance.now();
|
||||
|
||||
for (let i = 0; i < challenges.length; i++) {
|
||||
const [salt, target] = challenges[i];
|
||||
const nonce = solve_pow(salt, target);
|
||||
for (let i = 0; i < challenges.length; i++) {
|
||||
const [salt, target] = challenges[i];
|
||||
const nonce = solve_pow(salt, target);
|
||||
|
||||
console.log(`[${i + 1}/${challenges.length}] ${salt}:${target}:${nonce}`);
|
||||
}
|
||||
console.log(`[${i + 1}/${challenges.length}] ${salt}:${target}:${nonce}`);
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const totalTime = (endTime - startTime) / 1000;
|
||||
const endTime = performance.now();
|
||||
const totalTime = (endTime - startTime) / 1000;
|
||||
|
||||
console.log(`Solved challenges in ${totalTime.toFixed(3)}s`);
|
||||
console.log(`Solved challenges in ${totalTime.toFixed(3)}s`);
|
||||
}
|
||||
|
||||
runSolverTest();
|
||||
|
||||
@@ -18,6 +18,4 @@ sha256(${salt}${nonce})
|
||||
should start with ${target}
|
||||
is ${actualHash.slice(0, target.length)} (${actualHash})
|
||||
|
||||
${
|
||||
actualHash.slice(0, target.length) === target ? "✅ success!" : "❌ invalid"
|
||||
}`);
|
||||
${actualHash.slice(0, target.length) === target ? "✅ success!" : "❌ invalid"}`);
|
||||
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "widget",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.31.1",
|
||||
"playwright": "^1.52.0",
|
||||
"terser": "^5.43.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.31.1",
|
||||
"playwright": "^1.52.0",
|
||||
"terser": "^5.43.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
Client-side widget for Cap, the self-hosted CAPTCHA for the modern web.
|
||||
|
||||
**[Learn more](https://github.com/tiagozip/cap)**
|
||||
**[Learn more](https://github.com/tiagozip/cap)**
|
||||
|
||||
Vendored
+408
-1
File diff suppressed because one or more lines are too long
Vendored
+12
-48
@@ -70,40 +70,16 @@ interface CapWidget extends HTMLElement {
|
||||
reset(): void;
|
||||
setWorkersCount(workers: number): void;
|
||||
|
||||
addEventListener(
|
||||
type: "progress",
|
||||
listener: (event: CapProgressEvent) => void
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "solve",
|
||||
listener: (event: CapSolveEvent) => void
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "error",
|
||||
listener: (event: CapErrorEvent) => void
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "reset",
|
||||
listener: (event: CapResetEvent) => void
|
||||
): void;
|
||||
addEventListener(type: "progress", listener: (event: CapProgressEvent) => void): void;
|
||||
addEventListener(type: "solve", listener: (event: CapSolveEvent) => void): void;
|
||||
addEventListener(type: "error", listener: (event: CapErrorEvent) => void): void;
|
||||
addEventListener(type: "reset", listener: (event: CapResetEvent) => void): void;
|
||||
addEventListener(type: string, listener: EventListener): void;
|
||||
|
||||
removeEventListener(
|
||||
type: "progress",
|
||||
listener: (event: CapProgressEvent) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "solve",
|
||||
listener: (event: CapSolveEvent) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "error",
|
||||
listener: (event: CapErrorEvent) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "reset",
|
||||
listener: (event: CapResetEvent) => void
|
||||
): void;
|
||||
removeEventListener(type: "progress", listener: (event: CapProgressEvent) => void): void;
|
||||
removeEventListener(type: "solve", listener: (event: CapSolveEvent) => void): void;
|
||||
removeEventListener(type: "error", listener: (event: CapErrorEvent) => void): void;
|
||||
removeEventListener(type: "reset", listener: (event: CapResetEvent) => void): void;
|
||||
removeEventListener(type: string, listener: EventListener): void;
|
||||
}
|
||||
|
||||
@@ -116,22 +92,10 @@ declare class Cap {
|
||||
solve(): Promise<SolveResult>;
|
||||
reset(): void;
|
||||
|
||||
addEventListener(
|
||||
type: "progress",
|
||||
listener: (event: CapProgressEvent) => void
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "solve",
|
||||
listener: (event: CapSolveEvent) => void
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "error",
|
||||
listener: (event: CapErrorEvent) => void
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "reset",
|
||||
listener: (event: CapResetEvent) => void
|
||||
): void;
|
||||
addEventListener(type: "progress", listener: (event: CapProgressEvent) => void): void;
|
||||
addEventListener(type: "solve", listener: (event: CapSolveEvent) => void): void;
|
||||
addEventListener(type: "error", listener: (event: CapErrorEvent) => void): void;
|
||||
addEventListener(type: "reset", listener: (event: CapResetEvent) => void): void;
|
||||
addEventListener(type: string, listener: EventListener): void;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+18
-18
@@ -1,55 +1,55 @@
|
||||
{
|
||||
"name": "@cap.js/widget",
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.38",
|
||||
"description": "Client-side widget for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.",
|
||||
"keywords": [
|
||||
"security",
|
||||
"detection",
|
||||
"prevention",
|
||||
"defense",
|
||||
"protection",
|
||||
"algorithm",
|
||||
"anti-abuse",
|
||||
"anti-automation",
|
||||
"anti-bot",
|
||||
"anti-ddos",
|
||||
"anti-dos",
|
||||
"anti-exploitation",
|
||||
"anti-spam",
|
||||
"anti-scraping",
|
||||
"anti-spam",
|
||||
"attack-mitigation",
|
||||
"protocol",
|
||||
"bots",
|
||||
"captcha-alternative",
|
||||
"client-server",
|
||||
"computational-puzzle",
|
||||
"complexity",
|
||||
"puzzle",
|
||||
"computational-puzzle",
|
||||
"crypto",
|
||||
"cryptographic",
|
||||
"algorithm",
|
||||
"cybersecurity",
|
||||
"ddos",
|
||||
"defense",
|
||||
"detection",
|
||||
"filtering",
|
||||
"hashcash",
|
||||
"hcaptcha",
|
||||
"captcha-alternative",
|
||||
"verification",
|
||||
"invisible",
|
||||
"pow",
|
||||
"prevention",
|
||||
"proof-of-work",
|
||||
"protection",
|
||||
"protocol",
|
||||
"puzzle",
|
||||
"recaptcha",
|
||||
"security",
|
||||
"spam",
|
||||
"bots",
|
||||
"filtering",
|
||||
"turing-test"
|
||||
"turing-test",
|
||||
"verification"
|
||||
],
|
||||
"homepage": "https://github.com/tiagozip/cap#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagozip/cap/issues"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Tiago",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagozip/cap.git"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Tiago",
|
||||
"type": "commonjs",
|
||||
"main": "cap.min.js",
|
||||
"types": "cap.d.ts"
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
|
||||
setTimeout(() => {
|
||||
element.onclick = null;
|
||||
handlers.forEach((h) => { return element.removeEventListener("click", h) });
|
||||
handlers.forEach((h) => {
|
||||
return element.removeEventListener("click", h);
|
||||
});
|
||||
element.onclick = (e) => handleClick(e, element, capWidget, handlers);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
element.onclick = null;
|
||||
|
||||
const offset =
|
||||
parseInt(element.getAttribute("data-cap-floating-offset")) || 8;
|
||||
const position =
|
||||
element.getAttribute("data-cap-floating-position") || "top";
|
||||
const offset = parseInt(element.getAttribute("data-cap-floating-offset")) || 8;
|
||||
const position = element.getAttribute("data-cap-floating-position") || "top";
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
Object.assign(capWidget.style, {
|
||||
@@ -45,12 +45,12 @@
|
||||
if (position === "top") {
|
||||
capWidget.style.top = `${Math.max(
|
||||
window.scrollY,
|
||||
rect.top - capWidget.offsetHeight - offset + window.scrollY
|
||||
rect.top - capWidget.offsetHeight - offset + window.scrollY,
|
||||
)}px`;
|
||||
} else {
|
||||
capWidget.style.top = `${Math.min(
|
||||
rect.bottom + offset + window.scrollY,
|
||||
window.innerHeight - capWidget.offsetHeight + window.scrollY
|
||||
window.innerHeight - capWidget.offsetHeight + window.scrollY,
|
||||
)}px`;
|
||||
}
|
||||
|
||||
@@ -82,23 +82,21 @@
|
||||
|
||||
const capWidget = document.querySelector(capWidgetSelector);
|
||||
if (!document.contains(capWidget) && !capWidget.solve) {
|
||||
throw new Error(
|
||||
`[cap floating] "${capWidgetSelector}" doesn't exist or isn't a Cap widget`
|
||||
);
|
||||
throw new Error(`[cap floating] "${capWidgetSelector}" doesn't exist or isn't a Cap widget`);
|
||||
}
|
||||
|
||||
capWidget.style.display = "none";
|
||||
const handlers = [element.onclick].filter(Boolean);
|
||||
|
||||
if (typeof getEventListeners === "function") {
|
||||
handlers.push(
|
||||
...(getEventListeners(element).click || []).map((l) => l.listener)
|
||||
);
|
||||
handlers.push(...(getEventListeners(element).click || []).map((l) => l.listener));
|
||||
}
|
||||
|
||||
if (handlers.length) {
|
||||
element.onclick = null;
|
||||
handlers.forEach((h) => { return element.removeEventListener("click", h) });
|
||||
handlers.forEach((h) => {
|
||||
return element.removeEventListener("click", h);
|
||||
});
|
||||
}
|
||||
|
||||
element.addEventListener("click", (e) => {
|
||||
@@ -119,7 +117,7 @@
|
||||
mutations.forEach((mutation) =>
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) init(node);
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
).observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
color: var(--cap-color, #212121);
|
||||
}
|
||||
|
||||
.captcha:not([data-state]):active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.captcha:hover {
|
||||
filter: brightness(98%);
|
||||
}
|
||||
|
||||
+558
-138
@@ -64,7 +64,7 @@
|
||||
|
||||
var compressed = b64ToUint8(instrBytes);
|
||||
|
||||
const scriptText = await new Promise(function (resolve, reject) {
|
||||
const scriptText = await new Promise((resolve, reject) => {
|
||||
try {
|
||||
var ds = new DecompressionStream("deflate-raw");
|
||||
var writer = ds.writable.getWriter();
|
||||
@@ -89,7 +89,7 @@
|
||||
reader.read().then(pump).catch(reject);
|
||||
writer
|
||||
.write(compressed)
|
||||
.then(function () {
|
||||
.then(() => {
|
||||
writer.close();
|
||||
})
|
||||
.catch(reject);
|
||||
@@ -98,8 +98,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
var timeout = setTimeout(function () {
|
||||
return new Promise((resolve) => {
|
||||
var timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ __timeout: true });
|
||||
}, 20000);
|
||||
@@ -125,7 +125,10 @@
|
||||
if (d.type === "cap:instr") {
|
||||
cleanup();
|
||||
if (d.blocked) {
|
||||
resolve({ __blocked: true, blockReason: d.blockReason || "automated_browser" });
|
||||
resolve({
|
||||
__blocked: true,
|
||||
blockReason: d.blockReason || "automated_browser",
|
||||
});
|
||||
} else if (d.result) {
|
||||
resolve(d.result);
|
||||
} else {
|
||||
@@ -179,8 +182,382 @@
|
||||
const prefersReducedMotion = () =>
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
||||
|
||||
const SPECULATIVE_DELAY_MS = 2500;
|
||||
const SPECULATIVE_WORKERS = 1;
|
||||
const SPECULATIVE_YIELD_MS = 120;
|
||||
|
||||
const speculative = {
|
||||
state: "idle",
|
||||
challengeResp: null,
|
||||
challenges: null,
|
||||
results: [],
|
||||
completedCount: 0,
|
||||
solvePromise: null,
|
||||
promoteFn: null,
|
||||
_listeners: [],
|
||||
pendingPromotion: null,
|
||||
token: null,
|
||||
tokenExpires: null,
|
||||
|
||||
notify() {
|
||||
for (const fn of this._listeners) fn();
|
||||
this._listeners = [];
|
||||
},
|
||||
|
||||
onSettled(fn) {
|
||||
if (this.state === "done" || this.state === "error") {
|
||||
fn();
|
||||
} else {
|
||||
this._listeners.push(fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function _resetSpeculativeState() {
|
||||
speculative.state = "idle";
|
||||
speculative.challengeResp = null;
|
||||
speculative.challenges = null;
|
||||
speculative.results = [];
|
||||
speculative.completedCount = 0;
|
||||
speculative.solvePromise = null;
|
||||
speculative.promoteFn = null;
|
||||
speculative.pendingPromotion = null;
|
||||
speculative._listeners = [];
|
||||
speculative.token = null;
|
||||
speculative.tokenExpires = null;
|
||||
|
||||
_attachInteractionListeners();
|
||||
}
|
||||
|
||||
let _speculativeTimer = null;
|
||||
|
||||
function _onFirstInteraction() {
|
||||
if (speculative.state !== "idle") return;
|
||||
speculative.state = "waiting";
|
||||
|
||||
_speculativeTimer = setTimeout(() => {
|
||||
_beginSpeculativeSolve();
|
||||
}, SPECULATIVE_DELAY_MS);
|
||||
}
|
||||
|
||||
let _currentInteractionHandler = null;
|
||||
|
||||
function _detachInteractionListeners() {
|
||||
if (_currentInteractionHandler) {
|
||||
window.removeEventListener("mousemove", _currentInteractionHandler);
|
||||
window.removeEventListener("touchstart", _currentInteractionHandler);
|
||||
window.removeEventListener("keydown", _currentInteractionHandler);
|
||||
_currentInteractionHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _attachInteractionListeners() {
|
||||
_detachInteractionListeners();
|
||||
|
||||
const handler = () => {
|
||||
_detachInteractionListeners();
|
||||
_onFirstInteraction();
|
||||
};
|
||||
_currentInteractionHandler = handler;
|
||||
window.addEventListener("mousemove", handler, { passive: true });
|
||||
window.addEventListener("touchstart", handler, { passive: true });
|
||||
window.addEventListener("keydown", handler, { passive: true });
|
||||
}
|
||||
|
||||
_attachInteractionListeners();
|
||||
|
||||
async function _beginSpeculativeSolve() {
|
||||
if (speculative.state !== "waiting") return;
|
||||
speculative.state = "fetching";
|
||||
|
||||
const widget = document.querySelector("cap-widget");
|
||||
if (!widget) {
|
||||
speculative.state = "idle";
|
||||
return;
|
||||
}
|
||||
|
||||
let apiEndpoint = widget.getAttribute("data-cap-api-endpoint");
|
||||
if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) {
|
||||
apiEndpoint = "/";
|
||||
}
|
||||
if (!apiEndpoint) {
|
||||
speculative.state = "idle";
|
||||
return;
|
||||
}
|
||||
if (!apiEndpoint.endsWith("/")) apiEndpoint += "/";
|
||||
|
||||
try {
|
||||
const raw = await capFetch(`${apiEndpoint}challenge`, { method: "POST" });
|
||||
let resp;
|
||||
try {
|
||||
resp = await raw.json();
|
||||
} catch {
|
||||
throw new Error("Failed to parse speculative challenge response");
|
||||
}
|
||||
if (resp.error) throw new Error(resp.error);
|
||||
|
||||
resp._apiEndpoint = apiEndpoint;
|
||||
speculative.challengeResp = resp;
|
||||
|
||||
const { challenge, token } = resp;
|
||||
let challenges = challenge;
|
||||
if (!Array.isArray(challenges)) {
|
||||
let i = 0;
|
||||
challenges = Array.from({ length: challenge.c }, () => {
|
||||
i++;
|
||||
return [prng(`${token}${i}`, challenge.s), prng(`${token}${i}d`, challenge.d)];
|
||||
});
|
||||
}
|
||||
speculative.challenges = challenges;
|
||||
speculative.state = "solving";
|
||||
|
||||
speculative.solvePromise = _speculativeSolveAll(challenges);
|
||||
} catch (e) {
|
||||
console.warn("[cap] speculative challenge fetch failed:", e);
|
||||
speculative.state = "error";
|
||||
speculative.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async function _speculativeSolveAll(challenges) {
|
||||
_getSharedWorkerUrl();
|
||||
|
||||
let wasmModule = null;
|
||||
try {
|
||||
wasmModule = await getWasmModule();
|
||||
} catch {}
|
||||
|
||||
_speculativePool.setWasm(wasmModule);
|
||||
|
||||
const total = challenges.length;
|
||||
const results = new Array(total);
|
||||
|
||||
let concurrency = SPECULATIVE_WORKERS;
|
||||
let promoted = false;
|
||||
|
||||
speculative.promoteFn = (fullCount) => {
|
||||
if (promoted) return;
|
||||
promoted = true;
|
||||
concurrency = fullCount;
|
||||
_speculativePool._size = fullCount;
|
||||
_speculativePool._ensureSize(fullCount);
|
||||
};
|
||||
|
||||
if (speculative.pendingPromotion !== null) {
|
||||
speculative.promoteFn(speculative.pendingPromotion);
|
||||
speculative.pendingPromotion = null;
|
||||
}
|
||||
|
||||
let nextIndex = 0;
|
||||
|
||||
while (nextIndex < total) {
|
||||
const batchSize = concurrency;
|
||||
const batch = [];
|
||||
const batchIndices = [];
|
||||
|
||||
for (let i = 0; i < batchSize && nextIndex < total; i++) {
|
||||
batchIndices.push(nextIndex);
|
||||
batch.push(challenges[nextIndex]);
|
||||
nextIndex++;
|
||||
}
|
||||
|
||||
_speculativePool._ensureSize(Math.max(concurrency, batchSize));
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((challenge) =>
|
||||
_speculativePool.run(challenge[0], challenge[1]).then((nonce) => {
|
||||
speculative.completedCount++;
|
||||
return nonce;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < batchIndices.length; i++) {
|
||||
results[batchIndices[i]] = batchResults[i];
|
||||
}
|
||||
|
||||
if (!promoted && nextIndex < total) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SPECULATIVE_YIELD_MS));
|
||||
}
|
||||
}
|
||||
|
||||
speculative.results = results;
|
||||
speculative.state = "redeeming";
|
||||
_speculativeRedeem(results);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function _speculativeRedeem(solutions) {
|
||||
try {
|
||||
const challengeResp = speculative.challengeResp;
|
||||
const apiEndpoint = challengeResp._apiEndpoint;
|
||||
if (!apiEndpoint) throw new Error("[cap] speculative redeem: missing apiEndpoint");
|
||||
|
||||
let instrOut = null;
|
||||
if (challengeResp.instrumentation) {
|
||||
instrOut = await runInstrumentationChallenge(challengeResp.instrumentation);
|
||||
if (instrOut?.__timeout || instrOut?.__blocked) {
|
||||
speculative.state = "done";
|
||||
speculative.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const redeemRaw = await capFetch(`${apiEndpoint}redeem`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
token: challengeResp.token,
|
||||
solutions,
|
||||
...(instrOut && { instr: instrOut }),
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await redeemRaw.json();
|
||||
} catch {
|
||||
throw new Error("Failed to parse speculative redeem response");
|
||||
}
|
||||
|
||||
if (!resp.success) throw new Error(resp.error || "Speculative redeem failed");
|
||||
|
||||
speculative.token = resp.token;
|
||||
speculative.tokenExpires = new Date(resp.expires).getTime();
|
||||
speculative.state = "done";
|
||||
speculative.notify();
|
||||
} catch (e) {
|
||||
console.warn("[cap] speculative redeem failed (will redo on click):", e);
|
||||
speculative.state = "done";
|
||||
speculative.notify();
|
||||
}
|
||||
}
|
||||
|
||||
let _sharedWorkerUrl = null;
|
||||
|
||||
function _getSharedWorkerUrl() {
|
||||
if (_sharedWorkerUrl) return _sharedWorkerUrl;
|
||||
|
||||
_sharedWorkerUrl = URL.createObjectURL(
|
||||
new Blob([`%%workerScript%%`], { type: "application/javascript" }),
|
||||
);
|
||||
return _sharedWorkerUrl;
|
||||
}
|
||||
|
||||
class WorkerPool {
|
||||
constructor(size) {
|
||||
this._size = size;
|
||||
this._workers = [];
|
||||
this._idle = [];
|
||||
this._queue = [];
|
||||
this._wasmModule = null;
|
||||
this._spawnFailures = 0;
|
||||
}
|
||||
|
||||
setWasm(wasmModule) {
|
||||
this._wasmModule = wasmModule;
|
||||
}
|
||||
|
||||
_spawn() {
|
||||
const url = _getSharedWorkerUrl();
|
||||
const w = new Worker(url);
|
||||
w._busy = false;
|
||||
this._workers.push(w);
|
||||
this._idle.push(w);
|
||||
return w;
|
||||
}
|
||||
|
||||
_replaceWorker(deadWorker) {
|
||||
const idx = this._workers.indexOf(deadWorker);
|
||||
if (idx !== -1) this._workers.splice(idx, 1);
|
||||
const idleIdx = this._idle.indexOf(deadWorker);
|
||||
if (idleIdx !== -1) this._idle.splice(idleIdx, 1);
|
||||
|
||||
try {
|
||||
deadWorker.terminate();
|
||||
} catch {}
|
||||
|
||||
this._spawnFailures++;
|
||||
if (this._spawnFailures > 3) {
|
||||
console.error("[cap] worker spawn failed repeatedly, not retrying");
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._spawn();
|
||||
}
|
||||
|
||||
_ensureSize(n) {
|
||||
while (this._workers.length < n) this._spawn();
|
||||
}
|
||||
|
||||
run(salt, target) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._queue.push({ salt, target, resolve, reject });
|
||||
this._dispatch();
|
||||
});
|
||||
}
|
||||
|
||||
_dispatch() {
|
||||
while (this._idle.length > 0 && this._queue.length > 0) {
|
||||
const worker = this._idle.shift();
|
||||
const { salt, target, resolve, reject } = this._queue.shift();
|
||||
|
||||
let settled = false;
|
||||
|
||||
const onMessage = ({ data }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.removeEventListener("message", onMessage);
|
||||
worker.removeEventListener("error", onError);
|
||||
this._spawnFailures = 0;
|
||||
this._idle.push(worker);
|
||||
if (!data.found) {
|
||||
reject(new Error(data.error || "worker failed"));
|
||||
} else {
|
||||
resolve(data.nonce);
|
||||
}
|
||||
this._dispatch();
|
||||
};
|
||||
|
||||
const onError = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.removeEventListener("message", onMessage);
|
||||
worker.removeEventListener("error", onError);
|
||||
const replacement = this._replaceWorker(worker);
|
||||
reject(err);
|
||||
if (replacement) {
|
||||
this._dispatch();
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener("message", onMessage);
|
||||
worker.addEventListener("error", onError);
|
||||
|
||||
if (this._wasmModule) {
|
||||
worker.postMessage({ salt, target, wasmModule: this._wasmModule }, []);
|
||||
} else {
|
||||
worker.postMessage({ salt, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
terminate() {
|
||||
for (const w of this._workers) {
|
||||
try {
|
||||
w.terminate();
|
||||
} catch {}
|
||||
}
|
||||
this._workers = [];
|
||||
this._idle = [];
|
||||
this._queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
const _speculativePool = new WorkerPool(1);
|
||||
_speculativePool._spawn();
|
||||
|
||||
class CapWidget extends HTMLElement {
|
||||
#workerUrl = "";
|
||||
#resetTimer = null;
|
||||
#workersCount = navigator.hardwareConcurrency || 8;
|
||||
token = null;
|
||||
@@ -221,13 +598,7 @@
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.#workerUrl = URL.createObjectURL(
|
||||
// this placeholder will be replaced with the actual worker by the build script
|
||||
|
||||
new Blob([`%%workerScript%%`], {
|
||||
type: "application/javascript",
|
||||
}),
|
||||
);
|
||||
_getSharedWorkerUrl();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, _, value) {
|
||||
@@ -277,6 +648,15 @@
|
||||
this.setWorkersCount(parsedWorkers || navigator.hardwareConcurrency || 8);
|
||||
const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
|
||||
this.#host.innerHTML = `<input type="hidden" name="${fieldName}">`;
|
||||
|
||||
if (speculative.state === "idle" || speculative.state === "waiting") {
|
||||
if (_speculativeTimer) {
|
||||
clearTimeout(_speculativeTimer);
|
||||
_speculativeTimer = null;
|
||||
}
|
||||
speculative.state = "waiting";
|
||||
_speculativeTimer = setTimeout(() => _beginSpeculativeSolve(), SPECULATIVE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async solve() {
|
||||
@@ -287,106 +667,173 @@
|
||||
try {
|
||||
this.#solving = true;
|
||||
this.updateUI("verifying", this.getI18nText("verifying-label", "Verifying..."), true);
|
||||
|
||||
this.#div.setAttribute(
|
||||
"aria-label",
|
||||
this.getI18nText("verifying-aria-label", "Verifying you're a human, please wait"),
|
||||
);
|
||||
|
||||
this.dispatchEvent("progress", { progress: 0 });
|
||||
|
||||
try {
|
||||
let apiEndpoint = this.getAttribute("data-cap-api-endpoint");
|
||||
|
||||
if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) {
|
||||
apiEndpoint = "/";
|
||||
} else if (!apiEndpoint)
|
||||
} else if (!apiEndpoint) {
|
||||
throw new Error(
|
||||
"Missing API endpoint. Either custom fetch or an API endpoint must be provided.",
|
||||
);
|
||||
|
||||
if (!apiEndpoint.endsWith("/")) {
|
||||
apiEndpoint += "/";
|
||||
}
|
||||
if (!apiEndpoint.endsWith("/")) apiEndpoint += "/";
|
||||
|
||||
const challengeRaw = await capFetch(`${apiEndpoint}challenge`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
let solutions;
|
||||
let challengeResp;
|
||||
try {
|
||||
challengeResp = await challengeRaw.json();
|
||||
} catch (parseErr) {
|
||||
throw new Error("Failed to parse challenge response from server");
|
||||
|
||||
if (
|
||||
speculative.state === "done" &&
|
||||
speculative.token &&
|
||||
speculative.tokenExpires &&
|
||||
Date.now() < speculative.tokenExpires
|
||||
) {
|
||||
this.dispatchEvent("progress", { progress: 100 });
|
||||
|
||||
const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
|
||||
if (this.querySelector(`input[name='${fieldName}']`)) {
|
||||
this.querySelector(`input[name='${fieldName}']`).value = speculative.token;
|
||||
}
|
||||
this.dispatchEvent("solve", { token: speculative.token });
|
||||
this.token = speculative.token;
|
||||
|
||||
const expiresIn = speculative.tokenExpires - Date.now();
|
||||
if (this.#resetTimer) clearTimeout(this.#resetTimer);
|
||||
this.#resetTimer = setTimeout(() => this.reset(), expiresIn);
|
||||
|
||||
this.#div.setAttribute(
|
||||
"aria-label",
|
||||
this.getI18nText(
|
||||
"verified-aria-label",
|
||||
"We have verified you're a human, you may now continue",
|
||||
),
|
||||
);
|
||||
|
||||
_resetSpeculativeState();
|
||||
this.#solving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (challengeResp.error) {
|
||||
throw new Error(challengeResp.error);
|
||||
}
|
||||
if (speculative.state === "done") {
|
||||
solutions = speculative.results;
|
||||
challengeResp = speculative.challengeResp;
|
||||
this.dispatchEvent("progress", { progress: 100 });
|
||||
} else if (
|
||||
speculative.state === "solving" ||
|
||||
speculative.state === "redeeming" ||
|
||||
speculative.state === "fetching" ||
|
||||
speculative.state === "waiting"
|
||||
) {
|
||||
if (speculative.state === "waiting") {
|
||||
if (_speculativeTimer) {
|
||||
clearTimeout(_speculativeTimer);
|
||||
_speculativeTimer = null;
|
||||
}
|
||||
speculative.state = "waiting";
|
||||
_beginSpeculativeSolve();
|
||||
}
|
||||
|
||||
const { challenge, token } = challengeResp;
|
||||
speculative.pendingPromotion = this.#workersCount;
|
||||
if (speculative.promoteFn) {
|
||||
speculative.promoteFn(this.#workersCount);
|
||||
}
|
||||
|
||||
let challenges = challenge;
|
||||
const progressInterval = setInterval(() => {
|
||||
if (speculative.state !== "solving" && speculative.state !== "redeeming") {
|
||||
clearInterval(progressInterval);
|
||||
return;
|
||||
}
|
||||
const total = speculative.challenges ? speculative.challenges.length : 1;
|
||||
const done = speculative.completedCount;
|
||||
const visual =
|
||||
speculative.state === "redeeming"
|
||||
? 99
|
||||
: Math.min(98, Math.round((done / total) * 100));
|
||||
this.dispatchEvent("progress", { progress: visual });
|
||||
}, 150);
|
||||
|
||||
if (!Array.isArray(challenges)) {
|
||||
let i = 0;
|
||||
await new Promise((resolve) => speculative.onSettled(resolve));
|
||||
clearInterval(progressInterval);
|
||||
|
||||
challenges = Array.from({ length: challenge.c }, () => {
|
||||
i = i + 1;
|
||||
if (speculative.state !== "done") {
|
||||
throw new Error("Speculative solve failed – please try again");
|
||||
}
|
||||
|
||||
return [prng(`${token}${i}`, challenge.s), prng(`${token}${i}d`, challenge.d)];
|
||||
if (
|
||||
speculative.token &&
|
||||
speculative.tokenExpires &&
|
||||
Date.now() < speculative.tokenExpires
|
||||
) {
|
||||
this.dispatchEvent("progress", { progress: 100 });
|
||||
|
||||
const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
|
||||
if (this.querySelector(`input[name='${fieldName}']`)) {
|
||||
this.querySelector(`input[name='${fieldName}']`).value = speculative.token;
|
||||
}
|
||||
this.dispatchEvent("solve", { token: speculative.token });
|
||||
this.token = speculative.token;
|
||||
|
||||
const expiresIn = speculative.tokenExpires - Date.now();
|
||||
if (this.#resetTimer) clearTimeout(this.#resetTimer);
|
||||
this.#resetTimer = setTimeout(() => this.reset(), expiresIn);
|
||||
|
||||
this.#div.setAttribute(
|
||||
"aria-label",
|
||||
this.getI18nText(
|
||||
"verified-aria-label",
|
||||
"We have verified you're a human, you may now continue",
|
||||
),
|
||||
);
|
||||
|
||||
_resetSpeculativeState();
|
||||
this.#solving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
solutions = speculative.results;
|
||||
challengeResp = speculative.challengeResp;
|
||||
this.dispatchEvent("progress", { progress: 100 });
|
||||
} else {
|
||||
const challengeRaw = await capFetch(`${apiEndpoint}challenge`, {
|
||||
method: "POST",
|
||||
});
|
||||
try {
|
||||
challengeResp = await challengeRaw.json();
|
||||
} catch {
|
||||
throw new Error("Failed to parse challenge response from server");
|
||||
}
|
||||
if (challengeResp.error) throw new Error(challengeResp.error);
|
||||
|
||||
const { challenge, token } = challengeResp;
|
||||
let challenges = challenge;
|
||||
if (!Array.isArray(challenges)) {
|
||||
let i = 0;
|
||||
challenges = Array.from({ length: challenge.c }, () => {
|
||||
i++;
|
||||
return [prng(`${token}${i}`, challenge.s), prng(`${token}${i}d`, challenge.d)];
|
||||
});
|
||||
}
|
||||
|
||||
solutions = await this.solveChallenges(challenges);
|
||||
}
|
||||
|
||||
const instrPromise = challengeResp.instrumentation
|
||||
? runInstrumentationChallenge(challengeResp.instrumentation)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const powPromise = this.solveChallenges(challenges);
|
||||
|
||||
const instrErrorPromise = instrPromise.then((result) => {
|
||||
if (result && result.__timeout) return result;
|
||||
return null;
|
||||
});
|
||||
|
||||
const instrEarlyError = await Promise.race([
|
||||
instrErrorPromise,
|
||||
powPromise.then(() => null),
|
||||
]);
|
||||
|
||||
if (instrEarlyError && instrEarlyError.__timeout) {
|
||||
const errMsg = "Instrumentation timeout — please try again later";
|
||||
this.updateUIBlocked(this.getI18nText("error-label", "Error"), true);
|
||||
this.#div.setAttribute(
|
||||
"aria-label",
|
||||
this.getI18nText("error-aria-label", "An error occurred, please try again"),
|
||||
);
|
||||
this.removeEventListener("error", this.boundHandleError);
|
||||
const errEvent = new CustomEvent("error", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { isCap: true, message: errMsg },
|
||||
});
|
||||
super.dispatchEvent(errEvent);
|
||||
this.addEventListener("error", this.boundHandleError);
|
||||
this.executeAttributeCode("onerror", errEvent);
|
||||
console.error("[cap]", errMsg);
|
||||
this.#solving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [solutions, instrOut] = await Promise.all([powPromise, instrPromise]);
|
||||
const instrOut = await instrPromise;
|
||||
|
||||
if (instrOut?.__timeout || instrOut?.__blocked) {
|
||||
this.updateUIBlocked(
|
||||
this.getI18nText("error-label", "Error"),
|
||||
instrOut && instrOut.__blocked,
|
||||
);
|
||||
this.updateUIBlocked(this.getI18nText("error-label", "Error"), instrOut?.__blocked);
|
||||
this.#div.setAttribute(
|
||||
"aria-label",
|
||||
this.getI18nText("error-aria-label", "An error occurred, please try again"),
|
||||
);
|
||||
|
||||
this.removeEventListener("error", this.boundHandleError);
|
||||
const errEvent = new CustomEvent("error", {
|
||||
bubbles: true,
|
||||
@@ -395,13 +842,14 @@
|
||||
});
|
||||
super.dispatchEvent(errEvent);
|
||||
this.addEventListener("error", this.boundHandleError);
|
||||
|
||||
this.executeAttributeCode("onerror", errEvent);
|
||||
console.error("[cap]", "Instrumentation failed");
|
||||
this.#solving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { token } = challengeResp;
|
||||
|
||||
const redeemResponse = await capFetch(`${apiEndpoint}redeem`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@@ -421,6 +869,7 @@
|
||||
|
||||
this.dispatchEvent("progress", { progress: 100 });
|
||||
if (!resp.success) throw new Error(resp.error || "Invalid solution");
|
||||
|
||||
const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
|
||||
if (this.querySelector(`input[name='${fieldName}']`)) {
|
||||
this.querySelector(`input[name='${fieldName}']`).value = resp.token;
|
||||
@@ -429,6 +878,8 @@
|
||||
this.dispatchEvent("solve", { token: resp.token });
|
||||
this.token = resp.token;
|
||||
|
||||
_resetSpeculativeState();
|
||||
|
||||
if (this.#resetTimer) clearTimeout(this.#resetTimer);
|
||||
const expiresIn = new Date(resp.expires).getTime() - Date.now();
|
||||
if (expiresIn > 0 && expiresIn < 24 * 60 * 60 * 1000) {
|
||||
@@ -463,6 +914,9 @@
|
||||
const total = challenges.length;
|
||||
let completed = 0;
|
||||
|
||||
const speculativeHead = 0;
|
||||
const remaining = total - speculativeHead;
|
||||
|
||||
let wasmModule = null;
|
||||
const wasmSupported =
|
||||
typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function";
|
||||
@@ -492,67 +946,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array(this.#workersCount)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
try {
|
||||
return new Worker(this.#workerUrl);
|
||||
} catch (error) {
|
||||
console.error("[cap] Failed to create worker:", error);
|
||||
throw new Error("Worker creation failed");
|
||||
}
|
||||
});
|
||||
|
||||
const solveSingleChallenge = ([salt, target], workerId) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const worker = workers[workerId];
|
||||
if (!worker) {
|
||||
reject(new Error("Worker not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
worker.onmessage = ({ data }) => {
|
||||
if (!data.found) return;
|
||||
|
||||
completed++;
|
||||
this.dispatchEvent("progress", {
|
||||
progress: Math.round((completed / total) * 100),
|
||||
});
|
||||
|
||||
resolve(data.nonce);
|
||||
};
|
||||
|
||||
worker.onerror = (err) => {
|
||||
this.error(`Error in worker: ${err.message || err}`);
|
||||
reject(err);
|
||||
};
|
||||
|
||||
if (wasmModule) {
|
||||
worker.postMessage({ salt, target, wasmModule }, []);
|
||||
} else {
|
||||
worker.postMessage({ salt, target });
|
||||
}
|
||||
});
|
||||
const pool = new WorkerPool(this.#workersCount);
|
||||
pool.setWasm(wasmModule);
|
||||
pool._ensureSize(this.#workersCount);
|
||||
|
||||
const results = [];
|
||||
try {
|
||||
for (let i = 0; i < challenges.length; i += this.#workersCount) {
|
||||
const chunk = challenges.slice(i, Math.min(i + this.#workersCount, challenges.length));
|
||||
const chunkResults = await Promise.all(
|
||||
chunk.map((c, idx) => solveSingleChallenge(c, idx)),
|
||||
chunk.map(([salt, target]) =>
|
||||
pool.run(salt, target).then((nonce) => {
|
||||
completed++;
|
||||
const visual = Math.min(
|
||||
99,
|
||||
Math.round(((speculativeHead + completed) / total) * 100),
|
||||
);
|
||||
this.dispatchEvent("progress", { progress: visual });
|
||||
return nonce;
|
||||
}),
|
||||
),
|
||||
);
|
||||
results.push(...chunkResults);
|
||||
}
|
||||
} finally {
|
||||
workers.forEach((w) => {
|
||||
if (w) {
|
||||
try {
|
||||
w.terminate();
|
||||
} catch (error) {
|
||||
console.error("[cap] error terminating worker:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
pool.terminate();
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -645,7 +1063,9 @@
|
||||
if (current) {
|
||||
current.classList.remove("active");
|
||||
current.classList.add("exit");
|
||||
current.addEventListener("transitionend", () => current.remove(), { once: true });
|
||||
current.addEventListener("transitionend", () => current.remove(), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,11 +1109,14 @@
|
||||
if (current) {
|
||||
current.classList.remove("active");
|
||||
current.classList.add("exit");
|
||||
current.addEventListener("transitionend", () => current.remove(), { once: true });
|
||||
current.addEventListener("transitionend", () => current.remove(), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
const link = next.querySelector(".cap-troubleshoot-link");
|
||||
if (link) {
|
||||
console.log("linkblud")
|
||||
link.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
@@ -742,8 +1165,10 @@
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[cap] using `onxxx='…'` is strongly discouraged and will be deprecated soon. please use `addEventListener` callbacks instead.");
|
||||
|
||||
console.error(
|
||||
"[cap] using `onxxx='…'` is strongly discouraged and will be deprecated soon. please use `addEventListener` callbacks instead.",
|
||||
);
|
||||
|
||||
new Function("event", code).call(this, event);
|
||||
}
|
||||
@@ -803,11 +1228,6 @@
|
||||
clearTimeout(this.#resetTimer);
|
||||
this.#resetTimer = null;
|
||||
}
|
||||
|
||||
if (this.#workerUrl) {
|
||||
URL.revokeObjectURL(this.#workerUrl);
|
||||
this.#workerUrl = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+317
-360
File diff suppressed because one or more lines are too long
Vendored
+5
-13
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -9,20 +9,12 @@
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
challenge: Array.from({ length: 5 }, () => [
|
||||
Array.from(
|
||||
window.crypto.getRandomValues(
|
||||
new Uint8Array(Math.ceil(32 / 2))
|
||||
)
|
||||
)
|
||||
Array.from(window.crypto.getRandomValues(new Uint8Array(Math.ceil(32 / 2))))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, 32),
|
||||
|
||||
Array.from(
|
||||
window.crypto.getRandomValues(
|
||||
new Uint8Array(Math.ceil(2 / 2))
|
||||
)
|
||||
)
|
||||
Array.from(window.crypto.getRandomValues(new Uint8Array(Math.ceil(2 / 2))))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, 4),
|
||||
@@ -33,7 +25,7 @@
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url === "/api/redeem") {
|
||||
@@ -46,7 +38,7 @@
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return await window.fetch(url, options);
|
||||
|
||||
Reference in New Issue
Block a user