166 lines
3.8 KiB
JavaScript
166 lines
3.8 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import Cap from "@cap.js/server";
|
|
import Elysia from "elysia";
|
|
|
|
export const capMiddleware = (userOptions) => {
|
|
const options = {
|
|
token_validity_hours: 32,
|
|
tokens_store_path: ".data/middlewareTokens.json",
|
|
token_size: 16, // token size in bytes
|
|
verification_template_path: join(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
"./index.html",
|
|
),
|
|
scoping: "global", // 'global' | 'scoped'
|
|
|
|
...userOptions,
|
|
};
|
|
|
|
const cap = new Cap({
|
|
noFSState: true,
|
|
});
|
|
|
|
let tokensCache = null;
|
|
let cacheLastModified = 0;
|
|
|
|
fs.mkdir(dirname(options.tokens_store_path), { recursive: true });
|
|
|
|
async function loadCustomTokens() {
|
|
try {
|
|
const stats = await fs.stat(options.tokens_store_path);
|
|
const fileModified = stats.mtime.getTime();
|
|
|
|
if (tokensCache && fileModified <= cacheLastModified) {
|
|
return tokensCache;
|
|
}
|
|
|
|
const fileContent = await fs.readFile(options.tokens_store_path, "utf-8");
|
|
tokensCache = JSON.parse(fileContent);
|
|
cacheLastModified = Date.now();
|
|
return tokensCache;
|
|
} catch {
|
|
tokensCache = {};
|
|
cacheLastModified = Date.now();
|
|
return tokensCache;
|
|
}
|
|
}
|
|
|
|
async function saveCustomTokens(tokens) {
|
|
await fs.writeFile(options.tokens_store_path, JSON.stringify(tokens));
|
|
tokensCache = tokens;
|
|
cacheLastModified = Date.now();
|
|
}
|
|
|
|
async function storeCustomToken(token) {
|
|
const tokens = await loadCustomTokens();
|
|
|
|
tokens[token] = Date.now() + options.token_validity_hours * 60 * 60 * 1000;
|
|
await saveCustomTokens(tokens);
|
|
return token;
|
|
}
|
|
|
|
async function validateCustomToken(token) {
|
|
const tokens = await loadCustomTokens();
|
|
const tokenData = tokens[token];
|
|
|
|
if (!tokenData) return { success: false };
|
|
|
|
if (Date.now() > tokenData) {
|
|
delete tokens[token];
|
|
await saveCustomTokens(tokens);
|
|
return { success: false };
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
async function cleanupExpiredTokens() {
|
|
const tokens = await loadCustomTokens();
|
|
const now = Date.now();
|
|
let hasChanges = false;
|
|
|
|
for (const [token, data] of Object.entries(tokens)) {
|
|
if (now > data) {
|
|
delete tokens[token];
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
|
|
if (hasChanges) {
|
|
await saveCustomTokens(tokens);
|
|
}
|
|
}
|
|
|
|
const plugin = new Elysia({
|
|
name: "@cap.js/middleware-elysia",
|
|
seed: options,
|
|
});
|
|
|
|
plugin.onBeforeHandle({ as: options.scoping }, async ({ path, cookie }) => {
|
|
if (path === "/__cap_clearance/redeem") {
|
|
return;
|
|
}
|
|
|
|
const customToken = cookie["__cap_clearance"]?.value;
|
|
if (customToken) {
|
|
const validation = await validateCustomToken(customToken);
|
|
if (validation.success) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const challenge = cap.createChallenge();
|
|
|
|
return new Response(
|
|
(await fs.readFile(options.verification_template_path, "utf-8"))
|
|
.replace("window.CAP_CHALLENGE", JSON.stringify(challenge))
|
|
.replace("window.TOKEN_VALIDITY_HOURS", options.token_validity_hours)
|
|
.replace(
|
|
"{{TIME}}",
|
|
new Date()
|
|
.toISOString()
|
|
.replace("T", " ")
|
|
.replace(/\.\d+Z$/, "Z"),
|
|
),
|
|
{
|
|
headers: { "Content-Type": "text/html" },
|
|
},
|
|
);
|
|
});
|
|
|
|
plugin.post("/__cap_clearance/redeem", async ({ body, set }) => {
|
|
const { token, solutions } = body;
|
|
|
|
if (!token || !solutions) {
|
|
set.status = 400;
|
|
return { success: false };
|
|
}
|
|
|
|
const challengeResult = await cap.redeemChallenge({ token, solutions });
|
|
|
|
cap.validateToken(challengeResult.token);
|
|
|
|
if (challengeResult.success) {
|
|
const customToken = crypto
|
|
.randomBytes(options.token_size)
|
|
.toString("hex");
|
|
await storeCustomToken(customToken);
|
|
|
|
await cleanupExpiredTokens();
|
|
|
|
return {
|
|
success: true,
|
|
token: customToken,
|
|
expires: challengeResult.expires,
|
|
};
|
|
}
|
|
|
|
return challengeResult;
|
|
});
|
|
|
|
return plugin;
|
|
};
|