diff --git a/.gitignore b/.gitignore index 5459546..5510722 100644 --- a/.gitignore +++ b/.gitignore @@ -93,7 +93,4 @@ node_modules debug/ target/ **/*.rs.bk -*.pdb - - -standalone-v2-beta/** +*.pdb \ No newline at end of file diff --git a/assets/standalone_v1/Dockerfile b/assets/standalone_v1/Dockerfile new file mode 100644 index 0000000..f72e975 --- /dev/null +++ b/assets/standalone_v1/Dockerfile @@ -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" ] \ No newline at end of file diff --git a/assets/standalone_v1/bun.lock b/assets/standalone_v1/bun.lock new file mode 100644 index 0000000..a08968c --- /dev/null +++ b/assets/standalone_v1/bun.lock @@ -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=="], + } +} diff --git a/assets/standalone_v1/package.json b/assets/standalone_v1/package.json new file mode 100644 index 0000000..2588bb1 --- /dev/null +++ b/assets/standalone_v1/package.json @@ -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" +} diff --git a/assets/standalone_v1/public/index.html b/assets/standalone_v1/public/index.html new file mode 100644 index 0000000..223cd4f --- /dev/null +++ b/assets/standalone_v1/public/index.html @@ -0,0 +1,326 @@ + + + + + + Cap + + + + + + +
+

Your keys

+ +
+ +
+

Loading keys...

+
+ + + diff --git a/standalone/public/index.js b/assets/standalone_v1/public/index.js similarity index 100% rename from standalone/public/index.js rename to assets/standalone_v1/public/index.js diff --git a/standalone/public/lock.html b/assets/standalone_v1/public/lock.html similarity index 100% rename from standalone/public/lock.html rename to assets/standalone_v1/public/lock.html diff --git a/standalone/public/logo.png b/assets/standalone_v1/public/logo.png similarity index 100% rename from standalone/public/logo.png rename to assets/standalone_v1/public/logo.png diff --git a/standalone/public/test.html b/assets/standalone_v1/public/test.html similarity index 100% rename from standalone/public/test.html rename to assets/standalone_v1/public/test.html diff --git a/assets/standalone_v1/src/index.js b/assets/standalone_v1/src/index.js new file mode 100644 index 0000000..67f43b8 --- /dev/null +++ b/assets/standalone_v1/src/index.js @@ -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(); diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 2b200e5..79ad0fc 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -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, diff --git a/docs/guide/index.md b/docs/guide/index.md index 8071d4e..d7feb9a 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -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 diff --git a/docs/guide/middleware/demo.png b/docs/guide/middleware/demo.png deleted file mode 100644 index 531a40b..0000000 Binary files a/docs/guide/middleware/demo.png and /dev/null differ diff --git a/docs/guide/middleware/index.md b/docs/guide/middleware/index.md index fabb8e2..b1b592d 100644 --- a/docs/guide/middleware/index.md +++ b/docs/guide/middleware/index.md @@ -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. -![Example of Cap's checkpoint](./demo.png) - -## 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. \ No newline at end of file +![Screenshot of Cap's checkpoint flow](/checkpoints_screenshot.png) diff --git a/docs/guide/standalone.md b/docs/guide/standalone.md index 9510c5c..8bb5689 100644 --- a/docs/guide/standalone.md +++ b/docs/guide/standalone.md @@ -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. diff --git a/docs/guide/standalone/api.md b/docs/guide/standalone/api.md new file mode 100644 index 0000000..1342488 --- /dev/null +++ b/docs/guide/standalone/api.md @@ -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` diff --git a/docs/guide/standalone/index.md b/docs/guide/standalone/index.md new file mode 100644 index 0000000..5ac7ffb --- /dev/null +++ b/docs/guide/standalone/index.md @@ -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). + +![Screenshot of Cap's standalone mode](/standalone_screenshot.png) diff --git a/docs/guide/standalone/installation.md b/docs/guide/standalone/installation.md new file mode 100644 index 0000000..19e4d30 --- /dev/null +++ b/docs/guide/standalone/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. \ No newline at end of file diff --git a/docs/guide/standalone/options.md b/docs/guide/standalone/options.md new file mode 100644 index 0000000..be9baaa --- /dev/null +++ b/docs/guide/standalone/options.md @@ -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 + +``` + +For the floating mode, use: + +```html + +``` + +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:///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. diff --git a/docs/guide/standalone/usage.md b/docs/guide/standalone/usage.md new file mode 100644 index 0000000..b00f6d0 --- /dev/null +++ b/docs/guide/standalone/usage.md @@ -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://// +``` + +Make sure to replace: + +- ``: The actual URL where your Cap Standalone instance is running. This URL must be publicly accessible from the internet. +- ``: Your site key from this dashboard. + +Example: + +```html + +``` + +### 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:////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:////siteverify" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ "secret": "", "response": "" }' +``` + +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). diff --git a/docs/package.json b/docs/package.json index 8037aee..4a00ade 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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" }, diff --git a/docs/public/checkpoints_screenshot.png b/docs/public/checkpoints_screenshot.png new file mode 100644 index 0000000..10e080c Binary files /dev/null and b/docs/public/checkpoints_screenshot.png differ diff --git a/docs/public/standalone_screenshot.png b/docs/public/standalone_screenshot.png new file mode 100644 index 0000000..16b4b1e Binary files /dev/null and b/docs/public/standalone_screenshot.png differ diff --git a/standalone/.env.example b/standalone/.env.example new file mode 100644 index 0000000..cb24536 --- /dev/null +++ b/standalone/.env.example @@ -0,0 +1 @@ +ADMIN_KEY= \ No newline at end of file diff --git a/standalone/bun.lock b/standalone/bun.lock index a08968c..c9f44d0 100644 --- a/standalone/bun.lock +++ b/standalone/bun.lock @@ -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=="], } } diff --git a/standalone/package.json b/standalone/package.json index 2588bb1..d299971 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -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" }, diff --git a/standalone/public/index.css b/standalone/public/index.css new file mode 100644 index 0000000..6ae8aab --- /dev/null +++ b/standalone/public/index.css @@ -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; +} diff --git a/standalone/public/index.html b/standalone/public/index.html index 223cd4f..c28dd5d 100644 --- a/standalone/public/index.html +++ b/standalone/public/index.html @@ -3,324 +3,195 @@ - Cap - - + + Cap Standalone - -
-

Your keys

- + + + +
+ + + + +
-
-

Loading keys...

+ + + + + - + + diff --git a/standalone/public/js/api.js b/standalone/public/js/api.js new file mode 100644 index 0000000..d13fa11 --- /dev/null +++ b/standalone/public/js/api.js @@ -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, + }; + } +}; diff --git a/standalone/public/js/createApiKey.js b/standalone/public/js/createApiKey.js new file mode 100644 index 0000000..9c19f30 --- /dev/null +++ b/standalone/public/js/createApiKey.js @@ -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", + `
+ + +
+ + ` + ); + 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", + `
+ + + +

Make sure to copy your API key as it will not be shown again later.

+
+ + ` + ); + + successModal + .querySelector(".close-small-button") + .addEventListener("click", () => { + document.body.removeChild(successModal); + }); + + openSettings(); + } catch (error) { + createModal( + "Error", + `

Failed to create API key: ${error.message}

` + ); + modal.querySelector(".create-key-button").disabled = false; + } + }); +} + +document + .querySelector(".create-api-key") + .addEventListener("click", openCreateApiKey); + +export default openCreateApiKey; diff --git a/standalone/public/js/createKey.js b/standalone/public/js/createKey.js new file mode 100644 index 0000000..63e50bb --- /dev/null +++ b/standalone/public/js/createKey.js @@ -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", + `
+ + +
+ + ` + ); + 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", + `
+ + + + + + +

Make sure to copy your secret key as it will not be shown again later.

+
+ + + ` + ); + + 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", + `

Failed to create key: ${error.message}

` + ); + modal.querySelector(".create-key-button").disabled = false; + } + }); +} + +document.querySelector(".create-key").addEventListener("click", () => { + openCreateKey(document.querySelector(".search-input").value?.trim()); +}); + +export default openCreateKey; diff --git a/standalone/public/js/homepage.js b/standalone/public/js/homepage.js new file mode 100644 index 0000000..43675f5 --- /dev/null +++ b/standalone/public/js/homepage.js @@ -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 = `

An error occurred while fetching your keys. Please try again later.

`; + 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 = `

You don't have any keys

`; + return; + } + + keys.forEach((key) => { + const keyEl = document.createElement("div"); + keyEl.classList.add("key"); + + let differenceItem = "〰"; + if (key.difference.direction === "up") { + differenceItem = ` + `; + } else if (key.difference.direction === "down") { + differenceItem = ``; + } + + keyEl.innerHTML = ` +
+

${key.name}

+

${key.solvesLast24h} solves in last 24h • ${differenceItem} ${key.difference.value}%

+
+
+ +
+ `; + 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; diff --git a/standalone/public/js/index.js b/standalone/public/js/index.js new file mode 100644 index 0000000..6fe76c2 --- /dev/null +++ b/standalone/public/js/index.js @@ -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?", + ` + ` + ); + + 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=/;"; +} diff --git a/standalone/public/js/keyPage.js b/standalone/public/js/keyPage.js new file mode 100644 index 0000000..383a705 --- /dev/null +++ b/standalone/public/js/keyPage.js @@ -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", + `

Failed to load key data: ${error.message}

` + ); + 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", + `

Please fill in all fields correctly.

` + ); + + 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", + `

Failed to save key.

` + ); + } + + configElement.querySelector("button").disabled = false; + }; + + document + .querySelector(".danger-zone-buttons .delete-key") + .addEventListener("click", async () => { + const modal = createModal( + "Delete key?", + `

Are you sure you want to delete this key? This action cannot be undone.

+ + ` + ); + + 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", + `

Failed to delete key

` + ); + } + }); + + 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?", + `

Are you sure you want to rotate this key's secret? This won't delete the key itself.

+ + ` + ); + + 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", + `

Failed to rotate key secret.

` + ); + return; + } + const successModal = createModal( + "Secret key rotated", + `
+ + + +

Make sure to copy your secret key as it will not be shown again later.

+
+ + ` + ); + + 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 = ` Copy site key`; + + copyButton.onclick = () => { + navigator.clipboard.writeText(key.siteKey); + const originalText = copyButton.innerHTML; + copyButton.innerHTML = ` 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); + }; +} diff --git a/standalone/public/js/settings.js b/standalone/public/js/settings.js new file mode 100644 index 0000000..9b2e8c1 --- /dev/null +++ b/standalone/public/js/settings.js @@ -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 = ` +
+ ${session.token}... + ${ + JSON.parse(localStorage.getItem("cap_auth")).hash.endsWith( + session.token + ) + ? "current • " + : "" + }expires ${formatDate(session.expires)}${ + JSON.parse(localStorage.getItem("cap_auth")).hash.endsWith( + session.token + ) + ? "" + : " • created " + formatDate(session.created) + } +
+ + `; + + sessionsList.appendChild(sessionEl); + + sessionEl + .querySelector(".session-delete-button") + .addEventListener("click", async () => { + const modal = createModal( + "Logout session?", + ` + ` + ); + + 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 = `

You don't have any API keys

`; + return; + } + + apikeys.forEach((key) => { + const keyEl = document.createElement("div"); + keyEl.classList.add("apikey"); + + keyEl.innerHTML = ` +
+ ${key.name + .replaceAll("<", "<") + .replaceAll(">", ">")} + ${key.id.slice( + 0, + 12 + )}... • created ${formatDate(key.created)} +
+ + `; + + apikeysList.appendChild(keyEl); + + keyEl + .querySelector(".apikey-delete-button") + .addEventListener("click", async () => { + const modal = createModal( + "Delete API key?", + ` + ` + ); + + 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); + }); + }); + }); + })(); +}; diff --git a/standalone/public/js/state.js b/standalone/public/js/state.js new file mode 100644 index 0000000..3a5eebc --- /dev/null +++ b/standalone/public/js/state.js @@ -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"; + } + } +} diff --git a/standalone/public/js/utils.js b/standalone/public/js/utils.js new file mode 100644 index 0000000..dbeea0a --- /dev/null +++ b/standalone/public/js/utils.js @@ -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 = ` + + `; + + 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; +}; diff --git a/standalone/public/login.html b/standalone/public/login.html new file mode 100644 index 0000000..a1ba088 --- /dev/null +++ b/standalone/public/login.html @@ -0,0 +1,287 @@ + + + + + + Login to Cap + + + + +
+ + +
+ + +
+ + +

+

+ + Need help? + + Powered by Cap + +

+
+ + + diff --git a/standalone/public/logo-small.webp b/standalone/public/logo-small.webp new file mode 100644 index 0000000..7b01dbf Binary files /dev/null and b/standalone/public/logo-small.webp differ diff --git a/standalone/public/tester.html b/standalone/public/tester.html new file mode 100644 index 0000000..97d6f54 --- /dev/null +++ b/standalone/public/tester.html @@ -0,0 +1,133 @@ + + + + + + Challenge Tester + + + +
+ + + + + + + + +
No logs
+
+ + + + + diff --git a/standalone/src/assets.js b/standalone/src/assets.js new file mode 100644 index 0000000..e6009b2 --- /dev/null +++ b/standalone/src/assets.js @@ -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")); + }); diff --git a/standalone/src/auth.js b/standalone/src/auth.js new file mode 100644 index 0000000..4e81f80 --- /dev/null +++ b/standalone/src/auth.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.", + }; + } +}; diff --git a/standalone/src/cap.js b/standalone/src/cap.js new file mode 100644 index 0000000..0a5fab3 --- /dev/null +++ b/standalone/src/cap.js @@ -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 }; + }); diff --git a/standalone/src/db.js b/standalone/src/db.js new file mode 100644 index 0000000..c1f7a65 --- /dev/null +++ b/standalone/src/db.js @@ -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 }; diff --git a/standalone/src/index.js b/standalone/src/index.js index 67f43b8..2ffa28c 100644 --- a/standalone/src/index.js +++ b/standalone/src/index.js @@ -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( + 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()) - .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"); + 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`); diff --git a/standalone/src/ratelimit.js b/standalone/src/ratelimit.js new file mode 100644 index 0000000..2d137fb --- /dev/null +++ b/standalone/src/ratelimit.js @@ -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 ?? ""; +}; diff --git a/standalone/src/server.js b/standalone/src/server.js new file mode 100644 index 0000000..3aedbae --- /dev/null +++ b/standalone/src/server.js @@ -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"], + }, + } + ); \ No newline at end of file