feat: standalone v2!
This commit is contained in:
@@ -94,6 +94,3 @@ debug/
|
||||
target/
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
|
||||
standalone-v2-beta/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM oven/bun:1 AS base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
FROM base AS install
|
||||
RUN mkdir -p /temp/dev
|
||||
COPY package.json bun.lock /temp/dev/
|
||||
RUN cd /temp/dev && bun install
|
||||
|
||||
FROM base AS prod
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY package.json bun.lock /temp/prod/
|
||||
RUN cd /temp/prod && bun install
|
||||
|
||||
FROM base AS prerelease
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
FROM base AS release
|
||||
COPY --from=prod /temp/prod/node_modules node_modules
|
||||
COPY --from=prerelease /usr/src/app .
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN mkdir -p /usr/src/app/.data && chown -R bun:bun /usr/src/app/.data
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
|
||||
USER bun
|
||||
|
||||
ENTRYPOINT [ "bun", "run", "./src/index.js" ]
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "app",
|
||||
"dependencies": {
|
||||
"@cap.js/server": "^1.0.13",
|
||||
"@elysiajs/cors": "^1.3.3",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"elysia": "^1.3.1",
|
||||
"elysia-rate-limit": "^4.3.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@cap.js/server": ["@cap.js/server@1.0.13", "", { "dependencies": { "@types/node": "^22.14.1", "typescript": "^5.8.3" } }, "sha512-JcEd/XdqheDSaoBdW0YK4o2lpxlFiCipcUcvgr8ZHscprYVXOTpk7fpagiXqJAJSr68dwyaYk+h8WBv42R4QTw=="],
|
||||
|
||||
"@elysiajs/cors": ["@elysiajs/cors@1.3.3", "", { "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-mYIU6PyMM6xIJuj7d27Vt0/wuzVKIEnFPjcvlkyd7t/m9xspAG37cwNjFxVOnyvY43oOd2I/oW2DB85utXpA2Q=="],
|
||||
|
||||
"@elysiajs/static": ["@elysiajs/static@1.3.0", "", { "dependencies": { "node-cache": "^5.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-7mWlj2U/AZvH27IfRKqpUjDP1W9ZRldF9NmdnatFEtx0AOy7YYgyk0rt5hXrH6wPcR//2gO2Qy+k5rwswpEhJA=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="],
|
||||
|
||||
"elysia": ["elysia@1.3.1", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-En41P6cDHcHtQ0nvfsn9ayB+8ahQJqG1nzvPX8FVZjOriFK/RtZPQBtXMfZDq/AsVIk7JFZGFEtAVEmztNJVhQ=="],
|
||||
|
||||
"elysia-rate-limit": ["elysia-rate-limit@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "5.2.0", "debug": "4.3.4" }, "peerDependencies": { "elysia": ">= 1.0.0" } }, "sha512-TCquBhMqUK+Wce+2RoaIk6byhJ6KMtO316PYp4Mc1GKkBH7qKPQH7Y5qVLauBkzx69wBYgFwMrtuz8yToG3JtQ=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.1.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-wFCPCDLmHbKGUb8TOi/IS7jLsgR8WVDGtDK3CzcB4Guf/weq7G+I+DkXiRSZfbemBFOxOINKpraM6ml78vo8Zw=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
|
||||
|
||||
"node-cache": ["node-cache@5.1.2", "", { "dependencies": { "clone": "2.x" } }, "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"peek-readable": ["peek-readable@7.0.0", "", {}, "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ=="],
|
||||
|
||||
"strtok3": ["strtok3@10.2.2", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^7.0.0" } }, "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg=="],
|
||||
|
||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@tokenizer/inflate/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "cap-standalone",
|
||||
"version": "1.0.17",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch src/index.js",
|
||||
"docker:publish": "docker buildx build --platform linux/amd64,linux/arm64 -t tiago2/cap:latest . --push"
|
||||
},
|
||||
"keywords": [
|
||||
"security",
|
||||
"detection",
|
||||
"prevention",
|
||||
"defense",
|
||||
"protection",
|
||||
"anti-abuse",
|
||||
"anti-automation",
|
||||
"anti-bot",
|
||||
"anti-ddos",
|
||||
"anti-dos",
|
||||
"anti-exploitation",
|
||||
"anti-spam",
|
||||
"anti-scraping",
|
||||
"attack-mitigation",
|
||||
"protocol",
|
||||
"client-server",
|
||||
"computational-puzzle",
|
||||
"complexity",
|
||||
"puzzle",
|
||||
"crypto",
|
||||
"cryptographic",
|
||||
"algorithm",
|
||||
"cybersecurity",
|
||||
"ddos",
|
||||
"hashcash",
|
||||
"hcaptcha",
|
||||
"captcha-alternative",
|
||||
"verification",
|
||||
"invisible",
|
||||
"pow",
|
||||
"proof-of-work",
|
||||
"recaptcha",
|
||||
"spam",
|
||||
"bots",
|
||||
"filtering",
|
||||
"turing-test"
|
||||
],
|
||||
"dependencies": {
|
||||
"@cap.js/server": "^1.0.13",
|
||||
"@elysiajs/cors": "^1.3.3",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"elysia": "^1.3.1",
|
||||
"elysia-rate-limit": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"module": "src/index.js"
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cap</title>
|
||||
<link rel="icon" href="./public/logo.png" />
|
||||
<style>
|
||||
* {
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular",
|
||||
"San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu",
|
||||
"arial", sans-serif;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 600px;
|
||||
padding: 15px;
|
||||
margin: 3rem auto;
|
||||
background-color: #121214;
|
||||
color: #e8e8ea;
|
||||
}
|
||||
|
||||
nav .logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin-left: auto;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
nav a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
nav h1 {
|
||||
font-weight: 500;
|
||||
margin: 0px;
|
||||
font-size: 20px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
p.text {
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
color: white;
|
||||
background: #0076d8;
|
||||
background-origin: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
button.btn:focus {
|
||||
box-shadow: inset 0px 0.8px 0px -0.25px rgba(0, 118, 216, 0.2),
|
||||
0px 0.5px 1.5px rgba(54, 122, 246, 0.25), 0px 0px 0px 3.5px rgba(0, 118, 216, 0.5);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
button.btn:disabled {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dialog-wrapper {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fade-in 0.2s;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-wrapper .dialog {
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
background-color: #18181a;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
animation: dialog-in 0.2s;
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px,
|
||||
rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px,
|
||||
rgba(0, 0, 0, 0.09) 0px -3px 5px;
|
||||
}
|
||||
|
||||
@keyframes dialog-in {
|
||||
0% {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog .close-button {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #e8e8ea;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
margin: 0px;
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dialog .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.key-creation-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 18px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.key-creation-form label {
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.9;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.key-creation-form input {
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.key-creation-form input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.key-creation-form input:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.keys p {
|
||||
text-align: center;
|
||||
margin: 0px;
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.keys .copy-key {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background-origin: border-box;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.keys .copy-key:focus {
|
||||
box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2),
|
||||
0px 0.5px 1.5px rgba(54, 122, 246, 0.25), 0px 0px 0px 3.5px rgba(255, 255, 255, 0.1);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.keys .edit-key {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
color: inherit;
|
||||
padding: 0px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: -4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.keys .edit-key:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.keys .key {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key .key-name {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.action-secundary {
|
||||
border: none;
|
||||
padding: 0px;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-secundary:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dialog .actions-col {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.key-id {
|
||||
opacity: 0.8;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<img src="./public/logo.png" alt="Cap logo" class="logo" />
|
||||
<h1>Cap</h1>
|
||||
<a href="/internal/auth/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
|
||||
<div class="title">
|
||||
<h2>Your keys</h2>
|
||||
<button class="new-keys btn">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-plus-icon lucide-plus"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
Create key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="keys">
|
||||
<p>Loading keys...</p>
|
||||
</div>
|
||||
</body>
|
||||
<script src="/public/index.js"></script>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,449 @@
|
||||
import { Elysia, file, NotFoundError } from "elysia";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
import Cap from "@cap.js/server";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const ADMIN_KEY = process.env.ADMIN_KEY?.trim();
|
||||
const dataDir = "./.data";
|
||||
const keysStorePath = path.join(dataDir, "keys.json");
|
||||
|
||||
function generateAdminKeyHint() {
|
||||
return `\nWe've generated this one for you to use: \n\n${crypto
|
||||
.randomBytes(30)
|
||||
.toString("hex")}\n\nThen, restart this process.\n\n`;
|
||||
}
|
||||
|
||||
if (!ADMIN_KEY) {
|
||||
console.error(
|
||||
`\nNo admin key has been set. Make sure to set it using the \n\`ADMIN_KEY\` environment variable.${generateAdminKeyHint()}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (ADMIN_KEY === "your_secret_key") {
|
||||
console.error(
|
||||
`\nDon't leave the admin key as default! Make sure to set it \nusing the \`ADMIN_KEY\` environment variable.${generateAdminKeyHint()}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (ADMIN_KEY.length < 20) {
|
||||
console.warn(
|
||||
`\n${"*".repeat(
|
||||
60
|
||||
)}\n\nThe admin key you're using is quite short. We recommend \nusing a longer one.${generateAdminKeyHint()}${"*".repeat(
|
||||
60
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
let keys = [];
|
||||
const capInstances = new Map();
|
||||
|
||||
const updateCache = async () => {
|
||||
let cacheConfig = {};
|
||||
|
||||
try {
|
||||
cacheConfig = JSON.parse(
|
||||
await fs.readFile(path.join(dataDir, "assets-cache.json"), "utf-8")
|
||||
);
|
||||
} catch {}
|
||||
|
||||
const lastUpdate = cacheConfig["lastUpdate"] || 0;
|
||||
const currentTime = Date.now();
|
||||
const updateInterval = 1000 * 60 * 60 * 24; // 1 day
|
||||
|
||||
if (!(currentTime - lastUpdate > updateInterval)) return;
|
||||
|
||||
const CACHE_HOST = process.env.CACHE_HOST || "https://cdn.jsdelivr.net";
|
||||
if (CACHE_HOST === "disable") return;
|
||||
|
||||
const WIDGET_VERSION = process.env.WIDGET_VERSION || "latest";
|
||||
const WASM_VERSION = process.env.WASM_VERSION || "latest";
|
||||
|
||||
try {
|
||||
const [widgetSource, floatingSource, wasmSource, wasmLoaderSource] =
|
||||
await Promise.all([
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}`).then((r) => r.text()),
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}/cap-floating.min.js`).then(
|
||||
(r) => r.text()
|
||||
),
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm_bg.wasm`).then(
|
||||
(r) => r.arrayBuffer()
|
||||
),
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm.min.js`).then(
|
||||
(r) => r.text()
|
||||
),
|
||||
]);
|
||||
|
||||
cacheConfig["lastUpdate"] = currentTime;
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cache.json"),
|
||||
JSON.stringify(cacheConfig)
|
||||
);
|
||||
|
||||
await fs.writeFile(path.join(dataDir, "assets-widget.js"), widgetSource);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-floating.js"),
|
||||
floatingSource
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cap_wasm_bg.wasm"),
|
||||
Buffer.from(wasmSource)
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cap_wasm.js"),
|
||||
wasmLoaderSource
|
||||
);
|
||||
|
||||
console.log("[asset server] updated assets cache");
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[asset server] error updating assets cache, trying to load them might fail:",
|
||||
e
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
const data = await fs.readFile(keysStorePath, "utf-8");
|
||||
keys = JSON.parse(data);
|
||||
capInstances.clear();
|
||||
|
||||
keys.forEach((key) => {
|
||||
capInstances.set(
|
||||
key.publicKey,
|
||||
new Cap({
|
||||
tokens_store_path: path.join(dataDir, `tokens-${key.publicKey}.json`),
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
await fs.writeFile(keysStorePath, "[]");
|
||||
keys = [];
|
||||
capInstances.clear();
|
||||
}
|
||||
|
||||
await updateCache();
|
||||
};
|
||||
|
||||
const saveKeys = async () => {
|
||||
await fs.writeFile(keysStorePath, JSON.stringify(keys));
|
||||
};
|
||||
|
||||
const getCapInstance = (publicKey) => {
|
||||
if (!capInstances.has(publicKey) || !capInstances.get(publicKey)) {
|
||||
const keyData = keys.find((k) => k.publicKey === publicKey);
|
||||
if (keyData) {
|
||||
capInstances.set(
|
||||
publicKey,
|
||||
new Cap({
|
||||
tokens_store_path: path.join(dataDir, `tokens-${publicKey}.json`),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return capInstances.get(publicKey);
|
||||
};
|
||||
|
||||
const auth = new Elysia({
|
||||
prefix: "/internal/auth",
|
||||
})
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
count: 10,
|
||||
duration: 15000,
|
||||
})
|
||||
)
|
||||
.post("/", async ({ body, cookie, set }) => {
|
||||
try {
|
||||
if (
|
||||
!body?.password ||
|
||||
!timingSafeEqual(Buffer.from(body.password), Buffer.from(ADMIN_KEY))
|
||||
) {
|
||||
set.status = 401;
|
||||
return { success: false };
|
||||
}
|
||||
} catch {
|
||||
set.status = 500;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
cookie["cap-admin-key"].set({
|
||||
value: await Bun.password.hash(body.password),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 86400 * 7,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
})
|
||||
.get("/logout", ({ cookie, redirect }) => {
|
||||
cookie["cap-admin-key"].remove();
|
||||
return redirect("/");
|
||||
});
|
||||
|
||||
const internal = new Elysia({ prefix: "/internal" })
|
||||
.onBeforeHandle(async ({ cookie, set }) => {
|
||||
const authCookie = cookie["cap-admin-key"]?.value;
|
||||
if (!authCookie || !(await Bun.password.verify(ADMIN_KEY, authCookie))) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
};
|
||||
}
|
||||
})
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
number: 60,
|
||||
duration: 10000,
|
||||
})
|
||||
)
|
||||
.post(
|
||||
"/createKey",
|
||||
async ({
|
||||
body: {
|
||||
keyName,
|
||||
challengesCount = 18,
|
||||
challengeSize = 32,
|
||||
challengeDifficulty = 4,
|
||||
expiresMs = 600000,
|
||||
},
|
||||
set,
|
||||
}) => {
|
||||
if (!keyName?.trim()) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Key name is required" };
|
||||
}
|
||||
|
||||
const publicKey = crypto.randomBytes(6).toString("hex");
|
||||
const privateKey = crypto.randomBytes(25).toString("hex");
|
||||
const privateKeyHash = await Bun.password.hash(privateKey);
|
||||
|
||||
const newKey = {
|
||||
name: keyName,
|
||||
publicKey,
|
||||
privateKey: privateKeyHash,
|
||||
challengesCount: Number(challengesCount),
|
||||
challengeSize: Number(challengeSize),
|
||||
challengeDifficulty: Number(challengeDifficulty),
|
||||
expiresMs: Number(expiresMs),
|
||||
};
|
||||
|
||||
keys.push(newKey);
|
||||
await saveKeys();
|
||||
|
||||
getCapInstance(publicKey);
|
||||
|
||||
set.status = 201;
|
||||
return {
|
||||
success: true,
|
||||
publicKey,
|
||||
privateKey,
|
||||
};
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/editKey",
|
||||
async ({
|
||||
body: {
|
||||
publicKey,
|
||||
keyName,
|
||||
challengesCount,
|
||||
challengeSize,
|
||||
challengeDifficulty,
|
||||
expiresMs,
|
||||
},
|
||||
set,
|
||||
}) => {
|
||||
const keyIndex = keys.findIndex((key) => key.publicKey === publicKey);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
set.status = 404;
|
||||
return { success: false, message: "Key not found" };
|
||||
}
|
||||
|
||||
if (!keyName.trim()) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Key name is required" };
|
||||
}
|
||||
|
||||
const existingKey = keys[keyIndex];
|
||||
keys[keyIndex] = {
|
||||
...existingKey,
|
||||
name: keyName,
|
||||
challengesCount: Number(challengesCount),
|
||||
challengeSize: Number(challengeSize),
|
||||
challengeDifficulty: Number(challengeDifficulty),
|
||||
expiresMs: Number(expiresMs),
|
||||
};
|
||||
|
||||
await saveKeys();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
.get("/listKeys", async () => {
|
||||
return {
|
||||
keys: keys.map(({ privateKey, ...rest }) => rest),
|
||||
};
|
||||
})
|
||||
.post("/deleteKey", async ({ body: { publicKey }, set }) => {
|
||||
const initialLength = keys.length;
|
||||
keys = keys.filter((key) => key.publicKey !== publicKey);
|
||||
|
||||
if (keys.length === initialLength) {
|
||||
set.status = 404;
|
||||
return { success: false, message: "Key not found" };
|
||||
}
|
||||
|
||||
await saveKeys();
|
||||
|
||||
const tokenFilePath = path.join(dataDir, `tokens-${publicKey}.json`);
|
||||
try {
|
||||
await fs.unlink(tokenFilePath);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
console.warn(
|
||||
`Could not delete token file ${tokenFilePath}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
capInstances.delete(publicKey);
|
||||
|
||||
set.status = 200;
|
||||
return { success: true };
|
||||
})
|
||||
.post("/rotateKey", async ({ body: { publicKey }, set }) => {
|
||||
const keyIndex = keys.findIndex((key) => key.publicKey === publicKey);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
set.status = 404;
|
||||
return { success: false, message: "Key not found" };
|
||||
}
|
||||
|
||||
const newPrivateKey = crypto.randomBytes(25).toString("hex");
|
||||
const newPrivateKeyHash = await Bun.password.hash(newPrivateKey);
|
||||
|
||||
keys[keyIndex].privateKey = newPrivateKeyHash;
|
||||
|
||||
await saveKeys();
|
||||
|
||||
return { success: true, privateKey: newPrivateKey };
|
||||
});
|
||||
|
||||
const api = new Elysia({ prefix: "/:key" })
|
||||
.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || true,
|
||||
})
|
||||
)
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
number: 80,
|
||||
duration: 1000,
|
||||
})
|
||||
)
|
||||
.derive(({ params }) => {
|
||||
const keyData = keys.find((k) => k.publicKey === params.key);
|
||||
if (!keyData) {
|
||||
throw new NotFoundError("Key not found");
|
||||
}
|
||||
return { keyData, capInstance: getCapInstance(params.key) };
|
||||
})
|
||||
.post("/api/challenge", async ({ keyData, capInstance }) => {
|
||||
return await capInstance.createChallenge({
|
||||
challengeSize: keyData.challengeSize,
|
||||
challengeDifficulty: keyData.challengeDifficulty,
|
||||
challengeCount: keyData.challengesCount,
|
||||
expiresMs: keyData.expiresMs,
|
||||
});
|
||||
})
|
||||
.post("/api/redeem", async ({ body, set, capInstance }) => {
|
||||
const { token, solutions } = body;
|
||||
|
||||
if (!token || !solutions) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Missing solutions and/or token" };
|
||||
}
|
||||
|
||||
return await capInstance.redeemChallenge({ token, solutions });
|
||||
})
|
||||
.post("/siteverify", async ({ body, set, keyData, capInstance }) => {
|
||||
const { secret, response } = body;
|
||||
|
||||
if (!secret || !response) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Missing secret or/and response" };
|
||||
}
|
||||
|
||||
if (!(await Bun.password.verify(secret, keyData.privateKey))) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Invalid secret" };
|
||||
}
|
||||
|
||||
return await capInstance.validateToken(response, {
|
||||
keepToken: false,
|
||||
});
|
||||
});
|
||||
|
||||
const assetsServer = new Elysia({ prefix: "/assets" })
|
||||
.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || true,
|
||||
methods: ["GET"],
|
||||
})
|
||||
)
|
||||
.onBeforeHandle(({ set }) => {
|
||||
set.headers["Cache-Control"] = "max-age=31536000, immutable";
|
||||
})
|
||||
.get("/widget.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-widget.js"));
|
||||
})
|
||||
.get("/floating.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-floating.js"));
|
||||
})
|
||||
.get("/cap_wasm_bg.wasm", ({ set }) => {
|
||||
set.headers["Content-Type"] = "application/wasm";
|
||||
return file(path.join(dataDir, "assets-cap_wasm_bg.wasm"));
|
||||
})
|
||||
.get("/cap_wasm.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-cap_wasm.js"));
|
||||
});
|
||||
|
||||
new Elysia()
|
||||
.use(staticPlugin())
|
||||
.use(auth)
|
||||
.use(internal)
|
||||
.use(api)
|
||||
.use(assetsServer)
|
||||
.get("/", async ({ cookie }) => {
|
||||
const authCookie = cookie["cap-admin-key"]?.value;
|
||||
const isAuthed =
|
||||
authCookie && (await Bun.password.verify(ADMIN_KEY, authCookie));
|
||||
|
||||
return file(isAuthed ? "./public/index.html" : "./public/lock.html");
|
||||
})
|
||||
.listen(3000);
|
||||
|
||||
console.log(`Cap standalone running on http://localhost:3000`);
|
||||
init();
|
||||
@@ -152,15 +152,6 @@ export default withMermaid({
|
||||
{ text: "Quickstart", link: "/guide/index.md" },
|
||||
{ text: "Feature comparison", link: "/guide/alternatives.md" },
|
||||
{ text: "Community libraries", link: "/guide/community.md" },
|
||||
{
|
||||
text: "Modes",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Standalone mode", link: "/guide/standalone.md" },
|
||||
{ text: "Floating mode", link: "/guide/floating.md" },
|
||||
{ text: "Invisible mode", link: "/guide/invisible.md" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Libraries",
|
||||
collapsed: false,
|
||||
@@ -171,6 +162,25 @@ export default withMermaid({
|
||||
{ text: "CLI", link: "/guide/cli.md" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Standalone",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "About", link: "/guide/standalone/index.md" },
|
||||
{ text: "Installation", link: "/guide/standalone/installation.md" },
|
||||
{ text: "Usage", link: "/guide/standalone/usage.md" },
|
||||
{ text: "API", link: "/guide/standalone/api.md" },
|
||||
{ text: "Options", link: "/guide/standalone/options.md" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Modes",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "Floating mode", link: "/guide/floating.md" },
|
||||
{ text: "Invisible mode", link: "/guide/invisible.md" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Checkpoint",
|
||||
collapsed: true,
|
||||
|
||||
+7
-3
@@ -23,6 +23,8 @@ Cap consists mainly of the **widget** (can be used invisibly) and **server** (yo
|
||||
|
||||
This guide details how to use the usual setup. You can find guides on using the [Standalone server](./standalone.md), [M2M solver](./solver.md), and [checkpoint middleware](./middleware/index.md) in their respective sections.
|
||||
|
||||
We highly recommend checking out the [Standalone mode](./standalone/index.md) as it's complete, fast, simple to set up, and works with any language that can make HTTP requests. It also includes a dashboard, API key support, and more.
|
||||
|
||||
## Client-side
|
||||
|
||||
Start by adding importing the Cap widget library from a CDN:
|
||||
@@ -243,9 +245,11 @@ app.listen(3000, () => {
|
||||
|
||||
:::
|
||||
|
||||
::: warning
|
||||
These example codes don't have ratelimiting for simplicity. Make sure to add proper ratelimiting to your endpoints to prevent abuse.
|
||||
:::
|
||||
Note that these are the simplest examples possible. You should adapt them to your needs. Usually, you'll want to:
|
||||
|
||||
- Use an actual database such as SQLite or Redis for storing tokens and challenges. This is done for you in the [Standalone server](./standalone/index.md)
|
||||
|
||||
- Add some kind of actual ratelimiting. This is also done for you in the [Standalone server](./standalone/index.md)
|
||||
|
||||
### Token validation
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 488 KiB |
@@ -4,8 +4,4 @@ Cap's Checkpoints (previously known as middlewares) allow you to replicate Cloud
|
||||
|
||||
They're extremely simple to set up and use, with you only having to add a few lines of code to your server, unlike moving your entire website over to Cloudflare. Note that this is kind of a nuclear solution, as it _will_ also impact good bots such as search engine crawlers.
|
||||
|
||||

|
||||
|
||||
## How do Checkpoints work?
|
||||
|
||||
Checkpoints work by intercepting requests to your server and checking if the user has a valid `__cap_clearance` cookie. If the cookie is present and valid, the request is allowed to proceed. If not, the user is redirected to a challenge page.
|
||||

|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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.
|
||||
> [New docs](standalone/index.md)
|
||||
|
||||
## Installation
|
||||
|
||||
Cap Standalone is a self-hosted version of Cap's backend that allows you to spin up a server to validate and create challenges so you can use it with languages other than JS.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# API
|
||||
|
||||
Standalone mode offers a simple API for creating, viewing, and managing keys and sessions. First, login to your Cap Standalone dashboard and get an API key from **Settings** → **API Keys**. Give it a name and tap "Create".
|
||||
|
||||
Once your key is created, save it somewhere safe, as you won't be able to see it again.
|
||||
|
||||
Now, you can use this key to make API requests to your Standalone server. For each request you make, you'll need to include the `Authorization` header with your API key, like this:
|
||||
|
||||
```http
|
||||
Authorization: Bot YOUR_API_KEY
|
||||
```
|
||||
|
||||
You can see a list of all available API endpoints and their required bodies by going to `http://localhost:3000/swagger`
|
||||
@@ -0,0 +1,9 @@
|
||||
# Cap Standalone
|
||||
|
||||
Cap Standalone is a self-hosted version of Cap's backend that allows you to spin up a server to validate and create challenges so you can use it with languages other than JS.
|
||||
|
||||
It's simple yet amazingly powerful, allowing you to use Cap in any language that can make HTTP requests. It's mostly compatible with reCAPTCHA and hCaptcha's siteverify enpoints, so you can use it as a drop-in replacement for them.
|
||||
|
||||
It also offers API key support, a built-in assets server, a dashboard with statistics, and more. To get started, follow [the instructions to install it on your server](installation.md).
|
||||
|
||||

|
||||
@@ -0,0 +1,32 @@
|
||||
# Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
You'll need to have [Docker Engine 20.10 or higher](https://docs.docker.com/get-docker/) installed on your server. Both `x86_64` (amd64) and `arm64` architectures are supported.
|
||||
|
||||
---
|
||||
|
||||
Run the following command to pull the Cap Standalone Docker image from Docker Hub:
|
||||
|
||||
```bash
|
||||
docker pull tiago2/cap:latest
|
||||
```
|
||||
|
||||
Then, to run the server, use the following command:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v cap-data:/usr/src/app/.data \
|
||||
-e ADMIN_KEY=your_secret_password \
|
||||
--name cap-standalone \
|
||||
tiago2/cap:latest
|
||||
```
|
||||
|
||||
Make sure to replace `your_secret_password` with a strong password, as anyone with it will be able to log into the dashboard and create keys. It'll need to be at least 30 characters long.
|
||||
|
||||
Then, you can access the dashboard at `http://localhost:3000`, log in, and create a key. You'll get a site key and a secret key which you'll be able to use on your widget.
|
||||
|
||||
You'll also need to make the server publicly accessible from the internet, as the widget needs to be able to reach it.
|
||||
|
||||
Done! Now, read the [usage guide](/guide/standalone/usage.md) to learn how to use it.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Options
|
||||
|
||||
## CORS
|
||||
|
||||
You can change the default CORS settings for redeeming and generating challenges by setting the `CORS_ORIGIN` environment variable when running the server. This defaults to `*`, which allows all origins.
|
||||
|
||||
## Asset server
|
||||
|
||||
The asset server is disabled by default. You can enable it by setting the `ENABLE_ASSETS_SERVER` environment variable to `true`. This will serve the assets from the `/assets` endpoint.
|
||||
|
||||
Then, make sure to set `WIDGET_VERSION` and `WASM_VERSION` to the correct version of the widget and WASM files you want to serve. This defaults to `latest`, which will serve the latest version of the widget and WASM files, although these are not recommended in production as they might serve breaking changes.
|
||||
|
||||
Your assets will be served from the following paths:
|
||||
|
||||
- `/assets/widget.js`
|
||||
- `/assets/floating.js`
|
||||
- `/assets/cap_wasm_bg.wasm`
|
||||
- `/assets/cap_wasm.js`
|
||||
|
||||
You can use these in your app by setting the widget's script source to the appropriate path, like this:
|
||||
|
||||
```html
|
||||
<script src="https://<server url>/assets/widget.js"></script>
|
||||
```
|
||||
|
||||
For the floating mode, use:
|
||||
|
||||
```html
|
||||
<script src="https://<server url>/assets/floating.js"></script>
|
||||
```
|
||||
|
||||
And by setting `window.CAP_CUSTOM_WASM_URL` to the path of the `cap_wasm_bg.wasm` file, like this:
|
||||
|
||||
```js
|
||||
window.CAP_CUSTOM_WASM_URL = "https://<server url>/assets/cap_wasm_bg.wasm";
|
||||
```
|
||||
|
||||
By default, these fetch from `process.env.CACHE_HOST` (which defaults to `https://cdn.jsdelivr.net`). You can change this by setting the `CACHE_HOST` env variable when running the server.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Usage
|
||||
|
||||
First, make sure Cap Standalone is installed and running by following [the installation guide](installation.md).
|
||||
|
||||
Then, you can get to using Cap Standalone:
|
||||
|
||||
### Client-side
|
||||
|
||||
Let's configure your widget to use your self-hosted Cap Standalone server. To do this, set the widget's API endpoint option to:
|
||||
|
||||
```
|
||||
https://<instance_url>/<site_key>/
|
||||
```
|
||||
|
||||
Make sure to replace:
|
||||
|
||||
- `<instance_url>`: The actual URL where your Cap Standalone instance is running. This URL must be publicly accessible from the internet.
|
||||
- `<site_key>`: Your site key from this dashboard.
|
||||
|
||||
Example:
|
||||
|
||||
```html
|
||||
<cap-widget
|
||||
data-cap-api-endpoint="https://cap.example.com/d9256640cb53/"
|
||||
></cap-widget>
|
||||
```
|
||||
|
||||
### Server-side
|
||||
|
||||
After a user completes the CAPTCHA on your site, your backend needs to verify their token using this server's API.
|
||||
|
||||
You can do this by sending a `POST` request from your server to the following endpoint:
|
||||
|
||||
```
|
||||
https://<instance_url>/<site_key>/siteverify
|
||||
```
|
||||
|
||||
Your request needs to include the following data:
|
||||
|
||||
- `secret`: Your key secret from this dashboard. This is **not** the admin key, but rather your site key's secret.
|
||||
|
||||
- `response`: The CAPTCHA token generated by the widget on the client-side
|
||||
|
||||
Example using `curl`:
|
||||
|
||||
```bash
|
||||
curl "https://<instance_url>/<site_key>/siteverify" \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "secret": "<key_secret>", "response": "<captcha_token>" }'
|
||||
```
|
||||
|
||||
The response should look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
Or, if the captcha token is invalid or expired, it will return:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false
|
||||
}
|
||||
```
|
||||
|
||||
If `success` is true, you can proceed with your app logic.
|
||||
|
||||
### Client-side library storage
|
||||
|
||||
To learn more about using the client-side library, check out the [guide on it](options.md#asset-server).
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"start": "vitepress dev",
|
||||
"dev": "vitepress dev",
|
||||
"start": "bunx --bun vitepress dev",
|
||||
"dev": "bunx --bun vitepress dev",
|
||||
"build": "vitepress build",
|
||||
"preview": "vitepress preview"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1 @@
|
||||
ADMIN_KEY=
|
||||
@@ -7,6 +7,7 @@
|
||||
"@cap.js/server": "^1.0.13",
|
||||
"@elysiajs/cors": "^1.3.3",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"@elysiajs/swagger": "^1.3.0",
|
||||
"elysia": "^1.3.1",
|
||||
"elysia-rate-limit": "^4.3.0",
|
||||
},
|
||||
@@ -24,6 +25,14 @@
|
||||
|
||||
"@elysiajs/static": ["@elysiajs/static@1.3.0", "", { "dependencies": { "node-cache": "^5.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-7mWlj2U/AZvH27IfRKqpUjDP1W9ZRldF9NmdnatFEtx0AOy7YYgyk0rt5hXrH6wPcR//2gO2Qy+k5rwswpEhJA=="],
|
||||
|
||||
"@elysiajs/swagger": ["@elysiajs/swagger@1.3.0", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-0fo3FWkDRPNYpowJvLz3jBHe9bFe6gruZUyf+feKvUEEMG9ZHptO1jolSoPE0ffFw1BgN1/wMsP19p4GRXKdfg=="],
|
||||
|
||||
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
|
||||
|
||||
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="],
|
||||
|
||||
"@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
|
||||
@@ -32,6 +41,8 @@
|
||||
|
||||
"@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="],
|
||||
|
||||
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
@@ -52,28 +63,44 @@
|
||||
|
||||
"file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
|
||||
|
||||
"nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
||||
|
||||
"node-cache": ["node-cache@5.1.2", "", { "dependencies": { "clone": "2.x" } }, "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"peek-readable": ["peek-readable@7.0.0", "", {}, "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ=="],
|
||||
|
||||
"strtok3": ["strtok3@10.2.2", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^7.0.0" } }, "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg=="],
|
||||
|
||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
|
||||
|
||||
"zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="],
|
||||
|
||||
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
|
||||
|
||||
"@tokenizer/inflate/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
|
||||
|
||||
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cap-standalone",
|
||||
"version": "1.0.17",
|
||||
"version": "2.0.0",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch src/index.js",
|
||||
@@ -48,6 +48,7 @@
|
||||
"@cap.js/server": "^1.0.13",
|
||||
"@elysiajs/cors": "^1.3.3",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"@elysiajs/swagger": "^1.3.0",
|
||||
"elysia": "^1.3.1",
|
||||
"elysia-rate-limit": "^4.3.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,745 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #181818;
|
||||
color: white;
|
||||
display: flex;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
flex-direction: column;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 0.8em 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
nav .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
margin-right: auto;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
nav .logo img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
nav .logo p {
|
||||
margin: 0px;
|
||||
color: rgb(248, 248, 248);
|
||||
opacity: 0;
|
||||
margin-left: -5px;
|
||||
transition: margin-left 0.2s, opacity 0.2s;
|
||||
pointer-events: none;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
nav .logo:hover p {
|
||||
opacity: 1;
|
||||
margin-left: 0px;
|
||||
filter: none;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
nav .logo:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
nav .settings {
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
color: rgb(248, 248, 248);
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
nav .settings:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
nav .logout {
|
||||
font-family: inherit;
|
||||
margin-left: auto;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
color: rgb(248, 248, 248);
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
nav .logout:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.state-loading {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
}
|
||||
|
||||
.state-loading svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.state-homepage {
|
||||
max-width: 520px;
|
||||
flex-direction: column;
|
||||
margin: 2em auto;
|
||||
width: 100%;
|
||||
padding: 0px 0.5rem;
|
||||
}
|
||||
|
||||
.state-homepage h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top {
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 8px;
|
||||
background-color: #161616;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top input {
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-left: 0.8em;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top:has(input:focus) {
|
||||
border: 1px solid #3f3f3f;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 4px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background-color: #0076d8;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 0px 6px;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.keys-list.no-keys p {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-size: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #282828;
|
||||
border-radius: 10px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
color: white;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
animation: scaleIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 1px solid #323232;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-content header h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-content header .close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.modal .content {
|
||||
padding: 12px 16px;
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.modal-content input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.6em 0.7em;
|
||||
margin-top: 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
color: white;
|
||||
border: 1px solid #e3e4e810;
|
||||
background-color: #e3e4e807;
|
||||
font-family: inherit;
|
||||
margin-bottom: 8px;
|
||||
text-overflow: ellipsis;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.modal-content input[type="text"]::focus {
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.modal-content input[type="text"]:focus {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-content label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-content button.primary {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-family: inherit;
|
||||
height: 36px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 16px;
|
||||
width: calc(100% - 32px);
|
||||
margin-top: 0px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal-content button.primary.secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-content button.primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.modal-content button.primary:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.modal-content button.primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.key-created-header {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.key-created-header svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.key-created-header h3 {
|
||||
margin-top: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-content p.small-text {
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
max-width: 90%;
|
||||
opacity: 0.9;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.keys-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.keys-list .key {
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 8px;
|
||||
background-color: #161616;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 68px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.key .key-text {
|
||||
padding-left: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.key .key-text h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.key .key-text p {
|
||||
margin-bottom: 0px;
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.key .key-text p b {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.key .key-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.key .key-actions button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 100px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.key:focus,
|
||||
.key:active,
|
||||
.key:has(button:focus) {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
|
||||
}
|
||||
|
||||
.state-key-page {
|
||||
flex-direction: column;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 2em auto;
|
||||
padding: 0px 0.5rem;
|
||||
padding-bottom: 4em;
|
||||
}
|
||||
|
||||
.state-key-page .chart-container {
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
|
||||
border: 1px solid #e3e4e810;
|
||||
background-color: #e3e4e807;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.state-key-page .topbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left h1 {
|
||||
font-size: 23px;
|
||||
margin: 0px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 0.4em 0.6em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left button:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.state-key-page .time-duration {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.state-key-page .time-duration:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.state-key-page h2 {
|
||||
font-size: 18px;
|
||||
margin: 0px;
|
||||
font-weight: 600;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.danger-zone-buttons button {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
font-family: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 0.5em 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.danger-zone-buttons button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.danger-zone-buttons button:focus {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.danger-zone-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.config input {
|
||||
width: 100%;
|
||||
padding: 0.6em 0.7em;
|
||||
margin-top: 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
color: white;
|
||||
border: 1px solid #e3e4e810;
|
||||
background-color: #e3e4e807;
|
||||
font-family: inherit;
|
||||
margin-bottom: 8px;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.config input::focus {
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.config input:focus {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.config label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config .config-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-family: inherit;
|
||||
height: 36px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px 16px;
|
||||
}
|
||||
|
||||
.config button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.config button:focus {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
|
||||
}
|
||||
|
||||
.config button:disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.keypage-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
padding: 0px;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.63;
|
||||
margin-left: -6px;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.keypage-back:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.keypage-back:active {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.keypage-back svg {
|
||||
transition: margin-left 0.2s, margin-right 0.2s;
|
||||
}
|
||||
|
||||
.keypage-back:hover svg {
|
||||
margin-left: -2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.state-settings {
|
||||
flex-direction: column;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 2em auto;
|
||||
padding: 0px 0.5rem;
|
||||
padding-bottom: 4em;
|
||||
}
|
||||
|
||||
.state-settings {
|
||||
flex-direction: column;
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin: 2em auto;
|
||||
padding: 0px 0.5rem;
|
||||
padding-bottom: 4em;
|
||||
}
|
||||
|
||||
.state-settings h1 {
|
||||
margin-top: 0px;
|
||||
font-size: 27px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.state-settings h2 {
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.sessions-list, .apikeys-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sessions-list .session, .apikeys-list .apikey {
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 8px;
|
||||
background-color: #161616;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session .session-info, .apikey .apikey-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.session-token, .apikey-token {
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-expires, .apikey-created {
|
||||
opacity: 0.93;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.session .session-delete-button, .apikey .apikey-delete-button {
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 0.4em 0.6em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.session .session-delete-button:hover, .apikey .apikey-delete-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.session .session-delete-button:active, .apikey .apikey-delete-button:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.apikeys-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.apikeys-title button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #0076d8;
|
||||
color: white;
|
||||
padding: 0.4em 0.6em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.apikeys-title button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.apikeys-title button:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
+169
-298
@@ -3,306 +3,80 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cap</title>
|
||||
<link rel="icon" href="./public/logo.png" />
|
||||
<style>
|
||||
* {
|
||||
font-family: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular",
|
||||
"San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu",
|
||||
"arial", sans-serif;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
<link rel="stylesheet" href="./public/index.css" />
|
||||
|
||||
body {
|
||||
max-width: 600px;
|
||||
padding: 15px;
|
||||
margin: 3rem auto;
|
||||
background-color: #121214;
|
||||
color: #e8e8ea;
|
||||
}
|
||||
|
||||
nav .logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin-left: auto;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
nav a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
nav h1 {
|
||||
font-weight: 500;
|
||||
margin: 0px;
|
||||
font-size: 20px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
p.text {
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
color: white;
|
||||
background: #0076d8;
|
||||
background-origin: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
button.btn:focus {
|
||||
box-shadow: inset 0px 0.8px 0px -0.25px rgba(0, 118, 216, 0.2),
|
||||
0px 0.5px 1.5px rgba(54, 122, 246, 0.25), 0px 0px 0px 3.5px rgba(0, 118, 216, 0.5);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
button.btn:disabled {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dialog-wrapper {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fade-in 0.2s;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-wrapper .dialog {
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
background-color: #18181a;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
animation: dialog-in 0.2s;
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px,
|
||||
rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px,
|
||||
rgba(0, 0, 0, 0.09) 0px -3px 5px;
|
||||
}
|
||||
|
||||
@keyframes dialog-in {
|
||||
0% {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog .close-button {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #e8e8ea;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
margin: 0px;
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dialog .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.key-creation-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 18px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.key-creation-form label {
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.9;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.key-creation-form input {
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.key-creation-form input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.key-creation-form input:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.keys p {
|
||||
text-align: center;
|
||||
margin: 0px;
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.keys .copy-key {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background-origin: border-box;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.keys .copy-key:focus {
|
||||
box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2),
|
||||
0px 0.5px 1.5px rgba(54, 122, 246, 0.25), 0px 0px 0px 3.5px rgba(255, 255, 255, 0.1);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.keys .edit-key {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
color: inherit;
|
||||
padding: 0px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: -4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.keys .edit-key:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.keys .key {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key .key-name {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.action-secundary {
|
||||
border: none;
|
||||
padding: 0px;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-secundary:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dialog .actions-col {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.key-id {
|
||||
opacity: 0.8;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" href="./public/logo-small.webp" />
|
||||
<title>Cap Standalone</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<img src="./public/logo.png" alt="Cap logo" class="logo" />
|
||||
<h1>Cap</h1>
|
||||
<a href="/internal/auth/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
|
||||
<div class="title">
|
||||
<h2>Your keys</h2>
|
||||
<button class="new-keys btn">
|
||||
<button class="logo">
|
||||
<img src="./public/logo-small.webp" alt="Cap logo" />
|
||||
<p>Standalone</p>
|
||||
</button>
|
||||
<button class="logout">Logout</button>
|
||||
<button class="settings">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-settings"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"
|
||||
/>
|
||||
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="state-loading">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Loading..."
|
||||
title="Loading..."
|
||||
>
|
||||
<style>
|
||||
.spinner_ajPY {
|
||||
transform-origin: center;
|
||||
animation: spinner_AtaB 0.4s infinite linear;
|
||||
}
|
||||
@keyframes spinner_AtaB {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
class="spinner_ajPY"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="state-homepage" style="display: none">
|
||||
<div class="keys-top">
|
||||
<input type="text" placeholder="Search keys…" class="search-input" />
|
||||
<button class="create-key">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -314,13 +88,110 @@
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
Create key
|
||||
<span>Add key</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keys-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="keys">
|
||||
<p>Loading keys...</p>
|
||||
<div class="state-key-page" style="display: none">
|
||||
<button class="keypage-back">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M15 6l-6 6l6 6" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<div class="topbar">
|
||||
<div class="left">
|
||||
<h1></h1>
|
||||
<button title="Copy site key"></button>
|
||||
</div>
|
||||
<div class="right">
|
||||
<select class="time-duration">
|
||||
<option value="today">Today</option>
|
||||
<option value="yesterday">Yesterday</option>
|
||||
<option value="last7days">Last 7 Days</option>
|
||||
<option value="last28days">Last 28 Days</option>
|
||||
<option value="last91days">Last 91 Days</option>
|
||||
<option value="alltime">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
|
||||
<h2>Configuration</h2>
|
||||
|
||||
<div class="config">
|
||||
<div class="config-item">
|
||||
<label for="keyName">Key name</label>
|
||||
<input type="text" id="keyName" value="" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="difficulty">Difficulty</label>
|
||||
<input type="number" id="difficulty" value="" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="challengeCount">Challenge Count</label>
|
||||
<input type="number" id="challengeCount" value="" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="saltSize">Salt Size</label>
|
||||
<input type="number" id="saltSize" value="" />
|
||||
</div>
|
||||
<button>Save</button>
|
||||
</div>
|
||||
|
||||
<h2>Danger zone</h2>
|
||||
<div class="danger-zone-buttons">
|
||||
<button class="rotate-secret">Rotate key secret</button>
|
||||
<button class="delete-key">Delete key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-settings" style="display: none">
|
||||
<h1>Settings</h1>
|
||||
<h2>Sessions</h2>
|
||||
|
||||
<div class="sessions-list"></div>
|
||||
|
||||
<div class="apikeys-title">
|
||||
<h2>API Keys</h2>
|
||||
<button class="create-api-key">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-plus-icon lucide-plus"
|
||||
>
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg>
|
||||
<span>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="apikeys-list"></div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="/public/index.js"></script>
|
||||
<script src="./public/js/index.js" type="module"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0" async></script>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export default async (method, path, body) => {
|
||||
try {
|
||||
const { token, hash } = JSON.parse(localStorage.getItem("cap_auth"));
|
||||
|
||||
const requestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${btoa(
|
||||
JSON.stringify({
|
||||
token,
|
||||
hash,
|
||||
})
|
||||
)}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestInit.headers["Content-Type"] = "application/json";
|
||||
requestInit.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const json = await (await fetch(`/server${path}`, requestInit)).json();
|
||||
|
||||
if (json?.error === "Unauthorized") {
|
||||
localStorage.removeItem("cap_auth");
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
location.reload();
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (e) {
|
||||
return {
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import sendApiRequest from "./api.js";
|
||||
|
||||
import { openSettings } from "./settings.js";
|
||||
import { createModal } from "./utils.js";
|
||||
|
||||
async function openCreateApiKey() {
|
||||
const modal = createModal(
|
||||
"Add a key",
|
||||
`<div class="content">
|
||||
<label for="key-name">Key name</label>
|
||||
<input type="text" name="key-name" class="key-name-input" max-length="200" required>
|
||||
</div>
|
||||
|
||||
<button class="create-key-button primary" disabled>Create API key</button>`
|
||||
);
|
||||
modal.querySelector(".key-name-input").focus();
|
||||
|
||||
modal.querySelector(".key-name-input").addEventListener("input", (event) => {
|
||||
modal.querySelector(".create-key-button").disabled =
|
||||
!event.target.value.trim();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".key-name-input")
|
||||
.addEventListener("keydown", (event) => {
|
||||
if (event.target.value.trim() && event.key === "Enter") {
|
||||
modal.querySelector(".create-key-button").click();
|
||||
}
|
||||
});
|
||||
|
||||
modal.querySelector(".close-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".create-key-button")
|
||||
.addEventListener("click", async () => {
|
||||
const name = modal.querySelector(".key-name-input").value.trim();
|
||||
|
||||
modal.querySelector(".create-key-button").disabled = true;
|
||||
|
||||
try {
|
||||
const { apiKey } = await sendApiRequest("POST", "/settings/apikeys", {
|
||||
name,
|
||||
});
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
const successModal = createModal(
|
||||
"Key created",
|
||||
`<div class="content">
|
||||
<label for="api-key">API key</label>
|
||||
<input type="text" name="api-key" value="${apiKey}" readonly>
|
||||
|
||||
<p class="small-text">Make sure to copy your API key as it will not be shown again later.</p>
|
||||
</div>
|
||||
|
||||
<button class="close-small-button primary secondary">Close</button>`
|
||||
);
|
||||
|
||||
successModal
|
||||
.querySelector(".close-small-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
});
|
||||
|
||||
openSettings();
|
||||
} catch (error) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to create API key: ${error.message}</p></div>`
|
||||
);
|
||||
modal.querySelector(".create-key-button").disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".create-api-key")
|
||||
.addEventListener("click", openCreateApiKey);
|
||||
|
||||
export default openCreateApiKey;
|
||||
@@ -0,0 +1,100 @@
|
||||
import sendApiRequest from "./api.js";
|
||||
import setState from "./state.js";
|
||||
import initHomepage from "./homepage.js";
|
||||
|
||||
import { createModal } from "./utils.js";
|
||||
import loadKeyPage from "./keyPage.js";
|
||||
|
||||
async function openCreateKey(namePrefill = "") {
|
||||
const modal = createModal(
|
||||
"Add a key",
|
||||
`<div class="content">
|
||||
<label for="key-name">Key name</label>
|
||||
<input type="text" name="key-name" class="key-name-input" placeholder="Blog comments" value="${namePrefill}" max-length="200" required>
|
||||
</div>
|
||||
|
||||
<button class="create-key-button primary">Create key</button>`
|
||||
);
|
||||
modal.querySelector(".key-name-input").focus();
|
||||
|
||||
if (!namePrefill)
|
||||
document.querySelector(".create-key-button").disabled = true;
|
||||
|
||||
modal.querySelector(".key-name-input").addEventListener("input", (event) => {
|
||||
modal.querySelector(".create-key-button").disabled =
|
||||
!event.target.value.trim();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".key-name-input")
|
||||
.addEventListener("keydown", (event) => {
|
||||
if (event.target.value.trim() && event.key === "Enter") {
|
||||
modal.querySelector(".create-key-button").click();
|
||||
}
|
||||
});
|
||||
|
||||
modal.querySelector(".close-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".create-key-button")
|
||||
.addEventListener("click", async () => {
|
||||
const name = modal.querySelector(".key-name-input").value.trim();
|
||||
|
||||
modal.querySelector(".create-key-button").disabled = true;
|
||||
|
||||
try {
|
||||
const { siteKey, secretKey } = await sendApiRequest("POST", "/keys", {
|
||||
name,
|
||||
});
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
const successModal = createModal(
|
||||
"Key created",
|
||||
`<div class="content">
|
||||
<label for="site-key">Site key</label>
|
||||
<input type="text" name="site-key" value="${siteKey}" readonly>
|
||||
|
||||
<label for="secret-key">Secret key</label>
|
||||
<input type="text" name="secret-key" value="${secretKey}" readonly>
|
||||
|
||||
<p class="small-text">Make sure to copy your secret key as it will not be shown again later.</p>
|
||||
</div>
|
||||
|
||||
<button class="open-key-button primary" style="margin-bottom:8px">Open key</button>
|
||||
<button class="close-small-button primary secondary">Close</button>`
|
||||
);
|
||||
|
||||
successModal
|
||||
.querySelector(".open-key-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
loadKeyPage(siteKey, secretKey);
|
||||
});
|
||||
successModal
|
||||
.querySelector(".close-small-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
});
|
||||
|
||||
await initHomepage();
|
||||
setState("homepage");
|
||||
} catch (error) {
|
||||
setState("homepage");
|
||||
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to create key: ${error.message}</p></div>`
|
||||
);
|
||||
modal.querySelector(".create-key-button").disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector(".create-key").addEventListener("click", () => {
|
||||
openCreateKey(document.querySelector(".search-input").value?.trim());
|
||||
});
|
||||
|
||||
export default openCreateKey;
|
||||
@@ -0,0 +1,82 @@
|
||||
import sendApiRequest from "./api.js";
|
||||
import loadKeyPage from "./keyPage.js";
|
||||
import setState from "./state.js";
|
||||
|
||||
const initHomepage = async () => {
|
||||
setState("loading");
|
||||
|
||||
let keys;
|
||||
|
||||
try {
|
||||
keys = await sendApiRequest("GET", "/keys");
|
||||
} catch {
|
||||
setState("homepage");
|
||||
|
||||
document.querySelector(".keys-list").classList.add("no-keys");
|
||||
document.querySelector(
|
||||
".keys-list"
|
||||
).innerHTML = `<p>An error occurred while fetching your keys. Please try again later.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
setState("homepage");
|
||||
|
||||
document.querySelector(".keys-list").classList.remove("no-keys");
|
||||
document.querySelector(".keys-list").innerHTML = "";
|
||||
|
||||
if (keys.length === 0) {
|
||||
document.querySelector(".keys-list").classList.add("no-keys");
|
||||
document.querySelector(
|
||||
".keys-list"
|
||||
).innerHTML = `<p>You don't have any keys</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach((key) => {
|
||||
const keyEl = document.createElement("div");
|
||||
keyEl.classList.add("key");
|
||||
|
||||
let differenceItem = "〰";
|
||||
if (key.difference.direction === "up") {
|
||||
differenceItem = `<span style='color:rgb(34 197 94)'><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" width="12" height="12"><path fill-rule="evenodd" d="M8.25 3.75H19.5a.75.75 0 01.75.75v11.25a.75.75 0 01-1.5 0V6.31L5.03 20.03a.75.75 0 01-1.06-1.06L17.69 5.25H8.25a.75.75 0 010-1.5z" clip-rule="evenodd">
|
||||
</path></svg></span>`;
|
||||
} else if (key.difference.direction === "down") {
|
||||
differenceItem = `<span style='color:rgb(248 113 113)'><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" width="12" height="12"><path fill-rule="evenodd" d="M3.97 3.97a.75.75 0 011.06 0l13.72 13.72V8.25a.75.75 0 011.5 0V19.5a.75.75 0 01-.75.75H8.25a.75.75 0 010-1.5h9.44L3.97 5.03a.75.75 0 010-1.06z" clip-rule="evenodd"></path></svg></span>`;
|
||||
}
|
||||
|
||||
keyEl.innerHTML = `
|
||||
<div class="key-text">
|
||||
<h2>${key.name}</h2>
|
||||
<p><b>${key.solvesLast24h}</b> solves in last 24h • ${differenceItem} ${key.difference.value}%</p>
|
||||
</div>
|
||||
<div class="key-actions">
|
||||
<button class="open-key" title="Open key"><svg alt="Right arrow" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M13 18l6 -6" /><path d="M13 6l6 6" /></svg></button>
|
||||
</div>
|
||||
`;
|
||||
document.querySelector(".keys-list").appendChild(keyEl);
|
||||
|
||||
keyEl.addEventListener("click", () => {
|
||||
keyEl.querySelector(".open-key").click();
|
||||
});
|
||||
|
||||
keyEl.querySelector(".open-key").addEventListener("click", (event) => {
|
||||
loadKeyPage(key.siteKey);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelector(".keys-top input").addEventListener("input", (event) => {
|
||||
const searchTerm = event.target.value.toLowerCase();
|
||||
const keys = document.querySelectorAll(".keys-list .key");
|
||||
|
||||
keys.forEach((key) => {
|
||||
const keyName = key.querySelector("h2").innerText.toLowerCase();
|
||||
if (keyName.includes(searchTerm)) {
|
||||
key.style.display = "flex";
|
||||
} else {
|
||||
key.style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default initHomepage;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { openSettings } from "./settings.js";
|
||||
import initHomepage from "./homepage.js";
|
||||
import { createModal } from "./utils.js";
|
||||
import sendApiRequest from "./api.js";
|
||||
import setState from "./state.js";
|
||||
|
||||
import "./createKey.js";
|
||||
import "./createApiKey.js";
|
||||
|
||||
initHomepage();
|
||||
|
||||
document.querySelectorAll(".logo, .keypage-back").forEach((el) =>
|
||||
el.addEventListener("click", () => {
|
||||
setState("homepage");
|
||||
initHomepage();
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelector(".settings").addEventListener("click", openSettings);
|
||||
|
||||
document.querySelector(".logout").addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Logout?",
|
||||
`<button class="logout-confirm-button primary" style="margin-bottom:8px">Yes, log out</button>
|
||||
<button class="logout-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".logout-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".logout-confirm-button").disabled = true;
|
||||
await sendApiRequest("POST", "/logout");
|
||||
|
||||
localStorage.removeItem("cap_auth");
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
location.reload();
|
||||
});
|
||||
|
||||
modal.querySelector(".logout-cancel-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
|
||||
if (!localStorage.getItem("cap_auth")) {
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { createModal } from "./utils.js";
|
||||
import makeApiRequest from "./api.js";
|
||||
import setState from "./state.js";
|
||||
import initHomepage from "./homepage.js";
|
||||
|
||||
let currentChart = null;
|
||||
let currentSiteKey = null;
|
||||
|
||||
export default async function loadKeyPage(siteKey) {
|
||||
setState("loading");
|
||||
currentSiteKey = siteKey;
|
||||
|
||||
try {
|
||||
const response = await makeApiRequest("GET", `/keys/${siteKey}`);
|
||||
|
||||
if (!response.success && response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
displayKeyData(response);
|
||||
await loadChartData();
|
||||
setupEventListeners();
|
||||
setState("keyPage");
|
||||
} catch (error) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to load key data: ${error.message}</p></div>`
|
||||
);
|
||||
setState("homepage");
|
||||
}
|
||||
}
|
||||
|
||||
function displayKeyData(data) {
|
||||
const { key } = data;
|
||||
|
||||
const titleElement = document.querySelector(".state-key-page .topbar h1");
|
||||
titleElement.innerText = key.name;
|
||||
|
||||
const configElement = document.querySelector(".state-key-page .config");
|
||||
configElement.querySelector("#keyName").value = key.name;
|
||||
configElement.querySelector("#difficulty").value = key.config.difficulty;
|
||||
configElement.querySelector("#challengeCount").value =
|
||||
key.config.challengeCount;
|
||||
configElement.querySelector("#saltSize").value = key.config.saltSize;
|
||||
|
||||
configElement.querySelector("button").onclick = async () => {
|
||||
const name = configElement.querySelector("#keyName").value.trim();
|
||||
const difficulty = parseInt(
|
||||
configElement.querySelector("#difficulty").value,
|
||||
10
|
||||
);
|
||||
const challengeCount = parseInt(
|
||||
configElement.querySelector("#challengeCount").value,
|
||||
10
|
||||
);
|
||||
const saltSize = parseInt(
|
||||
configElement.querySelector("#saltSize").value,
|
||||
10
|
||||
);
|
||||
|
||||
if (
|
||||
!name ||
|
||||
isNaN(difficulty) ||
|
||||
isNaN(challengeCount) ||
|
||||
isNaN(saltSize)
|
||||
) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Please fill in all fields correctly.</p></div>`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector(".state-key-page h1").textContent =
|
||||
configElement.querySelector("#keyName").value;
|
||||
configElement.querySelector("button").disabled = true;
|
||||
|
||||
const { success } = await makeApiRequest(
|
||||
"PUT",
|
||||
`/keys/${key.siteKey}/config`,
|
||||
{
|
||||
name,
|
||||
difficulty,
|
||||
challengeCount,
|
||||
saltSize,
|
||||
}
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to save key.</p></div>`
|
||||
);
|
||||
}
|
||||
|
||||
configElement.querySelector("button").disabled = false;
|
||||
};
|
||||
|
||||
document
|
||||
.querySelector(".danger-zone-buttons .delete-key")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Delete key?",
|
||||
`<div class="content"><p>Are you sure you want to delete this key? This action cannot be undone.</p></div>
|
||||
<button class="delete-confirm-button primary" style="margin-bottom:8px">Yes, delete key</button>
|
||||
<button class="delete-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal
|
||||
.querySelector(".delete-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".delete-confirm-button").disabled = true;
|
||||
const { success } = await makeApiRequest(
|
||||
"DELETE",
|
||||
`/keys/${key.siteKey}`
|
||||
);
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
if (success) {
|
||||
initHomepage();
|
||||
} else {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to delete key</p></div>`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".delete-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector(".danger-zone-buttons .rotate-secret")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Rotate secret key?",
|
||||
`<div class="content"><p>Are you sure you want to rotate this key's secret? This won't delete the key itself.</p></div>
|
||||
<button class="rotate-confirm-button primary" style="margin-bottom:8px">Yes, rotate key</button>
|
||||
<button class="rotate-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".rotate-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".rotate-confirm-button").disabled = true;
|
||||
const { secretKey } = await makeApiRequest(
|
||||
"POST",
|
||||
`/keys/${key.siteKey}/rotate-secret`
|
||||
);
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
if (!secretKey) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to rotate key secret.</p></div>`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const successModal = createModal(
|
||||
"Secret key rotated",
|
||||
`<div class="content">
|
||||
<label for="secret-key">New secret key</label>
|
||||
<input type="text" name="secret-key" value="${secretKey}" readonly>
|
||||
|
||||
<p class="small-text">Make sure to copy your secret key as it will not be shown again later.</p>
|
||||
</div>
|
||||
|
||||
<button class="close-small-button primary secondary">Close</button>`
|
||||
);
|
||||
|
||||
successModal
|
||||
.querySelector(".close-small-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
});
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".rotate-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
|
||||
const copyButton = document.querySelector(".state-key-page .topbar button");
|
||||
copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-copy"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7m0 2.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z" /><path d="M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1" /></svg> Copy site key`;
|
||||
|
||||
copyButton.onclick = () => {
|
||||
navigator.clipboard.writeText(key.siteKey);
|
||||
const originalText = copyButton.innerHTML;
|
||||
copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-check"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg> Copied!`;
|
||||
setTimeout(() => {
|
||||
copyButton.innerHTML = originalText;
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
async function loadChartData(duration = "today") {
|
||||
document.querySelector(".chart-container").style.opacity = "0.3";
|
||||
document.querySelector(".chart-container").style.pointerEvents = "none";
|
||||
|
||||
const response = await makeApiRequest(
|
||||
"GET",
|
||||
`/keys/${currentSiteKey}?chartDuration=${duration}`
|
||||
);
|
||||
|
||||
document.querySelector(".chart-container").style.opacity = "1";
|
||||
document.querySelector(".chart-container").style.pointerEvents = "all";
|
||||
|
||||
renderChart(response.chartData, duration);
|
||||
}
|
||||
|
||||
function formatDateLabel(timestamp, duration) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
||||
switch (duration) {
|
||||
case "today":
|
||||
case "yesterday":
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
case "last7days":
|
||||
case "last28days":
|
||||
case "last91days":
|
||||
case "alltime":
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
default:
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(chartData, duration) {
|
||||
const canvas = document.getElementById("chart");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (currentChart) {
|
||||
currentChart.destroy();
|
||||
}
|
||||
|
||||
const labels = [];
|
||||
const dataPoints = [];
|
||||
|
||||
const { bucketSize, data } = chartData;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
let startTime, endTime, buckets;
|
||||
|
||||
switch (duration) {
|
||||
case "today":
|
||||
startTime = Math.floor(Date.now() / 1000 / 86400) * 86400;
|
||||
endTime = startTime + 86400;
|
||||
buckets = Math.floor((endTime - startTime) / bucketSize);
|
||||
break;
|
||||
case "yesterday":
|
||||
startTime = Math.floor(Date.now() / 1000 / 86400) * 86400 - 86400;
|
||||
endTime = startTime + 86400;
|
||||
buckets = Math.floor((endTime - startTime) / bucketSize);
|
||||
break;
|
||||
case "last7days":
|
||||
startTime = now - 7 * 86400;
|
||||
endTime = now;
|
||||
buckets = 7;
|
||||
break;
|
||||
case "last28days":
|
||||
startTime = now - 28 * 86400;
|
||||
endTime = now;
|
||||
buckets = 28;
|
||||
break;
|
||||
case "last91days":
|
||||
startTime = now - 91 * 86400;
|
||||
endTime = now;
|
||||
buckets = 91;
|
||||
break;
|
||||
case "alltime":
|
||||
if (data.length > 0) {
|
||||
startTime = data[0].bucket;
|
||||
endTime = now;
|
||||
buckets = Math.ceil((endTime - startTime) / bucketSize);
|
||||
} else {
|
||||
startTime = now - 86400;
|
||||
endTime = now;
|
||||
buckets = 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
startTime = now - 86400;
|
||||
endTime = now;
|
||||
buckets = 24;
|
||||
}
|
||||
|
||||
const dataMap = new Map();
|
||||
data.forEach((item) => {
|
||||
dataMap.set(item.bucket, item.count);
|
||||
});
|
||||
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
const bucketTime = startTime + i * bucketSize;
|
||||
labels.push(formatDateLabel(bucketTime, duration));
|
||||
|
||||
const alignedBucket = Math.floor(bucketTime / bucketSize) * bucketSize;
|
||||
dataPoints.push(dataMap.get(alignedBucket) || 0);
|
||||
}
|
||||
|
||||
function createGradient(chart) {
|
||||
const { ctx, chartArea } = chart;
|
||||
if (!chartArea) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gradient = ctx.createLinearGradient(
|
||||
0,
|
||||
chartArea.bottom,
|
||||
0,
|
||||
chartArea.top
|
||||
);
|
||||
|
||||
gradient.addColorStop(0, "rgba(0, 118, 216, 0.05)");
|
||||
gradient.addColorStop(0.5, "rgba(0, 118, 216, 0.3)");
|
||||
gradient.addColorStop(1, "rgba(0, 118, 216, 0.7)");
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
currentChart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Daily Count",
|
||||
data: dataPoints,
|
||||
fill: true,
|
||||
backgroundColor: function (context) {
|
||||
return createGradient(context.chart);
|
||||
},
|
||||
borderColor: "#0076D8",
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointBackgroundColor: "#0076D8",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 1.5,
|
||||
pointHoverRadius: 0,
|
||||
pointHoverBackgroundColor: "#fff",
|
||||
pointHoverBorderColor: "#0076D8",
|
||||
pointHoverBorderWidth: 2,
|
||||
tension: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: "rgba(255, 255, 255, 0.08)",
|
||||
borderColor: "rgba(255, 255, 255, 0.15)",
|
||||
drawTicks: false,
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "#b0b0b0",
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(255, 255, 255, 0.08)",
|
||||
borderColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
ticks: {
|
||||
color: "#b0b0b0",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: function (tooltipItems) {
|
||||
return tooltipItems[0].label;
|
||||
},
|
||||
label: function (tooltipItem) {
|
||||
return `${tooltipItem.raw} solves`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (currentChart) {
|
||||
currentChart.update("resize");
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(canvas);
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
const timeDurationSelect = document.querySelector(
|
||||
".state-key-page .time-duration"
|
||||
);
|
||||
|
||||
timeDurationSelect.onchange = (e) => {
|
||||
const duration = e.target.value;
|
||||
loadChartData(duration);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import setState from "./state.js";
|
||||
import { createModal } from "./utils.js";
|
||||
import makeApiRequest from "./api.js";
|
||||
|
||||
export const openSettings = () => {
|
||||
setState("settings");
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const sessions = await makeApiRequest("GET", "/settings/sessions");
|
||||
|
||||
const sessionsList = document.querySelector(
|
||||
".state-settings .sessions-list"
|
||||
);
|
||||
sessionsList.innerHTML = "";
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const sessionEl = document.createElement("div");
|
||||
sessionEl.classList.add("session");
|
||||
|
||||
sessionEl.innerHTML = `
|
||||
<div class="session-info">
|
||||
<span class="session-token">${session.token}...</span>
|
||||
<span class="session-expires">${
|
||||
JSON.parse(localStorage.getItem("cap_auth")).hash.endsWith(
|
||||
session.token
|
||||
)
|
||||
? "<b>current</b> • "
|
||||
: ""
|
||||
}expires ${formatDate(session.expires)}${
|
||||
JSON.parse(localStorage.getItem("cap_auth")).hash.endsWith(
|
||||
session.token
|
||||
)
|
||||
? ""
|
||||
: " • created " + formatDate(session.created)
|
||||
}</span>
|
||||
</div>
|
||||
<button class="session-delete-button">Logout</button>
|
||||
`;
|
||||
|
||||
sessionsList.appendChild(sessionEl);
|
||||
|
||||
sessionEl
|
||||
.querySelector(".session-delete-button")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Logout session?",
|
||||
`<button class="logout-confirm-button primary" style="margin-bottom:8px;">Yes, log out</button>
|
||||
<button class="logout-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".logout-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".logout-confirm-button").disabled = true;
|
||||
await makeApiRequest("POST", `/logout`, {
|
||||
session: session.token,
|
||||
});
|
||||
document.body.removeChild(modal);
|
||||
if (sessions.length === 1) {
|
||||
localStorage.removeItem("cap_auth");
|
||||
location.reload();
|
||||
}
|
||||
openSettings();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".logout-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
const apikeys = await makeApiRequest("GET", "/settings/apikeys");
|
||||
|
||||
const apikeysList = document.querySelector(".state-settings .apikeys-list");
|
||||
apikeysList.innerHTML = "";
|
||||
|
||||
if (apikeys.length === 0) {
|
||||
apikeysList.innerHTML = `<p>You don't have any API keys</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
apikeys.forEach((key) => {
|
||||
const keyEl = document.createElement("div");
|
||||
keyEl.classList.add("apikey");
|
||||
|
||||
keyEl.innerHTML = `
|
||||
<div class="apikey-info">
|
||||
<span class="apikey-token">${key.name
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")}</span>
|
||||
<span class="apikey-created" title="${key.id}...">${key.id.slice(
|
||||
0,
|
||||
12
|
||||
)}... • created ${formatDate(key.created)}</span>
|
||||
</div>
|
||||
<button class="apikey-delete-button">Delete</button>
|
||||
`;
|
||||
|
||||
apikeysList.appendChild(keyEl);
|
||||
|
||||
keyEl
|
||||
.querySelector(".apikey-delete-button")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Delete API key?",
|
||||
`<button class="delete-confirm-button primary" style="margin-bottom:8px;">Yes, delete</button>
|
||||
<button class="delete-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".delete-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".delete-confirm-button").disabled = true;
|
||||
await makeApiRequest("DELETE", `/settings/apikeys/${key.id}`);
|
||||
document.body.removeChild(modal);
|
||||
openSettings();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".delete-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
const states = {
|
||||
loading: document.querySelector(".state-loading"),
|
||||
homepage: document.querySelector(".state-homepage"),
|
||||
keyPage: document.querySelector(".state-key-page"),
|
||||
settings: document.querySelector(".state-settings"),
|
||||
};
|
||||
|
||||
export default function setState(stateName) {
|
||||
for (const [name, element] of Object.entries(states)) {
|
||||
if (name === stateName) {
|
||||
element.style.display = "flex";
|
||||
} else {
|
||||
element.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const createModal = (title, content) => {
|
||||
if (document.querySelector(".modal"))
|
||||
document.querySelector(".modal").remove();
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<header>
|
||||
<h2>${title}</h2>
|
||||
<button class="close-button" title="Close"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
</header>
|
||||
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector(".close-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
|
||||
document.body.addEventListener("click", (event) => {
|
||||
if (event.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
});
|
||||
|
||||
return modal;
|
||||
};
|
||||
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login to Cap</title>
|
||||
<link rel="icon" href="./public/logo-small.webp" />
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||
Oxygen-Sans, Ubuntu, Cantarell, Helvetica Neue, sans-serif;
|
||||
}
|
||||
body {
|
||||
text-align: center;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
margin-top: 10%;
|
||||
background-color: #181818;
|
||||
color: #e8e8ea;
|
||||
}
|
||||
form {
|
||||
max-width: 300px;
|
||||
margin: 0px auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
img {
|
||||
width: 60px;
|
||||
}
|
||||
label {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: #d9dfe5eb;
|
||||
}
|
||||
|
||||
.password-wrapper {
|
||||
border: 1px solid #e3e4e810;
|
||||
background-color: #e3e4e807;
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
transition: box-shadow 0.13s ease-in-out;
|
||||
}
|
||||
|
||||
.showhide-password {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.password {
|
||||
font-size: 24px;
|
||||
line-height: 1.33333333;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
height: 40px;
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
letter-spacing: -2px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.password.show-password {
|
||||
letter-spacing: 0px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.password:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.password-wrapper:has(.password:focus) {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.submit {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
color: black;
|
||||
background: white;
|
||||
background-origin: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: box-shadow 0.13s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit:focus {
|
||||
box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2),
|
||||
0px 0.5px 1.5px rgba(54, 122, 246, 0.25),
|
||||
0px 0px 0px 3.5px rgba(255, 255, 255, 0.5);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.submit:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
p.bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
p.bottom .poweredby {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
p.err {
|
||||
color: #ffffff;
|
||||
display: none;
|
||||
margin: 0px;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(255, 255, 255);
|
||||
opacity: 0.4;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form action="javascript:void(0)">
|
||||
<div class="logo">
|
||||
<img src="./public/logo-small.webp" alt="Cap logo" />
|
||||
</div>
|
||||
<label for="password">Admin key</label>
|
||||
<div class="password-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="•••••••••••••••••••••"
|
||||
class="password"
|
||||
/>
|
||||
<button
|
||||
title="Show/hide password"
|
||||
class="showhide-password"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-eye-icon lucide-eye"
|
||||
>
|
||||
<path
|
||||
d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit" disabled>Continue</button>
|
||||
<p class="err"></p>
|
||||
<p class="bottom">
|
||||
<!-- prettier-ignore -->
|
||||
<a
|
||||
href="https://github.com/tiagorangel1/cap/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
>Need help?</a>
|
||||
<a href="https://capjs.js.org/" target="_blank" class="poweredby">
|
||||
Powered by Cap
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</body>
|
||||
<script>
|
||||
let loading = false;
|
||||
document.querySelector("input").focus();
|
||||
|
||||
document.querySelector(".password").addEventListener("input", function (e) {
|
||||
if (document.querySelector(".password").value.trim().length < 30) {
|
||||
document.querySelector("button[type=submit]").disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!loading) {
|
||||
document.querySelector("button[type=submit]").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector("form")
|
||||
.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const admin_key = document.querySelector("input[name=password]").value;
|
||||
|
||||
if (!admin_key) return;
|
||||
|
||||
document.querySelector("button[type=submit]").disabled = true;
|
||||
|
||||
const { success, session_token, hashed_token, expires } = await (
|
||||
await fetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
admin_key,
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
|
||||
if (success && session_token) {
|
||||
localStorage.setItem(
|
||||
"cap_auth",
|
||||
JSON.stringify({
|
||||
token: session_token,
|
||||
expires: expires,
|
||||
hash: hashed_token,
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelector("p.err").style.display = "none";
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector("button[type=submit]").disabled = false;
|
||||
document.querySelector("p.err").style.display = "block";
|
||||
document.querySelector("p.err").innerText =
|
||||
"Incorrect admin key. Please try again.";
|
||||
document.querySelector("input[name=password]").value = "";
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector(".showhide-password")
|
||||
.addEventListener("click", () => {
|
||||
const passwordInput = document.querySelector("input[name=password]");
|
||||
const showhideButton = document.querySelector(".showhide-password");
|
||||
passwordInput.classList.toggle("show-password");
|
||||
|
||||
if (passwordInput.type === "password") {
|
||||
passwordInput.type = "text";
|
||||
passwordInput.placeholder = "";
|
||||
showhideButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye-off-icon lucide-eye-off"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`;
|
||||
} else {
|
||||
passwordInput.type = "password";
|
||||
passwordInput.placeholder = "••••••••••••••";
|
||||
showhideButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye-icon lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Challenge Tester</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
max-width: 500px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
label,
|
||||
input,
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
padding: 0.5em;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
pre {
|
||||
background: #fafafa;
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5em;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
cap-widget {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<label for="keyIdInput">Key ID:</label>
|
||||
<input type="text" id="keyIdInput" placeholder="e.g. 19d7e51172" />
|
||||
|
||||
<label for="secretKeyInput">Secret key:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="secretKeyInput"
|
||||
placeholder="e.g. eOAdXZvmQecVrvh..."
|
||||
/>
|
||||
|
||||
<button id="startButton">Start</button>
|
||||
|
||||
<pre class="logs">No logs</pre>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/@cap.js/widget@0.1.21/cap.min.js"></script>
|
||||
<script>
|
||||
const keyIdInput = document.getElementById("keyIdInput");
|
||||
const secretKeyInput = document.getElementById("secretKeyInput");
|
||||
const startButton = document.getElementById("startButton");
|
||||
const logsElement = document.querySelector(".logs");
|
||||
let cap = null;
|
||||
|
||||
const log = (msg) => {
|
||||
logsElement.textContent += `${msg}\n`;
|
||||
logsElement.scrollTop = logsElement.scrollHeight;
|
||||
};
|
||||
|
||||
async function validateToken(keyId, secretKey, token) {
|
||||
log(`\nValidating token...`);
|
||||
const response = await fetch(`/${keyId}/siteverify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ secret: secretKey, response: token }),
|
||||
});
|
||||
const { success } = await response.json();
|
||||
log(`Token: ${token}`);
|
||||
log(`Siteverify response: ${success}`);
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
const keyId = keyIdInput.value.trim();
|
||||
const secretKey = secretKeyInput.value.trim();
|
||||
|
||||
if (!keyId || !secretKey) {
|
||||
logsElement.textContent =
|
||||
"Error: Key ID and Secret Key are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
logsElement.textContent = `Requesting challenge for Key ID: ${keyId}...\n`;
|
||||
startButton.disabled = true;
|
||||
const startTime = Date.now();
|
||||
|
||||
cap?.widget?.remove();
|
||||
cap = new Cap({ apiEndpoint: `/${keyId}/` });
|
||||
cap.widget.style.display = "block";
|
||||
|
||||
cap.addEventListener("progress", (e) =>
|
||||
log(`Solving... ${e.detail.progress}%`)
|
||||
);
|
||||
cap.addEventListener("error", (e) => log(`ERROR: ${e.detail.error}`));
|
||||
|
||||
try {
|
||||
const result = await cap.solve();
|
||||
log(`Solved in ${Date.now() - startTime}ms`);
|
||||
await validateToken(keyId, secretKey, result.token);
|
||||
} catch (err) {
|
||||
log(`Error during solve: ${err}`);
|
||||
} finally {
|
||||
startButton.disabled = false;
|
||||
if (cap.widget) cap.widget.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
startButton.addEventListener("click", handleStart);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import path from "node:path";
|
||||
import { Elysia, file } from "elysia";
|
||||
|
||||
if (
|
||||
process.env.ENABLE_ASSETS_SERVER === "true" &&
|
||||
(process.env.WIDGET_VERSION === "latest" ||
|
||||
process.env.WASM_VERSION === "latest")
|
||||
) {
|
||||
console.warn(
|
||||
"📦 [asset server] using 'latest' version for assets is not recommended for production!\n make sure to pin it to a set version using the WIDGET_VERSION and WASM_VERSION env variables."
|
||||
);
|
||||
}
|
||||
|
||||
const dataDir = "./.data";
|
||||
const updateCache = async () => {
|
||||
if (process.env.ENABLE_ASSETS_SERVER !== "true") return;
|
||||
|
||||
let cacheConfig = {};
|
||||
|
||||
try {
|
||||
cacheConfig = JSON.parse(await fs.readFile("./assets-cache.json", "utf-8"));
|
||||
} catch {}
|
||||
|
||||
const lastUpdate = cacheConfig["lastUpdate"] || 0;
|
||||
const currentTime = Date.now();
|
||||
const updateInterval = 1000 * 60 * 60 * 24; // 1 day
|
||||
|
||||
if (!(currentTime - lastUpdate > updateInterval)) return;
|
||||
|
||||
const CACHE_HOST = process.env.CACHE_HOST || "https://cdn.jsdelivr.net";
|
||||
|
||||
const WIDGET_VERSION = process.env.WIDGET_VERSION || "latest";
|
||||
const WASM_VERSION = process.env.WASM_VERSION || "latest";
|
||||
|
||||
try {
|
||||
const [widgetSource, floatingSource, wasmSource, wasmLoaderSource] =
|
||||
await Promise.all([
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}`).then((r) =>
|
||||
r.text()
|
||||
),
|
||||
fetch(
|
||||
`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}/cap-floating.min.js`
|
||||
).then((r) => r.text()),
|
||||
fetch(
|
||||
`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm_bg.wasm`
|
||||
).then((r) => r.arrayBuffer()),
|
||||
fetch(
|
||||
`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm.min.js`
|
||||
).then((r) => r.text()),
|
||||
]);
|
||||
|
||||
cacheConfig["lastUpdate"] = currentTime;
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cache.json"),
|
||||
JSON.stringify(cacheConfig)
|
||||
);
|
||||
|
||||
await fs.writeFile(path.join(dataDir, "assets-widget.js"), widgetSource);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-floating.js"),
|
||||
floatingSource
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cap_wasm_bg.wasm"),
|
||||
Buffer.from(wasmSource)
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cap_wasm.js"),
|
||||
wasmLoaderSource
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("📦 [asset server] failed to update assets cache:", e);
|
||||
}
|
||||
};
|
||||
|
||||
updateCache();
|
||||
setInterval(updateCache, 1000 * 60 * 60);
|
||||
|
||||
export const assetsServer = new Elysia({
|
||||
prefix: "/assets",
|
||||
detail: { tags: ["Assets"] },
|
||||
})
|
||||
.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || true,
|
||||
methods: ["GET"],
|
||||
})
|
||||
)
|
||||
.onBeforeHandle(({ set }) => {
|
||||
set.headers["Cache-Control"] = "max-age=31536000, immutable";
|
||||
})
|
||||
.get("/widget.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-widget.js"));
|
||||
})
|
||||
.get("/floating.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-floating.js"));
|
||||
})
|
||||
.get("/cap_wasm_bg.wasm", ({ set }) => {
|
||||
set.headers["Content-Type"] = "application/wasm";
|
||||
return file(path.join(dataDir, "assets-cap_wasm_bg.wasm"));
|
||||
})
|
||||
.get("/cap_wasm.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-cap_wasm.js"));
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Elysia } from "elysia";
|
||||
|
||||
import { ratelimitGenerator } from "./ratelimit.js";
|
||||
import { db } from "./db.js";
|
||||
|
||||
const { ADMIN_KEY } = process.env;
|
||||
|
||||
const loginQuery = db.query(`
|
||||
INSERT INTO sessions (token, created, expires)
|
||||
VALUES ($token, $created, $expires)
|
||||
`);
|
||||
const getValidTokenQuery = db.query(`
|
||||
SELECT * FROM sessions WHERE token = $token AND expires > $now LIMIT 1
|
||||
`);
|
||||
|
||||
if (!ADMIN_KEY) throw new Error("auth: Admin key missing. Please add one");
|
||||
if (ADMIN_KEY.length < 30)
|
||||
throw new Error(
|
||||
"auth: Admin key too short. Please use one that's at least 30 characters"
|
||||
);
|
||||
|
||||
export const auth = new Elysia({
|
||||
prefix: "/auth",
|
||||
})
|
||||
.use(
|
||||
rateLimit({
|
||||
duration: 30_000,
|
||||
max: 20_000,
|
||||
scoping: "scoped",
|
||||
generator: ratelimitGenerator,
|
||||
})
|
||||
)
|
||||
.post("/login", async ({ body, set, cookie }) => {
|
||||
const { admin_key } = body;
|
||||
|
||||
const a = Buffer.from(admin_key, "utf8");
|
||||
const b = Buffer.from(ADMIN_KEY, "utf8");
|
||||
|
||||
if (!a || !b || a.length !== b.length) {
|
||||
set.status = 401;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (!timingSafeEqual(a, b)) {
|
||||
set.status = 401;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (admin_key !== ADMIN_KEY) {
|
||||
// as a last check, in case an attacker somehow bypasses
|
||||
// timingSafeEqual, we're checking AGAIN to see if the tokens
|
||||
// are right.
|
||||
|
||||
// yes, this is vulnerable to timing attacks, but those are
|
||||
// hard to execute and literally just accepting an invalid token
|
||||
// is worse.
|
||||
|
||||
set.status = 401;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const session_token = randomBytes(30).toString("hex");
|
||||
const expires = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const created = Date.now();
|
||||
|
||||
const hashedToken = await Bun.password.hash(session_token);
|
||||
|
||||
loginQuery.run({
|
||||
$token: hashedToken,
|
||||
$created: created,
|
||||
$expires: expires,
|
||||
});
|
||||
|
||||
cookie.cap_authed.set({
|
||||
value: "yes",
|
||||
expires: new Date(expires),
|
||||
});
|
||||
|
||||
return { success: true, session_token, hashed_token: hashedToken, expires };
|
||||
});
|
||||
|
||||
export const authBeforeHandle = async ({ set, headers }) => {
|
||||
const { authorization } = headers;
|
||||
|
||||
set.headers["X-Content-Type-Options"] = "nosniff";
|
||||
set.headers["X-Frame-Options"] = "DENY";
|
||||
set.headers["X-XSS-Protection"] = "1; mode=block";
|
||||
|
||||
if (authorization?.startsWith("Bot ")) {
|
||||
const botToken = authorization.replace("Bot ", "").trim();
|
||||
const [id, token] = botToken.split("_");
|
||||
|
||||
if (!id || !token) {
|
||||
set.status = 401;
|
||||
return { success: false, error: "Unauthorized. Invalid bot token." };
|
||||
}
|
||||
|
||||
const apiKey = db.query(`SELECT * FROM api_keys WHERE id = $id`).get({
|
||||
$id: id,
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized. Deleted or non-existent bot token.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await Bun.password.verify(token, apiKey.tokenHash))) {
|
||||
set.status = 401;
|
||||
return { success: false, error: "Unauthorized. Invalid bot token." };
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authorization || !authorization.startsWith("Bearer ")) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Unauthorized. An API key or session token is required to use this endpoint.",
|
||||
};
|
||||
}
|
||||
|
||||
const { token, hash } = JSON.parse(
|
||||
atob(authorization.replace("Bearer ", "").trim())
|
||||
);
|
||||
|
||||
const validToken = getValidTokenQuery.get({
|
||||
$token: hash,
|
||||
$now: Date.now(),
|
||||
});
|
||||
|
||||
if (!validToken) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized. An invalid session token was used.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await Bun.password.verify(token, validToken.token))) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized. An invalid session token was used.",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Elysia } from "elysia";
|
||||
import Cap from "@cap.js/server";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
|
||||
import { db } from "./db.js";
|
||||
import { ratelimitGenerator } from "./ratelimit.js";
|
||||
|
||||
const getSitekeyConfigQuery = db.query(
|
||||
`SELECT (config) FROM keys WHERE siteKey = ?`
|
||||
);
|
||||
const getSitekeyWithSecretQuery = db.query(
|
||||
`SELECT * FROM keys WHERE siteKey = ?`
|
||||
);
|
||||
|
||||
const insertChallengeQuery = db.query(`
|
||||
INSERT INTO challenges (siteKey, token, data, expires)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const getChallengeQuery = db.query(`
|
||||
SELECT * FROM challenges WHERE siteKey = ? AND token = ?
|
||||
`);
|
||||
const deleteChallengeQuery = db.query(`
|
||||
DELETE FROM challenges WHERE siteKey = ? AND token = ?
|
||||
`);
|
||||
|
||||
const insertTokenQuery = db.query(`
|
||||
INSERT INTO tokens (siteKey, token, expires)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
const getTokenQuery = db.query(`
|
||||
SELECT * FROM tokens WHERE siteKey = ? AND token = ?
|
||||
`);
|
||||
const deleteTokenQuery = db.query(`
|
||||
DELETE FROM tokens WHERE siteKey = ? AND token = ?
|
||||
`);
|
||||
|
||||
const upsertSolutionQuery = db.query(`
|
||||
INSERT INTO solutions (siteKey, bucket, count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT (siteKey, bucket)
|
||||
DO UPDATE SET count = count + 1
|
||||
`);
|
||||
|
||||
export const capServer = new Elysia({
|
||||
detail: {
|
||||
tags: ["Challenges"],
|
||||
},
|
||||
})
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
max: 45,
|
||||
duration: 5_000,
|
||||
generator: ratelimitGenerator,
|
||||
})
|
||||
)
|
||||
.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || true,
|
||||
methods: ["POST"],
|
||||
})
|
||||
)
|
||||
.post("/:siteKey/challenge", async ({ set, params }) => {
|
||||
const cap = new Cap({
|
||||
noFSState: true,
|
||||
});
|
||||
const _keyConfig = getSitekeyConfigQuery.get(params.siteKey);
|
||||
|
||||
if (!_keyConfig) {
|
||||
set.status = 404;
|
||||
return { error: "Invalid site key or secret" };
|
||||
}
|
||||
|
||||
const keyConfig = JSON.parse(_keyConfig.config);
|
||||
|
||||
const challenge = cap.createChallenge({
|
||||
challengeCount: keyConfig.challengeCount,
|
||||
challengeSize: keyConfig.saltSize,
|
||||
challengeDifficulty: keyConfig.difficulty,
|
||||
});
|
||||
|
||||
insertChallengeQuery.run(
|
||||
params.siteKey,
|
||||
challenge.token,
|
||||
challenge.challenge.map((c) => c.join(":")).join("\n"),
|
||||
challenge.expires
|
||||
);
|
||||
|
||||
return challenge;
|
||||
})
|
||||
.post("/:siteKey/redeem", async ({ body, set, params }) => {
|
||||
const challenge = getChallengeQuery.get(params.siteKey, body.token);
|
||||
|
||||
try {
|
||||
deleteChallengeQuery.run(params.siteKey, body.token);
|
||||
} catch {
|
||||
set.status = 404;
|
||||
return { error: "Challenge not found" };
|
||||
}
|
||||
|
||||
if (!challenge) {
|
||||
set.status = 404;
|
||||
return { error: "Challenge not found" };
|
||||
}
|
||||
|
||||
const cap = new Cap({
|
||||
noFSState: true,
|
||||
state: {
|
||||
challengesList: {
|
||||
[challenge.token]: {
|
||||
challenge: challenge.data.split("\n").map((c) => c.split(":")),
|
||||
expires: challenge.expires,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { success, token, expires } = await cap.redeemChallenge(body);
|
||||
|
||||
if (!success) {
|
||||
set.status = 403;
|
||||
return { error: "Invalid solution" };
|
||||
}
|
||||
|
||||
insertTokenQuery.run(params.siteKey, token, expires);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hourlyBucket = Math.floor(now / 3600) * 3600;
|
||||
upsertSolutionQuery.run(params.siteKey, hourlyBucket);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
expires,
|
||||
};
|
||||
})
|
||||
.post("/:siteKey/siteverify", async ({ body, set, params }) => {
|
||||
const sitekey = params.siteKey;
|
||||
const { secret, response } = body;
|
||||
|
||||
if (!sitekey || !secret || !response) {
|
||||
set.status = 400;
|
||||
return { error: "Missing required parameters" };
|
||||
}
|
||||
|
||||
const keyHash = getSitekeyWithSecretQuery.get(sitekey)?.secretHash;
|
||||
if (!keyHash) {
|
||||
set.status = 404;
|
||||
return { error: "Invalid site key or secret" };
|
||||
}
|
||||
|
||||
if (!(await Bun.password.verify(secret, keyHash))) {
|
||||
set.status = 403;
|
||||
return { error: "Invalid site key or secret" };
|
||||
}
|
||||
|
||||
const token = getTokenQuery.get(params.siteKey, response);
|
||||
|
||||
if (!token) {
|
||||
set.status = 404;
|
||||
return { error: "Token not found" };
|
||||
}
|
||||
|
||||
if (token.expires < Date.now()) {
|
||||
deleteTokenQuery.run(params.siteKey, response);
|
||||
set.status = 403;
|
||||
return { error: "Token expired" };
|
||||
}
|
||||
|
||||
deleteTokenQuery.run(params.siteKey, response);
|
||||
return { success: true };
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from "fs";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
fs.mkdirSync("./.data", {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const db = new Database("./.data/db.sqlite");
|
||||
|
||||
db.query(
|
||||
`create table if not exists sessions (
|
||||
token string primary key not null,
|
||||
expires integer not null,
|
||||
created integer not null
|
||||
)`
|
||||
).run();
|
||||
|
||||
db.query(
|
||||
`create table if not exists keys (
|
||||
siteKey string primary key not null,
|
||||
name string not null,
|
||||
secretHash string not null,
|
||||
config string not null,
|
||||
created integer not null
|
||||
)`
|
||||
).run();
|
||||
|
||||
db.query(
|
||||
`create table if not exists solutions (
|
||||
siteKey text not null,
|
||||
bucket integer not null,
|
||||
count integer default 0,
|
||||
primary key (siteKey, bucket)
|
||||
)`
|
||||
).run();
|
||||
|
||||
db.query(
|
||||
`create table if not exists challenges (
|
||||
siteKey string not null,
|
||||
token string not null,
|
||||
data string not null,
|
||||
expires integer not null,
|
||||
primary key (siteKey, token)
|
||||
)`
|
||||
).run();
|
||||
|
||||
db.query(
|
||||
`create table if not exists tokens (
|
||||
siteKey string not null,
|
||||
token string not null,
|
||||
expires integer not null,
|
||||
primary key (siteKey, token)
|
||||
)`
|
||||
).run();
|
||||
|
||||
db.query(
|
||||
`create table if not exists api_keys (
|
||||
id string not null,
|
||||
name string not null,
|
||||
tokenHash string not null,
|
||||
created integer not null,
|
||||
primary key (id, tokenHash)
|
||||
)`
|
||||
).run();
|
||||
|
||||
setInterval(() => {
|
||||
db.query("delete from sessions where expires < ?").run(Date.now());
|
||||
db.query("delete from tokens where expires < ?").run(Date.now());
|
||||
db.query("delete from challenges where expires < ?").run(Date.now());
|
||||
}, 60 * 1000);
|
||||
|
||||
db.query("delete from sessions where expires < ?").run(Date.now());
|
||||
db.query("delete from tokens where expires < ?").run(Date.now());
|
||||
db.query("delete from challenges where expires < ?").run(Date.now());
|
||||
|
||||
export { db };
|
||||
+59
-442
@@ -1,449 +1,66 @@
|
||||
import { Elysia, file, NotFoundError } from "elysia";
|
||||
import { Elysia, file } from "elysia";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
import Cap from "@cap.js/server";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const ADMIN_KEY = process.env.ADMIN_KEY?.trim();
|
||||
const dataDir = "./.data";
|
||||
const keysStorePath = path.join(dataDir, "keys.json");
|
||||
|
||||
function generateAdminKeyHint() {
|
||||
return `\nWe've generated this one for you to use: \n\n${crypto
|
||||
.randomBytes(30)
|
||||
.toString("hex")}\n\nThen, restart this process.\n\n`;
|
||||
}
|
||||
|
||||
if (!ADMIN_KEY) {
|
||||
console.error(
|
||||
`\nNo admin key has been set. Make sure to set it using the \n\`ADMIN_KEY\` environment variable.${generateAdminKeyHint()}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (ADMIN_KEY === "your_secret_key") {
|
||||
console.error(
|
||||
`\nDon't leave the admin key as default! Make sure to set it \nusing the \`ADMIN_KEY\` environment variable.${generateAdminKeyHint()}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (ADMIN_KEY.length < 20) {
|
||||
console.warn(
|
||||
`\n${"*".repeat(
|
||||
60
|
||||
)}\n\nThe admin key you're using is quite short. We recommend \nusing a longer one.${generateAdminKeyHint()}${"*".repeat(
|
||||
60
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
let keys = [];
|
||||
const capInstances = new Map();
|
||||
|
||||
const updateCache = async () => {
|
||||
let cacheConfig = {};
|
||||
|
||||
try {
|
||||
cacheConfig = JSON.parse(
|
||||
await fs.readFile(path.join(dataDir, "assets-cache.json"), "utf-8")
|
||||
);
|
||||
} catch {}
|
||||
|
||||
const lastUpdate = cacheConfig["lastUpdate"] || 0;
|
||||
const currentTime = Date.now();
|
||||
const updateInterval = 1000 * 60 * 60 * 24; // 1 day
|
||||
|
||||
if (!(currentTime - lastUpdate > updateInterval)) return;
|
||||
|
||||
const CACHE_HOST = process.env.CACHE_HOST || "https://cdn.jsdelivr.net";
|
||||
if (CACHE_HOST === "disable") return;
|
||||
|
||||
const WIDGET_VERSION = process.env.WIDGET_VERSION || "latest";
|
||||
const WASM_VERSION = process.env.WASM_VERSION || "latest";
|
||||
|
||||
try {
|
||||
const [widgetSource, floatingSource, wasmSource, wasmLoaderSource] =
|
||||
await Promise.all([
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}`).then((r) => r.text()),
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}/cap-floating.min.js`).then(
|
||||
(r) => r.text()
|
||||
),
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm_bg.wasm`).then(
|
||||
(r) => r.arrayBuffer()
|
||||
),
|
||||
fetch(`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm.min.js`).then(
|
||||
(r) => r.text()
|
||||
),
|
||||
]);
|
||||
|
||||
cacheConfig["lastUpdate"] = currentTime;
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cache.json"),
|
||||
JSON.stringify(cacheConfig)
|
||||
);
|
||||
|
||||
await fs.writeFile(path.join(dataDir, "assets-widget.js"), widgetSource);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-floating.js"),
|
||||
floatingSource
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cap_wasm_bg.wasm"),
|
||||
Buffer.from(wasmSource)
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dataDir, "assets-cap_wasm.js"),
|
||||
wasmLoaderSource
|
||||
);
|
||||
|
||||
console.log("[asset server] updated assets cache");
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[asset server] error updating assets cache, trying to load them might fail:",
|
||||
e
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
const data = await fs.readFile(keysStorePath, "utf-8");
|
||||
keys = JSON.parse(data);
|
||||
capInstances.clear();
|
||||
|
||||
keys.forEach((key) => {
|
||||
capInstances.set(
|
||||
key.publicKey,
|
||||
new Cap({
|
||||
tokens_store_path: path.join(dataDir, `tokens-${key.publicKey}.json`),
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
await fs.writeFile(keysStorePath, "[]");
|
||||
keys = [];
|
||||
capInstances.clear();
|
||||
}
|
||||
|
||||
await updateCache();
|
||||
};
|
||||
|
||||
const saveKeys = async () => {
|
||||
await fs.writeFile(keysStorePath, JSON.stringify(keys));
|
||||
};
|
||||
|
||||
const getCapInstance = (publicKey) => {
|
||||
if (!capInstances.has(publicKey) || !capInstances.get(publicKey)) {
|
||||
const keyData = keys.find((k) => k.publicKey === publicKey);
|
||||
if (keyData) {
|
||||
capInstances.set(
|
||||
publicKey,
|
||||
new Cap({
|
||||
tokens_store_path: path.join(dataDir, `tokens-${publicKey}.json`),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return capInstances.get(publicKey);
|
||||
};
|
||||
|
||||
const auth = new Elysia({
|
||||
prefix: "/internal/auth",
|
||||
})
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
count: 10,
|
||||
duration: 15000,
|
||||
})
|
||||
)
|
||||
.post("/", async ({ body, cookie, set }) => {
|
||||
try {
|
||||
if (
|
||||
!body?.password ||
|
||||
!timingSafeEqual(Buffer.from(body.password), Buffer.from(ADMIN_KEY))
|
||||
) {
|
||||
set.status = 401;
|
||||
return { success: false };
|
||||
}
|
||||
} catch {
|
||||
set.status = 500;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
cookie["cap-admin-key"].set({
|
||||
value: await Bun.password.hash(body.password),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 86400 * 7,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
})
|
||||
.get("/logout", ({ cookie, redirect }) => {
|
||||
cookie["cap-admin-key"].remove();
|
||||
return redirect("/");
|
||||
});
|
||||
|
||||
const internal = new Elysia({ prefix: "/internal" })
|
||||
.onBeforeHandle(async ({ cookie, set }) => {
|
||||
const authCookie = cookie["cap-admin-key"]?.value;
|
||||
if (!authCookie || !(await Bun.password.verify(ADMIN_KEY, authCookie))) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
};
|
||||
}
|
||||
})
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
number: 60,
|
||||
duration: 10000,
|
||||
})
|
||||
)
|
||||
.post(
|
||||
"/createKey",
|
||||
async ({
|
||||
body: {
|
||||
keyName,
|
||||
challengesCount = 18,
|
||||
challengeSize = 32,
|
||||
challengeDifficulty = 4,
|
||||
expiresMs = 600000,
|
||||
},
|
||||
set,
|
||||
}) => {
|
||||
if (!keyName?.trim()) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Key name is required" };
|
||||
}
|
||||
|
||||
const publicKey = crypto.randomBytes(6).toString("hex");
|
||||
const privateKey = crypto.randomBytes(25).toString("hex");
|
||||
const privateKeyHash = await Bun.password.hash(privateKey);
|
||||
|
||||
const newKey = {
|
||||
name: keyName,
|
||||
publicKey,
|
||||
privateKey: privateKeyHash,
|
||||
challengesCount: Number(challengesCount),
|
||||
challengeSize: Number(challengeSize),
|
||||
challengeDifficulty: Number(challengeDifficulty),
|
||||
expiresMs: Number(expiresMs),
|
||||
};
|
||||
|
||||
keys.push(newKey);
|
||||
await saveKeys();
|
||||
|
||||
getCapInstance(publicKey);
|
||||
|
||||
set.status = 201;
|
||||
return {
|
||||
success: true,
|
||||
publicKey,
|
||||
privateKey,
|
||||
};
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/editKey",
|
||||
async ({
|
||||
body: {
|
||||
publicKey,
|
||||
keyName,
|
||||
challengesCount,
|
||||
challengeSize,
|
||||
challengeDifficulty,
|
||||
expiresMs,
|
||||
},
|
||||
set,
|
||||
}) => {
|
||||
const keyIndex = keys.findIndex((key) => key.publicKey === publicKey);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
set.status = 404;
|
||||
return { success: false, message: "Key not found" };
|
||||
}
|
||||
|
||||
if (!keyName.trim()) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Key name is required" };
|
||||
}
|
||||
|
||||
const existingKey = keys[keyIndex];
|
||||
keys[keyIndex] = {
|
||||
...existingKey,
|
||||
name: keyName,
|
||||
challengesCount: Number(challengesCount),
|
||||
challengeSize: Number(challengeSize),
|
||||
challengeDifficulty: Number(challengeDifficulty),
|
||||
expiresMs: Number(expiresMs),
|
||||
};
|
||||
|
||||
await saveKeys();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
.get("/listKeys", async () => {
|
||||
return {
|
||||
keys: keys.map(({ privateKey, ...rest }) => rest),
|
||||
};
|
||||
})
|
||||
.post("/deleteKey", async ({ body: { publicKey }, set }) => {
|
||||
const initialLength = keys.length;
|
||||
keys = keys.filter((key) => key.publicKey !== publicKey);
|
||||
|
||||
if (keys.length === initialLength) {
|
||||
set.status = 404;
|
||||
return { success: false, message: "Key not found" };
|
||||
}
|
||||
|
||||
await saveKeys();
|
||||
|
||||
const tokenFilePath = path.join(dataDir, `tokens-${publicKey}.json`);
|
||||
try {
|
||||
await fs.unlink(tokenFilePath);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
console.warn(
|
||||
`Could not delete token file ${tokenFilePath}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
capInstances.delete(publicKey);
|
||||
|
||||
set.status = 200;
|
||||
return { success: true };
|
||||
})
|
||||
.post("/rotateKey", async ({ body: { publicKey }, set }) => {
|
||||
const keyIndex = keys.findIndex((key) => key.publicKey === publicKey);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
set.status = 404;
|
||||
return { success: false, message: "Key not found" };
|
||||
}
|
||||
|
||||
const newPrivateKey = crypto.randomBytes(25).toString("hex");
|
||||
const newPrivateKeyHash = await Bun.password.hash(newPrivateKey);
|
||||
|
||||
keys[keyIndex].privateKey = newPrivateKeyHash;
|
||||
|
||||
await saveKeys();
|
||||
|
||||
return { success: true, privateKey: newPrivateKey };
|
||||
});
|
||||
|
||||
const api = new Elysia({ prefix: "/:key" })
|
||||
.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || true,
|
||||
})
|
||||
)
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
number: 80,
|
||||
duration: 1000,
|
||||
})
|
||||
)
|
||||
.derive(({ params }) => {
|
||||
const keyData = keys.find((k) => k.publicKey === params.key);
|
||||
if (!keyData) {
|
||||
throw new NotFoundError("Key not found");
|
||||
}
|
||||
return { keyData, capInstance: getCapInstance(params.key) };
|
||||
})
|
||||
.post("/api/challenge", async ({ keyData, capInstance }) => {
|
||||
return await capInstance.createChallenge({
|
||||
challengeSize: keyData.challengeSize,
|
||||
challengeDifficulty: keyData.challengeDifficulty,
|
||||
challengeCount: keyData.challengesCount,
|
||||
expiresMs: keyData.expiresMs,
|
||||
});
|
||||
})
|
||||
.post("/api/redeem", async ({ body, set, capInstance }) => {
|
||||
const { token, solutions } = body;
|
||||
|
||||
if (!token || !solutions) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Missing solutions and/or token" };
|
||||
}
|
||||
|
||||
return await capInstance.redeemChallenge({ token, solutions });
|
||||
})
|
||||
.post("/siteverify", async ({ body, set, keyData, capInstance }) => {
|
||||
const { secret, response } = body;
|
||||
|
||||
if (!secret || !response) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Missing secret or/and response" };
|
||||
}
|
||||
|
||||
if (!(await Bun.password.verify(secret, keyData.privateKey))) {
|
||||
set.status = 400;
|
||||
return { success: false, message: "Invalid secret" };
|
||||
}
|
||||
|
||||
return await capInstance.validateToken(response, {
|
||||
keepToken: false,
|
||||
});
|
||||
});
|
||||
|
||||
const assetsServer = new Elysia({ prefix: "/assets" })
|
||||
.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || true,
|
||||
methods: ["GET"],
|
||||
})
|
||||
)
|
||||
.onBeforeHandle(({ set }) => {
|
||||
set.headers["Cache-Control"] = "max-age=31536000, immutable";
|
||||
})
|
||||
.get("/widget.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-widget.js"));
|
||||
})
|
||||
.get("/floating.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-floating.js"));
|
||||
})
|
||||
.get("/cap_wasm_bg.wasm", ({ set }) => {
|
||||
set.headers["Content-Type"] = "application/wasm";
|
||||
return file(path.join(dataDir, "assets-cap_wasm_bg.wasm"));
|
||||
})
|
||||
.get("/cap_wasm.js", ({ set }) => {
|
||||
set.headers["Content-Type"] = "text/javascript";
|
||||
return file(path.join(dataDir, "assets-cap_wasm.js"));
|
||||
});
|
||||
import { auth } from "./auth.js";
|
||||
import { server } from "./server.js";
|
||||
import { assetsServer } from "./assets.js";
|
||||
import { capServer } from "./cap.js";
|
||||
|
||||
new Elysia()
|
||||
.use(staticPlugin())
|
||||
.use(auth)
|
||||
.use(internal)
|
||||
.use(api)
|
||||
.use(assetsServer)
|
||||
.get("/", async ({ cookie }) => {
|
||||
const authCookie = cookie["cap-admin-key"]?.value;
|
||||
const isAuthed =
|
||||
authCookie && (await Bun.password.verify(ADMIN_KEY, authCookie));
|
||||
|
||||
return file(isAuthed ? "./public/index.html" : "./public/lock.html");
|
||||
.use(
|
||||
swagger({
|
||||
scalarConfig: {
|
||||
customCss: `.section-header-wrapper .section-header.tight { margin-top: 10px; }`,
|
||||
},
|
||||
exclude: ["/", "/auth/login"],
|
||||
documentation: {
|
||||
tags: [
|
||||
{
|
||||
name: "Keys",
|
||||
description:
|
||||
"Managing, creating and viewing keys. Requires API or session token",
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
description:
|
||||
"Managing sessions, API keys, and other settings. Requires API or session token",
|
||||
},
|
||||
{
|
||||
name: "Challenges",
|
||||
description: "Creating and managing challenges and tokens",
|
||||
},
|
||||
{
|
||||
name: "Assets",
|
||||
description: "Reading static assets from the assets server",
|
||||
},
|
||||
],
|
||||
info: {
|
||||
title: "Cap Standalone",
|
||||
version: "2.0.0",
|
||||
description:
|
||||
"API endpoints for Cap Standalone. Both Keys and Settings endpoints require an API key or session token.\n\n[Learn more](https://capjs.js.org)",
|
||||
},
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
.use(staticPlugin())
|
||||
.get("/", async ({ cookie }) => {
|
||||
return file(
|
||||
cookie.cap_authed?.value === "yes"
|
||||
? "./public/index.html"
|
||||
: "./public/login.html"
|
||||
);
|
||||
})
|
||||
.use(auth)
|
||||
.use(server)
|
||||
.use(assetsServer)
|
||||
.use(capServer)
|
||||
.listen(3000);
|
||||
|
||||
console.log(`Cap standalone running on http://localhost:3000`);
|
||||
init();
|
||||
console.log(`🧢 Cap running on http://localhost:3000`);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const ratelimitGenerator = (req, server) => {
|
||||
if (process.env.RATELIMIT_IP_HEADER) {
|
||||
const header = process.env.RATELIMIT_IP_HEADER;
|
||||
const ip = req.headers[header] || req.headers[header.toLowerCase()];
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
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`
|
||||
);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return ip ?? "";
|
||||
};
|
||||
@@ -0,0 +1,491 @@
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { Elysia, t } from "elysia";
|
||||
|
||||
import { ratelimitGenerator } from "./ratelimit.js";
|
||||
import { authBeforeHandle } from "./auth.js";
|
||||
import { db } from "./db.js";
|
||||
|
||||
const keyDefaults = {
|
||||
difficulty: 4,
|
||||
challengeCount: 50,
|
||||
saltSize: 32,
|
||||
};
|
||||
|
||||
const get24hSolvesQuery = db.query(
|
||||
`SELECT SUM(count) as total FROM solutions WHERE siteKey = ? AND bucket >= ?`
|
||||
);
|
||||
const get24hPreviousQuery = db.query(`
|
||||
SELECT SUM(count) as total FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ? AND bucket < ?
|
||||
`);
|
||||
const getKeysQuery = db.query(`SELECT * FROM keys ORDER BY created DESC`);
|
||||
|
||||
export const server = new Elysia({
|
||||
prefix: "/server",
|
||||
detail: {
|
||||
security: [
|
||||
{
|
||||
apiKey: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "scoped",
|
||||
max: 150,
|
||||
duration: 10_000,
|
||||
generator: ratelimitGenerator,
|
||||
})
|
||||
)
|
||||
.onBeforeHandle(authBeforeHandle)
|
||||
.get(
|
||||
"/keys",
|
||||
() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const day = 24 * 60 * 60;
|
||||
|
||||
const currentStart = now - day;
|
||||
const previousStart = now - 2 * day;
|
||||
|
||||
return getKeysQuery.all().map((key) => {
|
||||
const current =
|
||||
get24hSolvesQuery.get(key.siteKey, currentStart).total || 0;
|
||||
|
||||
const previous =
|
||||
get24hPreviousQuery.get(key.siteKey, previousStart, currentStart)
|
||||
.total || 0;
|
||||
|
||||
let change = 0;
|
||||
let direction = "";
|
||||
|
||||
if (previous > 0) {
|
||||
change = ((current - previous) / previous) * 100;
|
||||
direction =
|
||||
current > previous ? "up" : current < previous ? "down" : "";
|
||||
} else if (current > 0) {
|
||||
change = 100;
|
||||
direction = "up";
|
||||
}
|
||||
|
||||
return {
|
||||
siteKey: key.siteKey,
|
||||
name: key.name,
|
||||
created: key.created,
|
||||
solvesLast24h: current,
|
||||
difference: {
|
||||
value: change.toFixed(2),
|
||||
direction,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
tags: ["Keys"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/keys",
|
||||
async ({ body }) => {
|
||||
const siteKey = randomBytes(5).toString("hex");
|
||||
const secretKey = randomBytes(40)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "")
|
||||
.replace(/\//g, "")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
db.query(
|
||||
`INSERT INTO keys (siteKey, name, secretHash, config, created) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
siteKey,
|
||||
body?.name || siteKey,
|
||||
await Bun.password.hash(secretKey),
|
||||
JSON.stringify(keyDefaults),
|
||||
Date.now()
|
||||
);
|
||||
|
||||
return {
|
||||
siteKey,
|
||||
secretKey,
|
||||
};
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Keys"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/keys/:siteKey",
|
||||
({ params, query }) => {
|
||||
const key = db
|
||||
.query(`SELECT * FROM keys WHERE siteKey = ?`)
|
||||
.get(params.siteKey);
|
||||
if (!key) {
|
||||
return { success: false, error: "Key not found" };
|
||||
}
|
||||
|
||||
const chartDuration = query.chartDuration || "today";
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const day = 24 * 60 * 60;
|
||||
|
||||
const currentStart = now - day;
|
||||
let dataQuery;
|
||||
let bucketSize;
|
||||
let startTime;
|
||||
|
||||
switch (chartDuration) {
|
||||
case "today":
|
||||
bucketSize = 3600;
|
||||
startTime = Math.floor(Date.now() / 1000 / 86400) * 86400;
|
||||
dataQuery = db.query(`
|
||||
SELECT bucket, SUM(count) as count
|
||||
FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`);
|
||||
break;
|
||||
case "yesterday":
|
||||
bucketSize = 3600;
|
||||
startTime = Math.floor(Date.now() / 1000 / 86400) * 86400 - 86400;
|
||||
dataQuery = db.query(`
|
||||
SELECT bucket, SUM(count) as count
|
||||
FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ? AND bucket < ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`);
|
||||
break;
|
||||
case "last7days":
|
||||
bucketSize = 86400;
|
||||
startTime = now - 7 * 86400;
|
||||
dataQuery = db.query(`
|
||||
SELECT bucket, SUM(count) as count
|
||||
FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`);
|
||||
break;
|
||||
case "last28days":
|
||||
bucketSize = 86400;
|
||||
startTime = now - 28 * 86400;
|
||||
dataQuery = db.query(`
|
||||
SELECT bucket, SUM(count) as count
|
||||
FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`);
|
||||
break;
|
||||
case "last91days":
|
||||
bucketSize = 86400;
|
||||
startTime = now - 91 * 86400;
|
||||
dataQuery = db.query(`
|
||||
SELECT bucket, SUM(count) as count
|
||||
FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`);
|
||||
break;
|
||||
case "alltime":
|
||||
bucketSize = 86400;
|
||||
startTime = 0;
|
||||
dataQuery = db.query(`
|
||||
SELECT bucket, SUM(count) as count
|
||||
FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`);
|
||||
break;
|
||||
default:
|
||||
bucketSize = 3600;
|
||||
startTime = currentStart;
|
||||
dataQuery = db.query(`
|
||||
SELECT bucket, SUM(count) as count
|
||||
FROM solutions
|
||||
WHERE siteKey = ? AND bucket >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`);
|
||||
}
|
||||
|
||||
const historyData =
|
||||
chartDuration === "yesterday"
|
||||
? dataQuery.all(params.siteKey, startTime, startTime + 86400)
|
||||
: dataQuery.all(params.siteKey, startTime);
|
||||
|
||||
const currentSolves =
|
||||
get24hSolvesQuery.get(params.siteKey, currentStart).total || 0;
|
||||
|
||||
return {
|
||||
key: {
|
||||
siteKey: key.siteKey,
|
||||
name: key.name,
|
||||
created: key.created,
|
||||
config: JSON.parse(key.config),
|
||||
},
|
||||
stats: {
|
||||
solvesLast24h: currentSolves,
|
||||
},
|
||||
chartData: {
|
||||
duration: chartDuration,
|
||||
bucketSize,
|
||||
data: historyData,
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
siteKey: t.String(),
|
||||
}),
|
||||
query: t.Object({
|
||||
chartDuration: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("today"),
|
||||
t.Literal("yesterday"),
|
||||
t.Literal("last7days"),
|
||||
t.Literal("last28days"),
|
||||
t.Literal("last91days"),
|
||||
t.Literal("alltime"),
|
||||
])
|
||||
),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Keys"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.put(
|
||||
"/keys/:siteKey/config",
|
||||
async ({ params, body }) => {
|
||||
const key = db
|
||||
.query(`SELECT * FROM keys WHERE siteKey = ?`)
|
||||
.get(params.siteKey);
|
||||
if (!key) {
|
||||
return { success: false, error: "Key not found" };
|
||||
}
|
||||
|
||||
const { name, difficulty, challengeCount, saltSize } = body;
|
||||
const config = {
|
||||
...keyDefaults,
|
||||
name,
|
||||
difficulty,
|
||||
challengeCount,
|
||||
saltSize,
|
||||
};
|
||||
|
||||
db.query(`UPDATE keys SET name = ?, config = ? WHERE siteKey = ?`).run(
|
||||
config.name || key.name,
|
||||
JSON.stringify(config),
|
||||
params.siteKey
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
difficulty: t.Optional(t.Number()),
|
||||
challengeCount: t.Optional(t.Number()),
|
||||
saltSize: t.Optional(t.Number()),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Keys"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
"/keys/:siteKey",
|
||||
({ params, set }) => {
|
||||
const key = db
|
||||
.query(`SELECT * FROM keys WHERE siteKey = ?`)
|
||||
.get(params.siteKey);
|
||||
if (!key) {
|
||||
set.status = 404;
|
||||
return { success: false, error: "Key not found" };
|
||||
}
|
||||
|
||||
db.query(`DELETE FROM keys WHERE siteKey = ?`).run(params.siteKey);
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
siteKey: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Keys"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/keys/:siteKey/rotate-secret",
|
||||
async ({ params }) => {
|
||||
const key = db
|
||||
.query(`SELECT * FROM keys WHERE siteKey = ?`)
|
||||
.get(params.siteKey);
|
||||
if (!key) {
|
||||
return { success: false, error: "Key not found" };
|
||||
}
|
||||
const newSecretKey = randomBytes(40)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "")
|
||||
.replace(/\//g, "")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
db.query(`UPDATE keys SET secretHash = ? WHERE siteKey = ?`).run(
|
||||
await Bun.password.hash(newSecretKey),
|
||||
params.siteKey
|
||||
);
|
||||
return {
|
||||
secretKey: newSecretKey,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
siteKey: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Keys"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/settings/sessions",
|
||||
() => {
|
||||
const sessions = db.query(`SELECT * FROM sessions`).all();
|
||||
return sessions.map((session) => ({
|
||||
token: session.token.slice(-14),
|
||||
expires: new Date(session.expires).toISOString(),
|
||||
created: new Date(session.created).toISOString(),
|
||||
}));
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
tags: ["Settings"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/settings/apikeys",
|
||||
() => {
|
||||
const apikeys = db.query(`SELECT * FROM api_keys`).all();
|
||||
|
||||
return apikeys.map((key) => ({
|
||||
name: key.name,
|
||||
id: key.id,
|
||||
created: new Date(key.created).toISOString(),
|
||||
}));
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
tags: ["Settings"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/settings/apikeys",
|
||||
async ({ body }) => {
|
||||
const id = randomBytes(16).toString("hex");
|
||||
const token = randomBytes(32)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "")
|
||||
.replace(/\//g, "")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const name = body.name;
|
||||
|
||||
db.query(
|
||||
`INSERT INTO api_keys (id, name, tokenHash, created) VALUES (?, ?, ?, ?)`
|
||||
).run(id, name, await Bun.password.hash(token), Date.now());
|
||||
|
||||
return {
|
||||
apiKey: `${id}_${token}`,
|
||||
};
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Settings"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
"/settings/apikeys/:id",
|
||||
({ params, set }) => {
|
||||
const key = db
|
||||
.query(`SELECT * FROM api_keys WHERE id = ?`)
|
||||
.get(params.id);
|
||||
if (!key) {
|
||||
set.status = 404;
|
||||
return { success: false, error: "API key not found" };
|
||||
}
|
||||
db.query(`DELETE FROM api_keys WHERE id = ?`).run(params.id);
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Settings"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/logout",
|
||||
async ({ body, headers, set }) => {
|
||||
const { authorization } = headers;
|
||||
if (!authorization) {
|
||||
set.status = 401;
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const { hash } = JSON.parse(
|
||||
atob(authorization.replace("Bearer ", "").trim())
|
||||
);
|
||||
|
||||
let session = hash;
|
||||
|
||||
if (body.session) {
|
||||
// body.session are the last characters of the session token
|
||||
// e.g. body.session = (...)8KdbcHjqxWPR6Q
|
||||
|
||||
const sessionRow = db
|
||||
.query(`SELECT token FROM sessions WHERE token LIKE ?`)
|
||||
.get(`%${body.session}`);
|
||||
|
||||
if (!sessionRow) {
|
||||
set.status = 404;
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
session = sessionRow.token;
|
||||
}
|
||||
|
||||
db.query(`DELETE FROM sessions WHERE token = ?`).run(session);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
body: t.Optional(
|
||||
t.Object({
|
||||
session: t.Optional(t.String()),
|
||||
})
|
||||
),
|
||||
detail: {
|
||||
tags: ["Settings"],
|
||||
},
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user