80 lines
2.6 KiB
JavaScript
80 lines
2.6 KiB
JavaScript
const crypto = require("crypto");
|
|
|
|
const DELAY_MS = 3000;
|
|
const EXPIRES_MS = 30000;
|
|
const MAX_PENDING = 20;
|
|
const DESTRUCTIVE_PATH = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset)(?:\/|$)/i;
|
|
|
|
function isDestructivePath(value) {
|
|
return DESTRUCTIVE_PATH.test(normalizeAction(value));
|
|
}
|
|
|
|
function issueConfirmation(req, action) {
|
|
const normalizedAction = normalizeAction(action);
|
|
if (!req.session?.user?.id) throw new Error("Authentication is required.");
|
|
if (!isDestructivePath(normalizedAction)) throw new Error("This action is not registered as destructive.");
|
|
|
|
const now = Date.now();
|
|
prune(req.session, now);
|
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
const entries = req.session.destructive_confirmations || {};
|
|
entries[token] = {
|
|
action: normalizedAction,
|
|
user_id: req.session.user.id,
|
|
not_before: now + DELAY_MS,
|
|
expires_at: now + EXPIRES_MS
|
|
};
|
|
const keys = Object.keys(entries);
|
|
for (const key of keys.slice(0, Math.max(0, keys.length - MAX_PENDING))) delete entries[key];
|
|
req.session.destructive_confirmations = entries;
|
|
return {
|
|
token,
|
|
not_before: now + DELAY_MS,
|
|
expires_at: now + EXPIRES_MS,
|
|
delay_seconds: DELAY_MS / 1000
|
|
};
|
|
}
|
|
|
|
function consumeConfirmation(req, action, token = req.body?.confirmation_token || req.get?.("x-confirmation-token")) {
|
|
const normalizedAction = normalizeAction(action);
|
|
const entries = req.session?.destructive_confirmations || {};
|
|
const entry = token ? entries[token] : null;
|
|
if (!entry) return { valid: false, reason: "missing_or_invalid" };
|
|
delete entries[token];
|
|
req.session.destructive_confirmations = entries;
|
|
const now = Date.now();
|
|
if (entry.user_id !== req.session.user?.id || entry.action !== normalizedAction) {
|
|
return { valid: false, reason: "action_mismatch" };
|
|
}
|
|
if (now < entry.not_before) return { valid: false, reason: "too_early" };
|
|
if (now > entry.expires_at) return { valid: false, reason: "expired" };
|
|
return { valid: true };
|
|
}
|
|
|
|
function normalizeAction(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw.startsWith("/")) return "";
|
|
try {
|
|
return new URL(raw, "http://lumi.local").pathname;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function prune(session, now = Date.now()) {
|
|
const entries = session.destructive_confirmations || {};
|
|
for (const [token, entry] of Object.entries(entries)) {
|
|
if (!entry || entry.expires_at < now) delete entries[token];
|
|
}
|
|
session.destructive_confirmations = entries;
|
|
}
|
|
|
|
module.exports = {
|
|
DELAY_MS,
|
|
EXPIRES_MS,
|
|
isDestructivePath,
|
|
issueConfirmation,
|
|
consumeConfirmation,
|
|
normalizeAction
|
|
};
|