Lumi/src/services/destructive-confirm.js
2026-06-12 11:54:46 +02:00

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
};