450 lines
12 KiB
JavaScript
450 lines
12 KiB
JavaScript
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();
|