merge newui branch
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
export function batchAccordion(pageData) {
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "batchApiAccordion",
|
||||
className: "accordion batch-api-accordion",
|
||||
name: "settingsAccordion",
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-archive-stack-line" }),
|
||||
t.span({ className: "txt" }, "Batch API"),
|
||||
t.div({ className: "flex-fill" }),
|
||||
() => {
|
||||
if (pageData.formSettings.batch.enabled) {
|
||||
return t.span({ className: "label success" }, "Enabled");
|
||||
}
|
||||
return t.span({ className: "label" }, "Disabled");
|
||||
},
|
||||
() => {
|
||||
if (!app.utils.isEmpty(app.store.errors?.batch)) {
|
||||
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: "batch.enabled",
|
||||
name: "batch.enabled",
|
||||
type: "checkbox",
|
||||
className: "switch",
|
||||
checked: () => pageData.formSettings.batch.enabled || false,
|
||||
onchange: (e) => (pageData.formSettings.batch.enabled = e.target.checked),
|
||||
}),
|
||||
t.label(
|
||||
{ htmlFor: "batch.enabled" },
|
||||
t.span({ className: "txt" }, "Enable"),
|
||||
t.small({ className: "txt-hint" }, " (experimental)"),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-lg-4" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: "batch.maxRequests" },
|
||||
t.span({ className: "txt" }, "Max requests in a batch"),
|
||||
t.i({
|
||||
className: "ri-information-line link-faded",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Rate limiting (if enabled) also applies for the batch create/update/upsert/delete requests.",
|
||||
"right",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
id: "batch.maxRequests",
|
||||
name: "batch.maxRequests",
|
||||
type: "number",
|
||||
min: 1,
|
||||
step: 1,
|
||||
required: () => pageData.formSettings.batch.enabled,
|
||||
disabled: () => !pageData.formSettings.batch.enabled,
|
||||
value: () => pageData.formSettings.batch.maxRequests,
|
||||
oninput: (e) => (pageData.formSettings.batch.maxRequests = e.target.value << 0),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-lg-4" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: "batch.timeout" },
|
||||
t.span({ className: "txt" }, "Max processing time (in seconds)"),
|
||||
),
|
||||
t.input({
|
||||
id: "batch.timeout",
|
||||
name: "batch.timeout",
|
||||
type: "number",
|
||||
min: 1,
|
||||
step: 1,
|
||||
required: () => pageData.formSettings.batch.enabled,
|
||||
disabled: () => !pageData.formSettings.batch.enabled,
|
||||
value: () => pageData.formSettings.batch.timeout,
|
||||
oninput: (e) => pageData.formSettings.batch.timeout = parseInt(e.target.value, 10),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-lg-4" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: "batch.maxBodySize" },
|
||||
t.span({ className: "txt" }, "Max body size (in bytes)"),
|
||||
),
|
||||
t.input({
|
||||
id: "batch.maxBodySize",
|
||||
name: "batch.maxBodySize",
|
||||
type: "number",
|
||||
min: 0,
|
||||
step: 1,
|
||||
placeholder: "Default to 128MB",
|
||||
disabled: () => !pageData.formSettings.batch.enabled,
|
||||
value: () => pageData.formSettings.batch.maxBodySize || "",
|
||||
oninput: (e) => pageData.formSettings.batch.maxBodySize = parseInt(e.target.value, 10),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { settingsSidebar } from "../settingsSidebar";
|
||||
import { batchAccordion } from "./batchAccordion";
|
||||
import { rateLimitAccordion, sortRules } from "./rateLimitAccordion";
|
||||
import { trustedProxyAccordion } from "./trustedProxyAccordion";
|
||||
|
||||
export function pageApplicationSettings() {
|
||||
app.store.title = "Application settings";
|
||||
|
||||
const data = store({
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
formSettings: null,
|
||||
originalFormSettings: null,
|
||||
get originalFormSettingsHash() {
|
||||
return JSON.stringify(data.originalFormSettings);
|
||||
},
|
||||
get hasChanges() {
|
||||
return data.originalFormSettingsHash != JSON.stringify(data.formSettings);
|
||||
},
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
|
||||
async function loadSettings() {
|
||||
data.isLoading = true;
|
||||
|
||||
try {
|
||||
const settings = await app.pb.settings.getAll();
|
||||
init(settings);
|
||||
|
||||
data.isLoading = false;
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
app.checkApiError(err);
|
||||
// data.isLoading = false; don't reset in case of a server error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (data.isSaving || !data.hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isSaving = true;
|
||||
|
||||
data.formSettings.rateLimits.rules = sortRules(data.formSettings.rateLimits.rules);
|
||||
|
||||
try {
|
||||
const redacted = app.utils.filterRedactedProps(data.formSettings);
|
||||
const settings = await app.pb.settings.update(redacted);
|
||||
init(settings);
|
||||
|
||||
app.toasts.success("Successfully saved application settings.");
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
data.isSaving = false;
|
||||
}
|
||||
|
||||
function init(settings = {}) {
|
||||
// refresh local app settings
|
||||
app.store.settings = JSON.parse(JSON.stringify(settings));
|
||||
|
||||
// load from the css style as fallback
|
||||
if (!settings.meta?.accentColor) {
|
||||
const cssColor = window.getComputedStyle(document.documentElement)?.getPropertyValue("--accentColor");
|
||||
if (cssColor?.startsWith("#")) {
|
||||
settings.meta = settings.meta || {};
|
||||
settings.meta.accentColor = cssColor.toLowerCase() || "";
|
||||
}
|
||||
}
|
||||
|
||||
data.originalFormSettings = {
|
||||
meta: settings.meta || {},
|
||||
batch: settings.batch || {},
|
||||
trustedProxy: settings.trustedProxy || { headers: [] },
|
||||
rateLimits: settings.rateLimits || { rules: [] },
|
||||
};
|
||||
|
||||
sortRules(data.originalFormSettings.rateLimits.rules);
|
||||
|
||||
data.formSettings = JSON.parse(JSON.stringify(data.originalFormSettings));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
data.formSettings = JSON.parse(data.originalFormSettingsHash);
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageApplicationSettings",
|
||||
className: "page page-application-settings",
|
||||
},
|
||||
settingsSidebar(),
|
||||
t.div(
|
||||
{ className: "page-content full-height" },
|
||||
t.header(
|
||||
{ className: "page-header" },
|
||||
t.nav(
|
||||
{ className: "breadcrumbs" },
|
||||
t.div({ className: "breadcrumb-item" }, "Settings"),
|
||||
t.div({ className: "breadcrumb-item" }, "Application"),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "wrapper m-b-base" },
|
||||
() => {
|
||||
if (data.isLoading) {
|
||||
return t.div({ className: "block txt-center" }, t.span({ className: "loader lg" }));
|
||||
}
|
||||
|
||||
return t.form(
|
||||
{
|
||||
pbEvent: "applicationSettingsForm",
|
||||
className: "grid application-settings-form",
|
||||
inert: () => data.isSaving,
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-md-5" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "meta.appName" }, "Application name"),
|
||||
t.input({
|
||||
id: "meta.appName",
|
||||
name: "meta.appName",
|
||||
type: "text",
|
||||
required: true,
|
||||
value: () => data.formSettings.meta.appName || "",
|
||||
oninput: (e) => (data.formSettings.meta.appName = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-md-5" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "meta.appURL" }, "Application URL"),
|
||||
t.input({
|
||||
id: "meta.appURL",
|
||||
name: "meta.appURL",
|
||||
type: "url",
|
||||
required: true,
|
||||
value: () => data.formSettings.meta.appURL || "",
|
||||
oninput: (e) => (data.formSettings.meta.appURL = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-md-2" },
|
||||
// pass isSaving to ensure that it will be rerendered after save
|
||||
() => accentColorField(data, data.isSaving),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-lg-12" },
|
||||
() => trustedProxyAccordion(data),
|
||||
() => rateLimitAccordion(data),
|
||||
() => batchAccordion(data),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-lg-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
id: "meta.hideControls",
|
||||
name: "meta.hideControls",
|
||||
type: "checkbox",
|
||||
className: "switch",
|
||||
checked: () => data.formSettings.meta.hideControls,
|
||||
onchange: (e) => (data.formSettings.meta.hideControls = e.target.checked),
|
||||
}),
|
||||
t.label(
|
||||
{ htmlFor: "meta.hideControls" },
|
||||
t.span({ className: "txt" }, "Hide collection create and edit controls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div({ className: "col-lg-12" }, t.hr()),
|
||||
t.div(
|
||||
{ className: "col-lg-12" },
|
||||
t.div(
|
||||
{ className: "flex" },
|
||||
t.div({ className: "m-r-auto" }),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn transparent secondary",
|
||||
disabled: () => data.isSaving,
|
||||
hidden: () => !data.hasChanges,
|
||||
onclick: reset,
|
||||
},
|
||||
t.span({ className: "txt" }, "Cancel"),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn expanded ${data.isSaving ? "loading" : ""}`,
|
||||
disabled: () => !data.hasChanges || data.isSaving,
|
||||
},
|
||||
t.span({ className: "txt" }, "Save changes"),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
t.footer({ className: "page-footer" }, app.components.credits()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function accentColorField(pageData) {
|
||||
const uniqueId = "accent_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
isTooLight: false,
|
||||
});
|
||||
|
||||
let colorChangeTimeoutId;
|
||||
let tempNoAnimationTimeoutId;
|
||||
|
||||
function changeAccentColor(color) {
|
||||
// temporary disable animations to minimize flickering
|
||||
clearTimeout(tempNoAnimationTimeoutId);
|
||||
document.documentElement.style.setProperty("--animationSpeed", "0");
|
||||
|
||||
if (color) {
|
||||
document.documentElement.style.setProperty("--accentColor", color.toLowerCase());
|
||||
} else {
|
||||
document.documentElement.style.removeProperty("--accentColor");
|
||||
}
|
||||
|
||||
// restore animation
|
||||
tempNoAnimationTimeoutId = setTimeout(() => {
|
||||
document.documentElement.style.removeProperty("--animationSpeed");
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const watchers = [
|
||||
watch(() => pageData.formSettings?.meta?.accentColor, (newColor) => {
|
||||
clearTimeout(colorChangeTimeoutId);
|
||||
colorChangeTimeoutId = setTimeout(() => {
|
||||
changeAccentColor(newColor);
|
||||
}, 100);
|
||||
}),
|
||||
];
|
||||
|
||||
return t.div(
|
||||
{
|
||||
className: "field",
|
||||
ariaDescription: app.attrs.tooltip(() => local.isTooLight ? "Invalid - color is too light" : ""),
|
||||
onunmount: () => {
|
||||
clearTimeout(colorChangeTimeoutId);
|
||||
changeAccentColor(pageData.formSettings.meta.accentColor);
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.span({ className: "txt" }, "Accent"),
|
||||
t.i({
|
||||
hidden: () => !local.isTooLight,
|
||||
className: "txt-warning ri-alert-line",
|
||||
}),
|
||||
),
|
||||
app.components.colorPicker({
|
||||
id: uniqueId,
|
||||
name: "meta.accentColor",
|
||||
predefinedColors: () => app.store.predefinedAccentColors,
|
||||
value: () => pageData.formSettings.meta.accentColor,
|
||||
onchange: (color) => {
|
||||
// @todo consider removing the constraint once contrast-color is implemented
|
||||
local.isTooLight = false;
|
||||
if (!app.utils.isDarkEnoughForWhiteText(color)) {
|
||||
local.isTooLight = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pageData.formSettings.meta.accentColor = color;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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" }),
|
||||
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" }),
|
||||
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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
export let basePredefinedTags = [
|
||||
{ value: "*:list" },
|
||||
{ value: "*:view" },
|
||||
{ value: "*:create" },
|
||||
{ value: "*:update" },
|
||||
{ value: "*:delete" },
|
||||
{ value: "*:file", description: "targets the files download endpoint" },
|
||||
{ value: "*:listAuthMethods" },
|
||||
{ value: "*:authRefresh" },
|
||||
{ value: "*:auth", description: "targets all auth methods" },
|
||||
{ value: "*:authWithPassword" },
|
||||
{ value: "*:authWithOAuth2" },
|
||||
{ value: "*:authWithOTP" },
|
||||
{ value: "*:requestOTP" },
|
||||
{ value: "*:requestPasswordReset" },
|
||||
{ value: "*:confirmPasswordReset" },
|
||||
{ value: "*:requestVerification" },
|
||||
{ value: "*:confirmVerification" },
|
||||
{ value: "*:requestEmailChange" },
|
||||
{ value: "*:confirmEmailChange" },
|
||||
];
|
||||
|
||||
export function openRateLimitInfoModal() {
|
||||
const modal = rateLimitInfoModal();
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
app.modals.open(modal);
|
||||
}
|
||||
|
||||
function rateLimitInfoModal() {
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "rateLimitInfoModal",
|
||||
className: "modal rate-limit-info-modal",
|
||||
onafterclose: (el) => {
|
||||
el?.remove();
|
||||
},
|
||||
},
|
||||
t.header({ className: "modal-header" }, t.h5(null, "Rate limit label format")),
|
||||
t.div(
|
||||
{ className: "modal-content" },
|
||||
t.p(null, "The rate limit rules are resolved in the following order (stops on the first match):"),
|
||||
t.ol(
|
||||
null,
|
||||
t.li(null, "exact tag (e.g. ", t.code(null, "users:create")),
|
||||
t.li(null, "wildcard tag (e.g. ", t.code(null, "*:create")),
|
||||
t.li(null, "METHOD + exact path (e.g. ", t.code(null, "POST /a/b")),
|
||||
t.li(null, "METHOD + prefix path (e.g. ", t.code(null, "POST /a/b", t.strong(null, "/"))),
|
||||
t.li(null, "exact path (e.g. ", t.code(null, "/a/b")),
|
||||
t.li(null, "prefix path (e.g. ", t.code(null, "/a/b", t.strong(null, "/"))),
|
||||
),
|
||||
t.p(
|
||||
null,
|
||||
`In case of multiple rules with the same label but different target user audience (e.g. "guest" vs "auth"), only the matching audience rule is taken in consideration.`,
|
||||
),
|
||||
t.hr(),
|
||||
t.p(null, "The rate limit label could be in one of the following formats:"),
|
||||
t.ul(
|
||||
null,
|
||||
t.li(
|
||||
{ className: "m-b-sm" },
|
||||
t.code(null, "[METHOD ]/my/path"),
|
||||
" - full exact route match (",
|
||||
t.strong(null, "must be without trailing slash"),
|
||||
"; \"METHOD\" is optional).",
|
||||
t.br(),
|
||||
"For example:",
|
||||
t.ul(
|
||||
{ className: "m-0" },
|
||||
t.li(
|
||||
null,
|
||||
t.code(null, "/hello"),
|
||||
" - matches ",
|
||||
t.code(null, "GET /hello"),
|
||||
", ",
|
||||
t.code(null, "POST /hello"),
|
||||
", etc.",
|
||||
),
|
||||
t.li(null, t.code(null, "POST /hello"), " - matches only ", t.code(null, "POST /hello")),
|
||||
),
|
||||
),
|
||||
t.li(
|
||||
{ className: "m-b-sm" },
|
||||
t.code(null, "[METHOD ]/my/prefix", t.strong(null, "/")),
|
||||
" - path prefix (",
|
||||
t.strong(null, "must end with trailing slash;"),
|
||||
"\"METHOD\" is optional). For example:",
|
||||
t.ul(
|
||||
{ className: "m-0" },
|
||||
t.li(
|
||||
null,
|
||||
t.code(null, "/hello/"),
|
||||
" - matches ",
|
||||
t.code(null, "GET /hello"),
|
||||
", ",
|
||||
t.code(null, "POST /hello/a/b/c"),
|
||||
", etc.",
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
t.code(null, "POST /hello/"),
|
||||
" - matches ",
|
||||
t.code(null, "POST /hello"),
|
||||
", ",
|
||||
t.code(null, "POST /hello/a/b/c"),
|
||||
", etc.",
|
||||
),
|
||||
),
|
||||
),
|
||||
t.li(
|
||||
{ className: "m-b-0" },
|
||||
t.code(null, "collectionName:predefinedTag"),
|
||||
" - targets a specific action of a single collection.",
|
||||
" To apply the rule for all collections you can use the ",
|
||||
t.code(null, "*"),
|
||||
" wildcard. For example:",
|
||||
t.code(null, "posts:create"),
|
||||
", ",
|
||||
t.code(null, "users:listAuthMethods"),
|
||||
", ",
|
||||
t.code(null, "*:auth"),
|
||||
".",
|
||||
t.br(),
|
||||
"The predifined collection tags are (",
|
||||
t.em(null, "there should be autocomplete once you start typing"),
|
||||
"):",
|
||||
t.ul({ className: "m-0" }, () => {
|
||||
return basePredefinedTags.map((tag) => {
|
||||
return t.li(null, tag.value.replace("*:", ":"), () => {
|
||||
if (tag.description) {
|
||||
return t.em({ className: "txt-hint" }, " (", tag.description, ")");
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.footer(
|
||||
{ className: "modal-footer" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn transparent m-r-auto",
|
||||
onclick: () => app.modals.close(),
|
||||
},
|
||||
t.span({ className: "txt" }, "Close"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
export function trustedProxyAccordion(pageData) {
|
||||
const commonProxyHeaders = ["X-Forwarded-For", "Fly-Client-IP", "CF-Connecting-IP"];
|
||||
|
||||
const ipOptions = [
|
||||
{ label: "Use leftmost IP", value: true },
|
||||
{ label: "Use rightmost IP", value: false },
|
||||
];
|
||||
|
||||
const proxyInfo = store({
|
||||
isLoading: false,
|
||||
realIP: "",
|
||||
possibleProxyHeader: "",
|
||||
get suggestedProxyHeaders() {
|
||||
if (!proxyInfo.possibleProxyHeader) {
|
||||
return commonProxyHeaders;
|
||||
}
|
||||
|
||||
return [proxyInfo.possibleProxyHeader].concat(
|
||||
commonProxyHeaders.filter((h) => h != proxyInfo.possibleProxyHeader),
|
||||
);
|
||||
},
|
||||
get isEnabled() {
|
||||
return !app.utils.isEmpty(pageData.formSettings.trustedProxy?.headers);
|
||||
},
|
||||
});
|
||||
|
||||
loadProxyInfo();
|
||||
|
||||
async function loadProxyInfo() {
|
||||
proxyInfo.isLoading = true;
|
||||
|
||||
try {
|
||||
const health = await app.pb.health.check({ requestKey: "loadProxyInfo" });
|
||||
|
||||
proxyInfo.realIP = health.data?.realIP || "";
|
||||
proxyInfo.possibleProxyHeader = health.data?.possibleProxyHeader || "";
|
||||
proxyInfo.isLoading = false;
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
app.checkApiError(err);
|
||||
proxyInfo.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return t.details(
|
||||
{
|
||||
pbEvent: "trustedProxyAccordion",
|
||||
className: "accordion trusted-proxy-accordion",
|
||||
name: "settingsAccordion",
|
||||
open: () => (proxyInfo.isLoading ? false : null),
|
||||
},
|
||||
t.summary(
|
||||
null,
|
||||
t.i({ className: "ri-route-line" }),
|
||||
t.span({ className: "txt" }, "User IP proxy headers"),
|
||||
() => {
|
||||
if (proxyInfo.isLoading) {
|
||||
return t.span({ className: "loader sm" });
|
||||
}
|
||||
|
||||
if (!proxyInfo.isEnabled && proxyInfo.possibleProxyHeader) {
|
||||
return t.i({
|
||||
className: "ri-alert-line txt-warning",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Detected proxy header.\nIt is recommend to list it as trusted.",
|
||||
"right",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
proxyInfo.isEnabled
|
||||
&& proxyInfo.possibleProxyHeader
|
||||
&& !pageData.formSettings.trustedProxy.headers.includes(proxyInfo.possibleProxyHeader)
|
||||
) {
|
||||
return t.i({
|
||||
className: "ri-alert-line txt-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"The configured proxy header doesn't match with the detected one.",
|
||||
"right",
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
t.div({ className: "flex-fill" }),
|
||||
() => {
|
||||
if (proxyInfo.isEnabled) {
|
||||
return t.span({ className: "label success" }, "Enabled");
|
||||
}
|
||||
return t.span({ className: "label" }, "Disabled");
|
||||
},
|
||||
() => {
|
||||
if (!app.utils.isEmpty(app.store.errors?.trustedProxy)) {
|
||||
return t.i({
|
||||
className: "ri-error-warning-fill txt-danger",
|
||||
ariaDescription: app.attrs.tooltip("Has errors", "left"),
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
t.p(
|
||||
{ className: "m-t-0" },
|
||||
"Below you should see your real IP. If not - configure the correct proxy header for your environment.",
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
hidden: () => proxyInfo.isLoading,
|
||||
className: "alert info m-b-sm",
|
||||
},
|
||||
t.div(
|
||||
{ className: "flex gap-5" },
|
||||
t.span(null, "Resolved user IP:"),
|
||||
t.strong(null, () => proxyInfo.realIP || "N/A"),
|
||||
),
|
||||
t.div(
|
||||
{ className: "flex gap-5" },
|
||||
t.span(null, "Detected proxy header:"),
|
||||
t.strong(null, () => proxyInfo.possibleProxyHeader || "N/A"),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "content m-b-sm" },
|
||||
t.p(
|
||||
null,
|
||||
`
|
||||
When PocketBase is deployed on platforms like Fly or it is accessible through proxies such as
|
||||
NGINX, requests from different users will originate from the same IP address (the IP of the proxy
|
||||
connecting to your PocketBase app).
|
||||
`,
|
||||
),
|
||||
t.p(
|
||||
null,
|
||||
`
|
||||
In this case to retrieve the actual user IP (used for rate limiting, logging, etc.) you need to
|
||||
properly configure your proxy and list below the trusted headers that PocketBase could use to
|
||||
extract the user IP.
|
||||
`,
|
||||
),
|
||||
t.p({ className: "txt-bold" }, `When using such proxy, to avoid spoofing it is recommended to:`),
|
||||
t.ul(
|
||||
{ className: "txt-bold" },
|
||||
t.li(
|
||||
null,
|
||||
"use headers that are controlled only by the proxy and cannot be manually set by the users",
|
||||
),
|
||||
t.li(null, "make sure that the PocketBase server can be accessed ONLY through the proxy"),
|
||||
),
|
||||
t.p(null, "You can clear the headers field if PocketBase is not deployed behind a proxy."),
|
||||
),
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-lg-9" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "trustedProxy.headers" }, "Trusted IP proxy headers"),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: "trustedProxy.headers",
|
||||
name: "trustedProxy.headers",
|
||||
placeholder: "Leave empty to disable",
|
||||
value: () => app.utils.joinNonEmpty(pageData.formSettings.trustedProxy.headers),
|
||||
oninput: (e) => {
|
||||
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
|
||||
const newStr = app.utils.joinNonEmpty(newValue);
|
||||
const oldStr = app.utils.joinNonEmpty(pageData.formSettings.trustedProxy.headers);
|
||||
|
||||
// has an actual change
|
||||
if (oldStr != newStr) {
|
||||
pageData.formSettings.trustedProxy.headers = newValue;
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () =>
|
||||
`btn sm secondary transparent ${
|
||||
app.utils.isEmpty(pageData.formSettings.trustedProxy.headers) ? "hidden" : ""
|
||||
}`,
|
||||
onclick: () => {
|
||||
pageData.formSettings.trustedProxy.headers = [];
|
||||
},
|
||||
},
|
||||
t.span({ className: "txt" }, "Clear"),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
"Comma separated list of headers such as: ",
|
||||
t.div({ className: "inline-flex gap-5" }, () => {
|
||||
return proxyInfo.suggestedProxyHeaders.map((header) => {
|
||||
return t.div({
|
||||
type: "button",
|
||||
className: "label sm link-hint",
|
||||
onclick: () => {
|
||||
pageData.formSettings.trustedProxy.headers = [header];
|
||||
},
|
||||
textContent: header,
|
||||
});
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-lg-3" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: "trustedProxy.useLeftmostIP" },
|
||||
t.span({ className: "txt" }, "IP priority"),
|
||||
t.i({
|
||||
className: "ri-information-line tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"This is in case the proxy returns more than 1 IP as header value. The rightmost IP is usually considered to be the more trustworthy but this could vary depending on the proxy.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
app.components.select({
|
||||
id: "trustedProxy.useLeftmostIP",
|
||||
name: "trustedProxy.useLeftmostIP",
|
||||
options: ipOptions,
|
||||
required: true,
|
||||
value: () => pageData.formSettings.trustedProxy.useLeftmostIP || false,
|
||||
onchange: (selected) => {
|
||||
pageData.formSettings.trustedProxy.useLeftmostIP = selected?.[0]?.value;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user