Files
pocketbase/ui/src/settings/application/rateLimitAccordion.js
2026-04-18 22:11:58 +03:00

416 lines
18 KiB
JavaScript

import { basePredefinedTags, openRateLimitInfoModal } from "./rateLimitInfoModal";
// sort the specified rules list in place
export function sortRules(rules) {
if (!rules) {
return;
}
let compare = [{}, {}];
rules.sort((a, b) => {
compare[0].length = a.label.length;
compare[0].isTag = a.label.includes(":") || !a.label.includes("/");
compare[0].isWildcardTag = compare[0].isTag && a.label.startsWith("*");
compare[0].isExactTag = compare[0].isTag && !compare[0].isWildcardTag;
compare[0].isPrefix = !compare[0].isTag && a.label.endsWith("/");
compare[0].hasMethod = !compare[0].isTag && a.label.includes(" /");
compare[1].length = b.label.length;
compare[1].isTag = b.label.includes(":") || !b.label.includes("/");
compare[1].isWildcardTag = compare[1].isTag && b.label.startsWith("*");
compare[1].isExactTag = compare[1].isTag && !compare[1].isWildcardTag;
compare[1].isPrefix = !compare[1].isTag && b.label.endsWith("/");
compare[1].hasMethod = !compare[1].isTag && b.label.includes(" /");
for (let item of compare) {
item.priority = 0; // reset
if (item.isTag) {
item.priority += 1000;
if (item.isExactTag) {
item.priority += 10;
} else {
item.priority += 5;
}
} else {
if (item.hasMethod) {
item.priority += 10;
}
if (!item.isPrefix) {
item.priority += 5;
}
}
}
// sort additionally prefix paths based on their length
if (
compare[0].isPrefix
&& compare[1].isPrefix
&& ((compare[0].hasMethod && compare[1].hasMethod) || (!compare[0].hasMethod && !compare[1].hasMethod))
) {
if (compare[0].length > compare[1].length) {
compare[0].priority += 1;
} else if (compare[0].length < compare[1].length) {
compare[1].priority += 1;
}
}
if (compare[0].priority > compare[1].priority) {
return -1;
}
if (compare[0].priority < compare[1].priority) {
return 1;
}
return 0;
});
return rules;
}
export function rateLimitAccordion(pageData) {
const audienceOptions = [
{ value: "", label: "All" },
{ value: "@guest", label: "Guest only" },
{ value: "@auth", label: "Auth only" },
];
const accordionData = store({
predefinedTags: basePredefinedTags,
});
loadPredefinedTags();
async function loadPredefinedTags() {
let collections = [];
// fetch an up-to-date collections list
try {
collections = await app.pb.collections.getFullList();
} catch (err) {
console.warn("loadPredefinedTags: failed to load collections", err);
return;
}
accordionData.predefinedTags = [];
for (const collection of collections) {
if (collection.system) {
continue;
}
accordionData.predefinedTags.push({ value: collection.name + ":list" });
accordionData.predefinedTags.push({ value: collection.name + ":view" });
if (collection.type != "view") {
accordionData.predefinedTags.push({ value: collection.name + ":create" });
accordionData.predefinedTags.push({ value: collection.name + ":update" });
accordionData.predefinedTags.push({ value: collection.name + ":delete" });
}
if (collection.type == "auth") {
accordionData.predefinedTags.push({
value: collection.name + ":listAuthMethods",
});
accordionData.predefinedTags.push({
value: collection.name + ":authRefresh",
});
accordionData.predefinedTags.push({ value: collection.name + ":auth" });
accordionData.predefinedTags.push({
value: collection.name + ":authWithPassword",
});
accordionData.predefinedTags.push({
value: collection.name + ":authWithOAuth2",
});
accordionData.predefinedTags.push({
value: collection.name + ":authWithOTP",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestOTP",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestPasswordReset",
});
accordionData.predefinedTags.push({
value: collection.name + ":confirmPasswordReset",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestVerification",
});
accordionData.predefinedTags.push({
value: collection.name + ":confirmVerification",
});
accordionData.predefinedTags.push({
value: collection.name + ":requestEmailChange",
});
accordionData.predefinedTags.push({
value: collection.name + ":confirmEmailChange",
});
}
if (collection.fields.find((f) => f.type == "file")) {
accordionData.predefinedTags.push({ value: collection.name + ":file" });
}
}
accordionData.predefinedTags = accordionData.predefinedTags.concat(basePredefinedTags);
}
function newRule() {
if (!Array.isArray(pageData.formSettings.rateLimits.rules)) {
pageData.formSettings.rateLimits.rules = [];
}
pageData.formSettings.rateLimits.rules.push({
label: "",
maxRequests: 200,
duration: 3,
audience: "",
});
// enable the rate limiter if this is the first rule that is being added
if (pageData.formSettings.rateLimits.rules.length == 1) {
pageData.formSettings.rateLimits.enabled = true;
}
}
function removeRule(i) {
pageData.formSettings.rateLimits.rules.splice(i, 1);
if (!pageData.formSettings.rateLimits.rules.length) {
pageData.formSettings.rateLimits.enabled = false;
}
}
const watchers = [];
return t.details(
{
pbEvent: "rateLimitAccordion",
className: "accordion rate-limit-accordion",
name: "settingsAccordion",
onmount: () => {
watchers.push(
// clear rules errors on any rule change since an error could be
// for a duplicated tag that may have been updated in a different rule
watch(
() => JSON.stringify(pageData.formSettings.rateLimits.rules),
() => {
if (!app.store.errors?.rateLimits?.rules) {
return;
}
delete app.store.errors.rateLimits;
},
),
);
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.summary(
null,
t.i({ className: "ri-pulse-fill", ariaHidden: true }),
t.span({ className: "txt" }, "Rate limiting"),
t.div({ className: "flex-fill" }),
() => {
if (pageData.formSettings.rateLimits.enabled) {
return t.span({ className: "label success" }, "Enabled");
}
return t.span({ className: "label" }, "Disabled");
},
() => {
if (!app.utils.isEmpty(app.store.errors?.rateLimits)) {
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
}
},
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.input({
id: "rateLimits.enabled",
name: "rateLimits.enabled",
type: "checkbox",
className: "switch",
checked: () => pageData.formSettings.rateLimits.enabled || false,
onchange: (e) => (pageData.formSettings.rateLimits.enabled = e.target.checked),
}),
t.label(
{ htmlFor: "rateLimits.enabled" },
t.span({ className: "txt" }, "Enable"),
t.small({ className: "txt-hint" }, " (experimental)"),
),
),
),
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "rate-limit-table-wrapper" },
t.table(
{ className: "rate-limit-table" },
t.thead(
{
hidden: () => !pageData.formSettings.rateLimits.rules?.length,
},
t.tr(
null,
t.th({ className: "col-label" }, "Rate limit label"),
t.th(
{ className: "col-requests" },
"Max requests",
t.br(),
t.small(null, "(per IP)"),
),
t.th(
{ className: "col-duration" },
"Interval",
t.br(),
t.small(null, "(in seconds)"),
),
t.th({ className: "col-audience" }, "Targeted users"),
t.th({ className: "col-action" }),
),
),
t.tbody(null, () => {
const rows = [];
const rules = pageData.formSettings.rateLimits.rules || [];
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
rows.push(
t.tr(
{ className: "rate-limit-row" },
t.td(
{ className: "col-label" },
t.div(
{ className: "field" },
t.input({
type: "text",
required: true,
className: "inline-error",
id: "rateLimits.rules." + i + ".label",
name: "rateLimits.rules." + i + ".label",
placeholder: "tag (users:create) or path (/api/)",
"html-list": "rateLimits.rules." + i + ".label_list",
value: () => rule.label,
oninput: (e) => (rule.label = e.target.value),
}),
t.datalist(
{
id: "rateLimits.rules." + i + ".label_list",
},
() => {
return accordionData.predefinedTags.map((tag) => {
return t.option({ value: tag.value }, tag.label || "");
});
},
),
),
),
t.td(
{ className: "col-requests" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Max requests*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".maxRequests",
value: () => rule.maxRequests || 0,
oninput: (e) => rule.maxRequests = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-duration" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Interval*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".duration",
value: () => rule.duration,
oninput: (e) => rule.duration = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-audience" },
t.div(
{ className: "field" },
app.components.select({
name: "rateLimits.rules." + i + ".audience",
className: "inline-error",
options: audienceOptions,
required: true,
value: () => rule.audience || "",
onchange: (selected) => {
rule.audience = selected?.[0]?.value;
},
}),
),
),
t.td(
{ className: "col-action" },
t.button(
{
type: "button",
araiaDescription: app.attrs.tooltip("Remove rule"),
className: "btn sm secondary transparent circle",
onclick: () => removeRule(i),
},
t.i({ className: "ri-close-line" }),
),
),
),
);
}
return rows;
}),
),
),
t.div(
{ className: "flex m-t-sm" },
t.button(
{
type: "button",
className: "btn secondary sm",
onclick: () => newRule(),
},
t.i({ className: "ri-add-line", ariaHidden: true }),
t.span({ className: "txt" }, "Add rate limit rule"),
),
t.button(
{
type: "button",
className: "link-hint txt-sm m-l-auto",
onclick: () => openRateLimitInfoModal(),
},
t.em(null, "Learn more about the rate limit rules"),
),
),
),
),
);
}