416 lines
18 KiB
JavaScript
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"),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|