diff --git a/standalone/Dockerfile b/standalone/Dockerfile index 1065954..3800d71 100644 --- a/standalone/Dockerfile +++ b/standalone/Dockerfile @@ -21,10 +21,11 @@ COPY --from=prerelease /usr/src/app . WORKDIR /usr/src/app ENV DATA_PATH=/usr/src/app/.data -RUN mkdir -p ${DATA_PATH} && chown -R bun:bun ${DATA_PATH} +RUN mkdir -p "${DATA_PATH}" && chown -R bun:bun "${DATA_PATH}" +VOLUME [ "${DATA_PATH}" ] EXPOSE 3000/tcp USER bun -ENTRYPOINT [ "bun", "run", "./src/index.js" ] \ No newline at end of file +ENTRYPOINT [ "bun", "run", "./src/index.js" ] diff --git a/standalone/src/assets.js b/standalone/src/assets.js index d69c73a..dcdc513 100644 --- a/standalone/src/assets.js +++ b/standalone/src/assets.js @@ -3,20 +3,22 @@ import path from "node:path"; import { cors } from "@elysiajs/cors"; import { Elysia, file } from "elysia"; +import { config } from "./config.js"; + if ( - process.env.ENABLE_ASSETS_SERVER === "true" && - (process.env.WIDGET_VERSION === "latest" || - process.env.WASM_VERSION === "latest") + config.assetsServer.enabled && + (config.assetsServer.versions.widget === "latest" || + config.assetsServer.versions.wasm === "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 = process.env.DATA_PATH || "./.data"; +const dataDir = config.dataPath; const updateCache = async () => { - if (process.env.ENABLE_ASSETS_SERVER !== "true") return; + if (!config.assetsServer.enabled) return; const cacheConfigPath = path.join(dataDir, "assets-cache.json"); let cacheConfig = {}; @@ -30,37 +32,36 @@ const updateCache = async () => { const updateInterval = 1000 * 60 * 60 * 24; // 1 day const intervalExceeded = currentTime - lastUpdate > updateInterval; - const WIDGET_VERSION = process.env.WIDGET_VERSION || "latest"; - const WASM_VERSION = process.env.WASM_VERSION || "latest"; + const versions = config.assetsServer.versions; if (!cacheConfig.versions) cacheConfig.versions = {}; - const versionsChanged = cacheConfig.versions.widget !== WIDGET_VERSION - || cacheConfig.versions.wasm !== WASM_VERSION; + const versionsChanged = cacheConfig.versions.widget !== versions.widget + || cacheConfig.versions.wasm !== versions.wasm; if (!intervalExceeded && !versionsChanged) return; - const CACHE_HOST = process.env.CACHE_HOST || "https://cdn.jsdelivr.net"; + const cacheHost = config.assetsServer.cacheHost; try { const [widgetSource, floatingSource, wasmSource, wasmLoaderSource] = await Promise.all([ - fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}`).then((r) => + fetch(`${cacheHost}/npm/@cap.js/widget@${versions.widget}`).then((r) => r.text(), ), fetch( - `${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}/cap-floating.min.js`, + `${cacheHost}/npm/@cap.js/widget@${versions.widget}/cap-floating.min.js`, ).then((r) => r.text()), fetch( - `${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm_bg.wasm`, + `${cacheHost}/npm/@cap.js/wasm@${versions.wasm}/browser/cap_wasm_bg.wasm`, ).then((r) => r.arrayBuffer()), fetch( - `${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm.min.js`, + `${cacheHost}/npm/@cap.js/wasm@${versions.wasm}/browser/cap_wasm.min.js`, ).then((r) => r.text()), ]); cacheConfig.lastUpdate = currentTime; - cacheConfig.versions.widget = WIDGET_VERSION; - cacheConfig.versions.wasm = WASM_VERSION; + cacheConfig.versions.widget = versions.widget; + cacheConfig.versions.wasm = versions.wasm; await fs.writeFile(cacheConfigPath, JSON.stringify(cacheConfig)); await fs.writeFile(path.join(dataDir, "assets-widget.js"), widgetSource); @@ -90,7 +91,7 @@ export const assetsServer = new Elysia({ }) .use( cors({ - origin: process.env.CORS_ORIGIN?.split(",") || true, + origin: config.corsOrigins, methods: ["GET"], }), ) diff --git a/standalone/src/auth.js b/standalone/src/auth.js index a68e76f..8442aee 100644 --- a/standalone/src/auth.js +++ b/standalone/src/auth.js @@ -1,17 +1,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import { Elysia } from "elysia"; import { rateLimit } from "elysia-rate-limit"; +import { config } from "./config.js"; import { db } from "./db.js"; import { ratelimitGenerator } from "./ratelimit.js"; -const { ADMIN_KEY } = process.env; - -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", }) @@ -27,7 +20,7 @@ export const auth = new Elysia({ const { admin_key } = body; const a = Buffer.from(admin_key, "utf8"); - const b = Buffer.from(ADMIN_KEY, "utf8"); + const b = Buffer.from(config.adminKey, "utf8"); if (!a || !b || a.length !== b.length) { set.status = 401; @@ -39,7 +32,7 @@ export const auth = new Elysia({ return { success: false }; } - if (admin_key !== ADMIN_KEY) { + if (admin_key !== config.adminKey) { // as a last check, in case an attacker somehow bypasses // timingSafeEqual, we're checking AGAIN to see if the tokens // are right. diff --git a/standalone/src/cap.js b/standalone/src/cap.js index 886e622..2434da7 100644 --- a/standalone/src/cap.js +++ b/standalone/src/cap.js @@ -3,6 +3,7 @@ import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; import { rateLimit } from "elysia-rate-limit"; +import { config } from "./config.js"; import { db } from "./db.js"; import { ratelimitGenerator } from "./ratelimit.js"; @@ -21,7 +22,7 @@ export const capServer = new Elysia({ ) .use( cors({ - origin: process.env.CORS_ORIGIN?.split(",") || true, + origin: config.corsOrigins, methods: ["POST"], }), ) @@ -68,15 +69,16 @@ export const capServer = new Elysia({ return { error: "Challenge not found" }; } + const challengeDataParts = challenge.data.split(","); const cap = new Cap({ noFSState: true, state: { challengesList: { [challenge.token]: { challenge: { - c: challenge.data.split(",")[0], - s: challenge.data.split(",")[1], - d: challenge.data.split(",")[2], + c: challengeDataParts[0], + s: challengeDataParts[1], + d: challengeDataParts[2], }, expires: challenge.expires, }, diff --git a/standalone/src/config.js b/standalone/src/config.js new file mode 100644 index 0000000..18c21e7 --- /dev/null +++ b/standalone/src/config.js @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +async function readEnv(varName) { + const fileVarName = `${varName}_FILE`; + const filePath = process.env[fileVarName]; + if (filePath) return (await fs.readFile(filePath, "utf-8")).trim(); + return process.env[varName]; +} + +const dataPath = path.resolve(await readEnv("DATA_PATH") || ".data"); +await fs.mkdir(dataPath, {recursive: true}); + +const adminKey = await readEnv("ADMIN_KEY"); +if (!adminKey) throw new Error("auth: Admin key missing. Please add one"); +if (adminKey.length < 30) + throw new Error( + "auth: Admin key too short. Please use one that's at least 30 characters" + ); + +const dbUrl = await readEnv("DB_URL") || `sqlite://${path.join(dataPath, "db.sqlite")}`; + +const corsOrigins = (await readEnv("CORS_ORIGIN"))?.split(",") || true; + +const enableAssetsServer = await readEnv("ENABLE_ASSETS_SERVER") === "true"; +const widgetAssetVersion = await readEnv("WIDGET_VERSION") || "latest"; +const wasmAssetVersion = await readEnv("WASM_VERSION") || "latest"; +const cacheHost = await readEnv("CACHE_HOST") || "https://cdn.jsdelivr.net"; + +const serverPort = await readEnv("SERVER_PORT") || 3000; +const serverHostname = await readEnv("SERVER_HOSTNAME") || "0.0.0.0"; + +const rateLimitIpHeader = await readEnv("RATELIMIT_IP_HEADER"); +const rateLimitHideIpWarning = await readEnv("HIDE_RATELIMIT_IP_WARNING") === "true"; + +export const config = { + dataPath, + dbUrl, + adminKey, + corsOrigins, + assetsServer: { + enabled: enableAssetsServer, + cacheHost, + versions: { + widget: widgetAssetVersion, + wasm: wasmAssetVersion, + }, + }, + server: { + hostname: serverHostname, + port: serverPort, + }, + rateLimiting: { + ipHeader: rateLimitIpHeader, + hideWarning: rateLimitHideIpWarning, + }, +}; diff --git a/standalone/src/db.js b/standalone/src/db.js index 2253216..2719581 100644 --- a/standalone/src/db.js +++ b/standalone/src/db.js @@ -1,17 +1,10 @@ -import fs from "node:fs"; -import { join } from "node:path"; +import { config } from "./config.js"; import { SQL } from "bun"; -fs.mkdirSync(process.env.DATA_PATH || "./.data", { - recursive: true, -}); - let db; async function initDb() { - const dbUrl = process.env.DB_URL || `sqlite://${join(process.env.DATA_PATH || "./.data", "db.sqlite")}`; - - db = new SQL(dbUrl); + db = new SQL(config.dbUrl); await db`create table if not exists sessions ( token text primary key not null, diff --git a/standalone/src/index.js b/standalone/src/index.js index a886e28..0684473 100644 --- a/standalone/src/index.js +++ b/standalone/src/index.js @@ -1,19 +1,17 @@ import { staticPlugin } from "@elysiajs/static"; import { swagger } from "@elysiajs/swagger"; import { Elysia, file } from "elysia"; +import { config } from "./config.js"; import { assetsServer } from "./assets.js"; import { auth } from "./auth.js"; import { capServer } from "./cap.js"; import { server } from "./server.js"; import { siteverifyServer } from "./siteverify.js"; -const serverPort = process.env.SERVER_PORT || 3000; -const serverHostname = process.env.SERVER_HOSTNAME || "0.0.0.0"; - new Elysia({ serve: { - port: serverPort, - hostname: serverHostname, + port: config.server.port, + hostname: config.server.hostname, }, }) .use( @@ -73,6 +71,6 @@ new Elysia({ .use(assetsServer) .use(capServer) .use(siteverifyServer) - .listen(serverPort); + .listen(config.server.port); -console.log(`🧢 Cap running on http://${serverHostname}:${serverPort}`); +console.log(`🧢 Cap running on http://${config.server.hostname}:${config.server.port}`); diff --git a/standalone/src/ratelimit.js b/standalone/src/ratelimit.js index 1743b86..20b9076 100644 --- a/standalone/src/ratelimit.js +++ b/standalone/src/ratelimit.js @@ -1,6 +1,8 @@ +import { config } from "./config.js"; + export const ratelimitGenerator = (req, server) => { - if (process.env.RATELIMIT_IP_HEADER) { - const header = process.env.RATELIMIT_IP_HEADER; + const header = config.rateLimiting.ipHeader; + if (header) { const ip = req.headers.get(header) || req.headers.get(header.toLowerCase()); if (ip) { @@ -16,7 +18,7 @@ export const ratelimitGenerator = (req, server) => { const ip = server?.requestIP(req)?.address; if (!server || !req || !ip) { - if (process.env.HIDE_RATELIMIT_IP_WARNING !== "true") { + if (!config.rateLimiting.hideWarning) { 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`, ); diff --git a/standalone/src/siteverify.js b/standalone/src/siteverify.js index f510c37..c9a94a3 100644 --- a/standalone/src/siteverify.js +++ b/standalone/src/siteverify.js @@ -1,6 +1,7 @@ import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; +import { config } from "./config.js"; import { db } from "./db.js"; import { ratelimitGenerator } from "./ratelimit.js"; @@ -22,14 +23,14 @@ export const siteverifyServer = new Elysia({ }) .use( cors({ - origin: process.env.CORS_ORIGIN?.split(",") || true, + origin: config.corsOrigins, methods: ["POST"], }), ) .post("/:siteKey/siteverify", async ({ body, set, params, request, server }) => { const ip = ratelimitGenerator(request, server); const now = Date.now(); - + const unblockTime = blockedIPs.get(ip); if (unblockTime && now < unblockTime) { const retryAfter = Math.ceil((unblockTime - now) / 1000); @@ -38,7 +39,7 @@ export const siteverifyServer = new Elysia({ set.headers["X-RateLimit-Limit"] = "1"; set.headers["X-RateLimit-Remaining"] = "0"; set.headers["X-RateLimit-Reset"] = Math.ceil(unblockTime / 1000).toString(); - return { error: "You were temporarily for using an invalid secret key. Please try again later." }; + return { error: "You were temporarily blocked for using an invalid secret key. Please try again later." }; } const sitekey = params.siteKey; @@ -57,7 +58,7 @@ export const siteverifyServer = new Elysia({ } const isValidSecret = await Bun.password.verify(secret, keyHash); - + if (!isValidSecret) { blockedIPs.set(ip, now + 250); set.status = 403;