Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions standalone/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
ENTRYPOINT [ "bun", "run", "./src/index.js" ]
35 changes: 18 additions & 17 deletions standalone/src/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -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);
Expand Down Expand Up @@ -90,7 +91,7 @@ export const assetsServer = new Elysia({
})
.use(
cors({
origin: process.env.CORS_ORIGIN?.split(",") || true,
origin: config.corsOrigins,
methods: ["GET"],
}),
)
Expand Down
13 changes: 3 additions & 10 deletions standalone/src/auth.js
Original file line number Diff line number Diff line change
@@ -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",
})
Expand All @@ -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;
Expand All @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions standalone/src/cap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -21,7 +22,7 @@ export const capServer = new Elysia({
)
.use(
cors({
origin: process.env.CORS_ORIGIN?.split(",") || true,
origin: config.corsOrigins,
methods: ["POST"],
}),
)
Expand Down Expand Up @@ -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,
},
Expand Down
57 changes: 57 additions & 0 deletions standalone/src/config.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
11 changes: 2 additions & 9 deletions standalone/src/db.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
12 changes: 5 additions & 7 deletions standalone/src/index.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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}`);
8 changes: 5 additions & 3 deletions standalone/src/ratelimit.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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`,
);
Expand Down
9 changes: 5 additions & 4 deletions standalone/src/siteverify.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand Down