A lot of changes to how cap works. Note: the capdotjs npm library has now been deprecated
This commit is contained in:
+2
-1
@@ -1,3 +1,4 @@
|
||||
.old
|
||||
build/Release
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
@@ -89,4 +90,4 @@ docs/.vitepress/dist
|
||||
.DS_Store
|
||||
.vscode
|
||||
.data
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
@@ -3,8 +3,8 @@ const babel = require("@babel/core");
|
||||
const { minify } = require("terser");
|
||||
|
||||
const paths = [
|
||||
["./src/cap.js", "./lib/cap.min.js"],
|
||||
["./src/cap-floating.js", "./lib/cap-floating.min.js"],
|
||||
["./widget/src/cap.js", "./widget/cap.min.js"],
|
||||
["./widget/src/cap-floating.js", "./widget/cap-floating.min.js"],
|
||||
];
|
||||
|
||||
paths.forEach(async function ([inpath, outpath]) {
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const Koa = require("koa");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const json = require("koa-json");
|
||||
const mount = require("koa-mount");
|
||||
const Router = require("@koa/router");
|
||||
const compress = require("koa-compress");
|
||||
const ratelimit = require("koa-ratelimit");
|
||||
const bodyParser = require("koa-bodyparser");
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
|
||||
app.proxy = true;
|
||||
|
||||
const serve = require("koa-static");
|
||||
app.use(mount("/docs", serve(path.join(__dirname, "/docs/.vitepress/dist/"))));
|
||||
|
||||
const TOKENS_PATH = path.join(__dirname, ".data", "tokensList.json");
|
||||
const state = {
|
||||
challengesList: {},
|
||||
tokensList: {},
|
||||
requestCounts: {}
|
||||
};
|
||||
|
||||
app.use(bodyParser());
|
||||
app.use(json());
|
||||
app.use(compress());
|
||||
|
||||
app.use(async (ctx, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
const ms = Date.now() - start;
|
||||
ctx.set("X-Response-Time", `${ms}ms`);
|
||||
});
|
||||
|
||||
const db = new Map();
|
||||
|
||||
const createRateLimit = () =>
|
||||
ratelimit({
|
||||
driver: "memory",
|
||||
db: db,
|
||||
duration: 60 * 1000,
|
||||
max: 30,
|
||||
errorMessage: "Too many requests, please try again later.",
|
||||
});
|
||||
|
||||
const nid = function (l) {
|
||||
return crypto
|
||||
.randomBytes(Math.ceil(l / 2))
|
||||
.toString("hex")
|
||||
.slice(0, l);
|
||||
};
|
||||
|
||||
router.post("/api/challenge", createRateLimit(), async (ctx) => {
|
||||
state.requestCounts[ctx.ip] = {
|
||||
counts: (state.requestCounts[ctx.ip]?.counts + 1) || 1,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
const config = { challengeCount: 18, challengeSize: 32, challengeDifficulty: 4 };
|
||||
const challenges = Array.from({ length: config.challengeCount }, () => [
|
||||
nid(config.challengeSize),
|
||||
nid(config.challengeDifficulty),
|
||||
]);
|
||||
const token = crypto.randomBytes(25).toString("hex");
|
||||
const expires = Date.now() + 600000;
|
||||
|
||||
state.challengesList[token] = {
|
||||
challenge: challenges,
|
||||
expires,
|
||||
ip: ctx.ip,
|
||||
};
|
||||
|
||||
ctx.body = { challenge: challenges, token, expires };
|
||||
});
|
||||
|
||||
router.post("/api/redeem", createRateLimit(), async (ctx) => {
|
||||
const { token, solutions } = ctx.request.body;
|
||||
const challengeData = state.challengesList[token];
|
||||
delete state.challengesList[token];
|
||||
|
||||
if (
|
||||
!challengeData ||
|
||||
challengeData.expires < Date.now() ||
|
||||
!(challengeData.ip === ctx.ip)
|
||||
) {
|
||||
ctx.body = { success: false };
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = challengeData.challenge.every(([salt, target]) => {
|
||||
const solution = solutions.find(([s, t]) => s === salt && t === target);
|
||||
return (
|
||||
solution &&
|
||||
crypto
|
||||
.createHash("sha256")
|
||||
.update(salt + solution[2])
|
||||
.digest("hex")
|
||||
.startsWith(target)
|
||||
);
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
ctx.body = { success: false };
|
||||
return;
|
||||
}
|
||||
|
||||
const encodeBigInt = (num, base = 62) => {
|
||||
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let res = "", n = BigInt(num);
|
||||
while (n > 0) (res = chars[Number(n % BigInt(base))] + res), (n /= BigInt(base));
|
||||
return res || "0";
|
||||
};
|
||||
|
||||
const vertoken = crypto.randomBytes(15).toString("hex");
|
||||
const expires = Date.now() + 20 * 60 * 1000;
|
||||
const hash = encodeBigInt(Bun.hash(vertoken));
|
||||
const id = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
state.tokensList[`${id}:${hash}`] = expires;
|
||||
|
||||
await fsp.writeFile(TOKENS_PATH, JSON.stringify(state.tokensList), "utf8");
|
||||
|
||||
ctx.body = { success: true, token: `${id}:${vertoken}`, expires };
|
||||
});
|
||||
|
||||
router.post("/api/validate", createRateLimit(), async (ctx) => {
|
||||
const encodeBigInt = (num, base = 62) => {
|
||||
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let res = "", n = BigInt(num);
|
||||
while (n > 0) (res = chars[Number(n % BigInt(base))] + res), (n /= BigInt(base));
|
||||
return res || "0";
|
||||
};
|
||||
|
||||
const { token } = ctx.request.body;
|
||||
const [id, vertoken] = token.split(":");
|
||||
const hash = encodeBigInt(Bun.hash(vertoken));
|
||||
|
||||
if (state.tokensList[`${id}:${hash}`]) {
|
||||
delete state.tokensList[`${id}:${hash}`];
|
||||
ctx.body = { success: true };
|
||||
} else {
|
||||
ctx.body = { success: false };
|
||||
}
|
||||
})
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
ctx.redirect("/docs");
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
const now = Date.now();
|
||||
let tokensChanged = false;
|
||||
|
||||
for (const k in state.challengesList) {
|
||||
if (state.challengesList[k].expires < now) delete state.challengesList[k];
|
||||
}
|
||||
|
||||
for (const k in state.tokensList) {
|
||||
if (state.tokensList[k] < now) {
|
||||
delete state.tokensList[k];
|
||||
tokensChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokensChanged) {
|
||||
await fsp.writeFile(TOKENS_PATH, JSON.stringify(state.tokensList), "utf8");
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setInterval(function () {
|
||||
const now = Date.now();
|
||||
|
||||
Object.entries(state.requestCounts).forEach(([ip, { counts, timestamp }]) => {
|
||||
if (now - timestamp > 30 * 60 * 1000) {
|
||||
delete state.requestCounts[ip];
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
state.tokensList = JSON.parse(fs.readFileSync(TOKENS_PATH, "utf-8").toString()) || {};
|
||||
|
||||
app.use(router.routes()).use(router.allowedMethods());
|
||||
|
||||
app.listen(process.env.PORT || 3000, () => {
|
||||
console.log("Server running on http://localhost:3000");
|
||||
require("./build.js");
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
# Demo
|
||||
|
||||
**Note:** Please note that the demo implementation is still experimental! Don't consider it to be representative of the stability or behavior of Cap overall.
|
||||
|
||||
### Normal button:
|
||||
|
||||
<Demo />
|
||||
|
||||
@@ -16,7 +16,7 @@ Cap is fully compliant with GDPR and CCPA. It doesn't use cookies or track you i
|
||||
|
||||
## Security
|
||||
|
||||
- IP addresses are only stored in-memory
|
||||
- IP addresses are not stored by default
|
||||
- Requests are stored in-memory in make sure they are not tampered with (hashed tokens only, this is stored in .data/tokensList.json by default)
|
||||
- Confirmation tokens reset after 20 minutes
|
||||
- Challenges are only valid for 10 minutes
|
||||
|
||||
+3
-25
@@ -3,39 +3,17 @@
|
||||
Cap can automatically hide the captcha until the form is submitted. To use this feature, add the `data-cap-floating` attribute to the Cap widget with the query selector of the `cap-widget` element you want to use.
|
||||
|
||||
```html
|
||||
<cap-widget id="floating" onsolve="alert(`Verification token: ${event.detail.token}`)" data-api-endpoint="https://<your website url>/api/"></cap-widget>
|
||||
<cap-widget id="floating" onsolve="alert(`Verification token: ${event.detail.token}`)" data-api-endpoint="<api endpoint>"></cap-widget>
|
||||
|
||||
<button data-cap-floating="#floating" data-cap-floating-position="bottom">Trigger floating mode</button>
|
||||
```
|
||||
|
||||
You'll also need to import both the Cap library and the floating mode script from JSDelivr:
|
||||
```html{2}
|
||||
<script src="https://cdn.jsdelivr.net/npm/capdotjs"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/capdotjs/lib/cap-floating.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget/cap-floating.min.js"></script>
|
||||
```
|
||||
|
||||
::: details Other CDNs
|
||||
While it is recommended to use JSDelivr, you can also import Cap from other CDNs like unpkg or GitHub.
|
||||
|
||||
##### unpkg
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/capdotjs/lib/cap-floating.min.js"></script>
|
||||
```
|
||||
|
||||
##### jsdelivr + GitHub
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/gh/tiagorangel1/cap/lib/cap-floating.min.js"></script>
|
||||
```
|
||||
|
||||
##### GitHub
|
||||
|
||||
```html
|
||||
<script src="https://raw.githubusercontent.com/tiagorangel1/cap/master/lib/cap-floating.min.js"></script>
|
||||
```
|
||||
:::
|
||||
|
||||
> [!NOTE]
|
||||
> You'll not need to re-import the main Cap library if you've already done it.
|
||||
|
||||
|
||||
+47
-36
@@ -7,41 +7,67 @@ Cap is a library designed for safeguarding against spam and abuse by utilizing a
|
||||
|
||||
## Adding the Cap widget
|
||||
|
||||
Cap is built to be straightforward and requires no API tokens for setup. Start by importing the Cap library:
|
||||
### Server-side
|
||||
Cap is fully self-hosted, so you'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute. This is easy since we've already pre-made a library to help you generate and validate challenges for you.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/capdotjs"></script>
|
||||
Start by installing it using npm or bun:
|
||||
|
||||
```
|
||||
npm i @cap.js/server
|
||||
```
|
||||
|
||||
::: details Other CDNs
|
||||
While it is recommended to use JSDelivr, you can also import Cap from other CDNs like unpkg or GitHub.
|
||||
Now, you'll need to change your server code to add the routes that Cap needs to work. Here's an example with Express.js:
|
||||
|
||||
##### unpkg
|
||||
```js
|
||||
const express = require('express');
|
||||
const Cap = require('./index.js');
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/capdotjs"></script>
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const cap = new Cap({
|
||||
tokens_store_path: '.data/tokensList.json' // make sure this file has already been created and added to your gitignore
|
||||
});
|
||||
|
||||
app.post('/api/challenge', (req, res) => {
|
||||
res.json(cap.createChallenge({
|
||||
challengeCount: 2,
|
||||
challengeSize: 16,
|
||||
challengeDifficulty: 3,
|
||||
expiresMs: 300000
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/api/redeem', async (req, res) => {
|
||||
const { token, solutions } = req.body;
|
||||
if (!token || !solutions) {
|
||||
return res.status(400).json({ success: false });
|
||||
}
|
||||
res.json(await cap.redeemChallenge({ token, solutions }));
|
||||
});
|
||||
|
||||
app.listen(3000, () => {
|
||||
console.log('Listening on port 3000');
|
||||
})
|
||||
```
|
||||
|
||||
##### jsdelivr + GitHub
|
||||
It should be easy to adapt this to work with other frameworks such as Hono.
|
||||
|
||||
### Client-side
|
||||
|
||||
Cap's widget is really easy to add. Start by adding it from a CDN:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/gh/tiagorangel1/cap/lib/cap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
|
||||
```
|
||||
|
||||
##### GitHub
|
||||
|
||||
```html
|
||||
<script src="https://raw.githubusercontent.com/tiagorangel1/cap/master/lib/cap.min.js"></script>
|
||||
```
|
||||
:::
|
||||
|
||||
Next, add the `<cap-widget>` component to your HTML.
|
||||
|
||||
```html
|
||||
<cap-widget id="cap" data-api-endpoint="https://<your website url>/api/"></cap-widget>
|
||||
<cap-widget id="cap" data-api-endpoint="<your cap api endpoint>"></cap-widget>
|
||||
```
|
||||
|
||||
**Note:** You'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute.
|
||||
**Note:** You'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute. In the server-side example we provided, the `data-api-endpoint` attribute is set to `/api`. You can change this by replacing every `app.post('/api/...', ...)` to `app.post('/<endpoint>/...', ...)`.
|
||||
|
||||
Then, in your JavaScript, listen for the `solve` event to capture the token when generated:
|
||||
|
||||
@@ -60,21 +86,6 @@ Alternatively, you can use `onsolve=""` directly within the widget or wrap the w
|
||||
|
||||
## Server-Side Validation
|
||||
|
||||
Once you have the token, verify it server-side with a simple POST request:
|
||||
Once the token is generated and captured, you can use it later to validate the user's identity. This is typically done by sending the token to a server-side endpoint that uses the Cap API for validation.
|
||||
|
||||
```http
|
||||
POST <api-endpoint>/validate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"token": "xxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
}
|
||||
```
|
||||
|
||||
The server response will confirm the validity and immediately invalidate the token:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
To do this, you'll only need to call `await cap.validateToken("...")`. This will return `{ success: Boolean }`. Note that the token will immediately be deleted after this. To prevent this, use `await cap.validateToken("...", { keepToken: true })`.
|
||||
+1
-1
@@ -8,7 +8,7 @@ hero:
|
||||
tagline: "Cap is the open-source & modern ReCAPTCHA alternative designed for speed. Lightweight, powerful, private and secure."
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Quickstart
|
||||
text: Guide
|
||||
link: /guide
|
||||
- theme: alt
|
||||
text: Demo
|
||||
|
||||
+6
-5
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"name": "capdotjs",
|
||||
"version": "0.0.4",
|
||||
"name": "cap",
|
||||
"author": "Tiago Rangel",
|
||||
"main": "lib/cap.min.js",
|
||||
"description": "A modern, lightning-quick PoW captcha",
|
||||
"keywords": [
|
||||
"captcha",
|
||||
@@ -14,12 +12,15 @@
|
||||
"node"
|
||||
],
|
||||
"scripts": {
|
||||
"demo": "bun run demo.js",
|
||||
"build": "bun run build.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
|
||||
"docs:dev": "cd docs && bun run docs:dev",
|
||||
"docs:build": "cd docs && bun run docs:build",
|
||||
"docs:preview": "cd docs && bun run docs:preview"
|
||||
"docs:preview": "cd docs && bun run docs:preview",
|
||||
|
||||
"widget:publish": "cd widget && sudo npm publish --access public",
|
||||
"server:publish": "cd server && sudo npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.8",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap)
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs/promises");
|
||||
const { EventEmitter } = require("events");
|
||||
|
||||
const encodeBigInt = (num, base = 62) => {
|
||||
const chars =
|
||||
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let res = "",
|
||||
n = BigInt(num);
|
||||
while (n > 0) {
|
||||
res = chars[Number(n % BigInt(base))] + res;
|
||||
n /= BigInt(base);
|
||||
}
|
||||
return res || "0";
|
||||
};
|
||||
|
||||
class Cap extends EventEmitter {
|
||||
config = {
|
||||
tokens_store_path: ".data/tokensList.json",
|
||||
state: {
|
||||
challengesList: {},
|
||||
tokensList: {},
|
||||
},
|
||||
};
|
||||
|
||||
#cleanupPromise = null;
|
||||
|
||||
constructor(configObj) {
|
||||
super();
|
||||
this.config = { ...this.config, ...configObj };
|
||||
|
||||
this.#loadTokens().catch(() => {});
|
||||
|
||||
process.on("beforeExit", () => this.cleanup());
|
||||
|
||||
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => {
|
||||
process.once(signal, () => {
|
||||
this.cleanup()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #loadTokens() {
|
||||
try {
|
||||
const data = await fs.readFile(this.config.tokens_store_path, "utf-8");
|
||||
this.config.state.tokensList = JSON.parse(data) || {};
|
||||
this.#cleanExpiredTokens();
|
||||
} catch (error) {
|
||||
this.config.state.tokensList = {};
|
||||
}
|
||||
}
|
||||
|
||||
#cleanExpiredTokens() {
|
||||
const now = Date.now();
|
||||
let tokensChanged = false;
|
||||
|
||||
for (const k in this.config.state.challengesList) {
|
||||
if (this.config.state.challengesList[k].expires < now) {
|
||||
delete this.config.state.challengesList[k];
|
||||
}
|
||||
}
|
||||
|
||||
for (const k in this.config.state.tokensList) {
|
||||
if (this.config.state.tokensList[k] < now) {
|
||||
delete this.config.state.tokensList[k];
|
||||
tokensChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
return tokensChanged;
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
if (this.#cleanupPromise) return this.#cleanupPromise;
|
||||
|
||||
this.#cleanupPromise = (async () => {
|
||||
const tokensChanged = this.#cleanExpiredTokens();
|
||||
|
||||
if (tokensChanged) {
|
||||
await fs.writeFile(
|
||||
this.config.tokens_store_path,
|
||||
JSON.stringify(this.config.state.tokensList),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return this.#cleanupPromise;
|
||||
}
|
||||
|
||||
createChallenge(conf) {
|
||||
this.#cleanExpiredTokens();
|
||||
|
||||
const challenges = Array.from(
|
||||
{ length: conf?.challengeCount || 18 },
|
||||
() => [
|
||||
crypto
|
||||
.randomBytes(Math.ceil((conf?.challengeSize || 32) / 2))
|
||||
.toString("hex")
|
||||
.slice(0, conf?.challengeSize || 32),
|
||||
crypto
|
||||
.randomBytes(Math.ceil((conf?.challengeDifficulty || 4) / 2))
|
||||
.toString("hex")
|
||||
.slice(0, conf?.challengeDifficulty || 4),
|
||||
]
|
||||
);
|
||||
|
||||
const token = crypto.randomBytes(25).toString("hex");
|
||||
const expires = Date.now() + (conf?.expiresMs || 600000);
|
||||
|
||||
if (conf?.store === false) {
|
||||
return { challenge: challenges, expires };
|
||||
}
|
||||
|
||||
this.config.state.challengesList[token] = {
|
||||
challenge: challenges,
|
||||
expires,
|
||||
token,
|
||||
};
|
||||
|
||||
return { challenge: challenges, token, expires };
|
||||
}
|
||||
|
||||
async redeemChallenge({ token, solutions }) {
|
||||
this.#cleanExpiredTokens();
|
||||
|
||||
const challengeData = this.config.state.challengesList[token];
|
||||
if (!challengeData || challengeData.expires < Date.now()) {
|
||||
delete this.config.state.challengesList[token];
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
delete this.config.state.challengesList[token];
|
||||
|
||||
const isValid = challengeData.challenge.every(([salt, target]) => {
|
||||
const solution = solutions.find(([s, t]) => s === salt && t === target);
|
||||
return (
|
||||
solution &&
|
||||
crypto
|
||||
.createHash("sha256")
|
||||
.update(salt + solution[2])
|
||||
.digest("hex")
|
||||
.startsWith(target)
|
||||
);
|
||||
});
|
||||
|
||||
if (!isValid) return { success: false };
|
||||
|
||||
const vertoken = crypto.randomBytes(15).toString("hex");
|
||||
const expires = Date.now() + 20 * 60 * 1000;
|
||||
const hash = encodeBigInt(Bun.hash(vertoken));
|
||||
const id = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.config.state.tokensList[`${id}:${hash}`] = expires;
|
||||
|
||||
await fs.writeFile(
|
||||
this.config.tokens_store_path,
|
||||
JSON.stringify(this.config.state.tokensList),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
return { success: true, token: `${id}:${vertoken}`, expires };
|
||||
}
|
||||
|
||||
async validateToken(token, conf) {
|
||||
this.#cleanExpiredTokens();
|
||||
|
||||
const [id, vertoken] = token.split(":");
|
||||
const hash = encodeBigInt(Bun.hash(vertoken));
|
||||
const key = `${id}:${hash}`;
|
||||
|
||||
if (this.config.state.tokensList[key]) {
|
||||
if (conf.keepToken) {
|
||||
delete this.config.state.tokensList[key];
|
||||
|
||||
await fs.writeFile(
|
||||
this.config.tokens_store_path,
|
||||
JSON.stringify(this.config.state.tokensList),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Cap;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@cap.js/server",
|
||||
"version": "1.0.1",
|
||||
"description": "Cap server-side helper",
|
||||
"keywords": [
|
||||
"captcha",
|
||||
"pow",
|
||||
"crypto",
|
||||
"encryption",
|
||||
"recaptcha",
|
||||
"bun",
|
||||
"js",
|
||||
"node",
|
||||
"hcaptcha"
|
||||
],
|
||||
"homepage": "https://github.com/tiagorangel1/cap#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagorangel1/cap/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagorangel1/cap.git"
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"author": "Tiago Rangel",
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"npm:publish": "sudo npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const Cap = require('./index.js');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const cap = new Cap({
|
||||
tokens_store_path: '.data/tokensList.json'
|
||||
});
|
||||
|
||||
function solveChallenge(salt, target) {
|
||||
let nonce = 0;
|
||||
while (true) {
|
||||
if (crypto.createHash('sha256').update(salt + nonce.toString()).digest('hex').startsWith(target)) {
|
||||
return nonce.toString();
|
||||
}
|
||||
nonce++;
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/challenge', (req, res) => {
|
||||
res.json(cap.createChallenge({
|
||||
challengeCount: 2,
|
||||
challengeSize: 16,
|
||||
challengeDifficulty: 3,
|
||||
expiresMs: 300000
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/api/redeem', async (req, res) => {
|
||||
const { token, solutions } = req.body;
|
||||
if (!token || !solutions) {
|
||||
return res.status(400).json({ success: false });
|
||||
}
|
||||
res.json(await cap.redeemChallenge({ token, solutions }));
|
||||
});
|
||||
|
||||
app.post('/api/validate', async (req, res) => {
|
||||
const { token } = req.body;
|
||||
if (!token) {
|
||||
return res.status(400).json({ success: false });
|
||||
}
|
||||
res.json(await cap.validateToken({ token }));
|
||||
});
|
||||
|
||||
async function test() {
|
||||
const { challenge, token } = await fetch('http://localhost:3000/api/challenge', {
|
||||
method: 'POST'
|
||||
}).then(r => r.json());
|
||||
|
||||
const solutions = challenge.map(([salt, target]) => [salt, target, solveChallenge(salt, target)]);
|
||||
const { success, token: verToken } = await fetch('http://localhost:3000/api/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, solutions })
|
||||
}).then(r => r.json());
|
||||
|
||||
if (!success || !verToken) {
|
||||
throw new Error('Failed to redeem challenge');
|
||||
}
|
||||
|
||||
const validation = await fetch('http://localhost:3000/api/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: verToken })
|
||||
}).then(r => r.json());
|
||||
|
||||
if (!validation.success) {
|
||||
throw new Error('Failed to validate token');
|
||||
}
|
||||
|
||||
console.log('All tests passed!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
app.listen(3000, test);
|
||||
@@ -0,0 +1 @@
|
||||
# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap)
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@cap.js/widget",
|
||||
"version": "0.0.2",
|
||||
"description": "Cap widget",
|
||||
"keywords": [
|
||||
"captcha",
|
||||
"pow",
|
||||
"crypto",
|
||||
"encryption",
|
||||
"recaptcha",
|
||||
"bun",
|
||||
"js",
|
||||
"node",
|
||||
"hcaptcha"
|
||||
],
|
||||
"homepage": "https://github.com/tiagorangel1/cap#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tiagorangel1/cap/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiagorangel1/cap.git"
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"author": "Tiago Rangel",
|
||||
"type": "commonjs",
|
||||
"main": "cap.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"npm:publish": "sudo npm publish --access public"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user