Files
web/scripts/js/utils.js
T
RD WebDesign bc01113163 Enforce hostname and IPv6 validation on the static DHCP table
Only accept valid hostnames:
  Previously, invalid hostnames were trigerring the cell highlighting,
  but invalid entries were still accepted when using the green "save"
  button. Now, hostnames are saved only if they pass the validation,
  like other values.

Only allow IPv6 enclosed in square brackets:
  The previous validation function used to validate IPv6 didn't accept
  brackets, resulting in errors when an IPv6 was typed directly on the
  table cell. A new function was created to validate IPv6 enclosed in
  brackets.

Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
2026-04-24 15:10:06 -03:00

778 lines
23 KiB
JavaScript

/* Pi-hole: A black hole for Internet advertisements
* (c) 2020 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global moment:false, apiFailure: false, updateFtlInfo: false, NProgress:false, WaitMe:false */
"use strict";
$(() => {
// CSRF protection for AJAX requests, this has to be configured globally
// because we are using the jQuery $.ajax() function directly in some cases
// Furthermore, has this to be done before any AJAX request is made so that
// the CSRF token is sent along with each request to the API
$.ajaxSetup({
headers: { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content") },
});
});
/**
* Decode a base64 string to UTF-8 text using native browser APIs
* This is the replacement for the deprecated atob() function
* @param {string} base64 - Base64 encoded string
* @returns {string} Decoded UTF-8 string
*/
// eslint-disable-next-line no-unused-vars -- Used by other scripts (e.g., footer.js)
function base64ToString(base64) {
// Remove padding and whitespace
const cleanBase64 = base64.replaceAll(/[=\s]/gv, "");
const base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Decode base64 to bytes
const bytes = [];
for (let i = 0; i < cleanBase64.length; i += 4) {
const encoded1 = base64Chars.indexOf(cleanBase64[i]);
const encoded2 = base64Chars.indexOf(cleanBase64[i + 1]);
const encoded3 = base64Chars.indexOf(cleanBase64[i + 2]);
const encoded4 = base64Chars.indexOf(cleanBase64[i + 3]);
/* eslint-disable no-bitwise -- Bitwise operations required for base64 decoding */
bytes.push((encoded1 << 2) | (encoded2 >> 4));
if (encoded3 !== -1) bytes.push(((encoded2 & 15) << 4) | (encoded3 >> 2));
if (encoded4 !== -1) bytes.push(((encoded3 & 3) << 6) | encoded4);
/* eslint-enable no-bitwise */
}
// Decode bytes as UTF-8
return new TextDecoder().decode(new Uint8Array(bytes));
}
// Credit: https://stackoverflow.com/a/4835406
function escapeHtml(text) {
const map = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
// Return early when text is not a string
if (typeof text !== "string") return text;
return text.replaceAll(/[&<>"']/gv, m => map[m]);
}
function unescapeHtml(text) {
const map = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#039;": "'",
"&Uuml;": "Ü",
"&uuml;": "ü",
"&Auml;": "Ä",
"&auml;": "ä",
"&Ouml;": "Ö",
"&ouml;": "ö",
"&szlig;": "ß",
};
if (text === null) return null;
return text.replaceAll(
/&(?:amp|lt|gt|quot|#039|Uuml|uuml|Auml|auml|Ouml|ouml|szlig);/gv,
m => map[m]
);
}
function padNumber(num) {
return ("00" + num).substr(-2, 2);
}
let showAlertBox = null;
function showAlert(type, icon, title, message, toast) {
const options = {
title: "&nbsp;<strong>" + escapeHtml(title) + "</strong><br>",
message: escapeHtml(message),
icon,
};
const settings = {
type,
delay: 5000, // default value
mouse_over: "pause",
animate: {
enter: "animate__animated animate__fadeInDown",
exit: "animate__animated animate__fadeOutUp",
},
};
switch (type) {
case "info":
options.icon = icon !== null && icon.length > 0 ? icon : "fas fa-clock";
break;
case "success":
break;
case "warning":
options.icon = "fas fa-exclamation-triangle";
settings.delay *= 2;
break;
case "error":
options.icon = "fas fa-times";
if (title.length === 0)
options.title = "&nbsp;<strong>Error, something went wrong!</strong><br>";
settings.delay *= 2;
// If the message is an API object, nicely format the error message
// Try to parse message as JSON
try {
const data = JSON.parse(message);
console.log(data); // eslint-disable-line no-console
if (data.error !== undefined) {
options.title = "&nbsp;<strong>" + escapeHtml(data.error.message) + "</strong><br>";
if (data.error.hint !== null) options.message = escapeHtml(data.error.hint);
}
} catch {
// Do nothing
}
break;
default:
// Case not handled, do nothing
console.log("Unknown alert type: " + type); // eslint-disable-line no-console
return;
}
if (toast === undefined) {
if (type === "info") {
// Create a new notification for info boxes
showAlertBox = $.notify(options, settings);
return showAlertBox;
}
if (showAlertBox !== null) {
// Update existing notification for other boxes (if available)
showAlertBox.update(options);
showAlertBox.update(settings);
return showAlertBox;
}
// Create a new notification for other boxes if no previous info box exists
return $.notify(options, settings);
}
if (toast === null) {
// Always create a new toast
return $.notify(options, settings);
}
// Update existing toast
toast.update(options);
toast.update(settings);
return toast;
}
function datetime(date, html, humanReadable) {
if (date === 0 && humanReadable) {
return "Never";
}
const format =
html === false ? "Y-MM-DD HH:mm:ss z" : "Y-MM-DD [<br class='hidden-lg'>]HH:mm:ss z";
const timestr = moment.unix(Math.floor(date)).format(format).trim();
return humanReadable
? '<span title="' + timestr + '">' + moment.unix(Math.floor(date)).fromNow() + "</span>"
: timestr;
}
function datetimeRelative(date) {
return moment.unix(Math.floor(date)).fromNow();
}
function disableAll() {
$("input").prop("disabled", true);
$("select").prop("disabled", true);
$("button").prop("disabled", true);
$("textarea").prop("disabled", true);
}
function enableAll() {
$("input").prop("disabled", false);
$("select").prop("disabled", false);
$("button").prop("disabled", false);
$("textarea").prop("disabled", false);
// Enable custom input field only if applicable
const ip = $("#select") ? $("#select").val() : null;
if (ip !== null && ip !== "custom") {
$("#ip-custom").prop("disabled", true);
}
}
// Pi-hole IPv4/CIDR validator by DL6ER, see regexr.com/50csh
function validateIPv4CIDR(ip) {
// One IPv4 element is 8bit: 0 - 255
const ipv4elem = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)";
// CIDR for IPv4 is 1 - 32 bit (optional)
const v4cidr = "(\\/([1-9]|[1-2][0-9]|3[0-2])){0,1}";
// Build the complete IPv4/CIDR validator
// Format: xxx.xxx.xxx.xxx[/yy] where each xxx is 0-255 and optional yy is 1-32
const ipv4validator = new RegExp(
`^${ipv4elem}\\.${ipv4elem}\\.${ipv4elem}\\.${ipv4elem}${v4cidr}$`,
"v"
);
return ipv4validator.test(ip);
}
function validateIPv4(ip) {
// Add pseudo-CIDR to the IPv4
const ipv4WithCIDR = ip.includes("/") ? ip : ip + "/32";
// Validate the IPv4/CIDR
return validateIPv4CIDR(ipv4WithCIDR);
}
// Pi-hole IPv6/CIDR validator by DL6ER, see regexr.com/50csn
function validateIPv6CIDR(ip) {
// One IPv6 element is 16bit: 0000 - FFFF
const ipv6elem = "[0-9A-Fa-f]{1,4}";
// CIDR for IPv6 is 1-128 bit (optional)
const v6cidr = "(\\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}";
const ipv6validator = new RegExp(
`^(((?:${ipv6elem}))*((?::${ipv6elem}))*::((?:${ipv6elem}))*((?::${ipv6elem}))*|((?:${ipv6elem}))((?::${ipv6elem})){7})${v6cidr}$`,
"v"
);
return ipv6validator.test(ip);
}
function validateIPv6(ip) {
// Add pseudo-CIDR to the IPv6
const ipv6WithCIDR = ip.includes("/") ? ip : ip + "/128";
// Validate the IPv6/CIDR
return validateIPv6CIDR(ipv6WithCIDR);
}
function validateIPv6Brackets(ip) {
// Check if the IPv6 is enclosed in brackets and return in case of failure
if (!ip.trim().startsWith("[") || !ip.trim().endsWith("]")) {
return false;
}
// Strip brackets before validating the IPv6
const ipWithoutBrackets = ip.replaceAll("[", "").replaceAll("]", "");
// Validate the ip
return validateIPv6(ipWithoutBrackets);
}
function validateMAC(mac) {
// Format: xx:xx:xx:xx:xx:xx where each xx is 0-9 or a-f (case insensitive)
// Also allows dashes as separator, e.g. xx-xx-xx-xx-xx-xx
const macvalidator = /^([\da-f]{2}[:\-]){5}([\da-f]{2})$/iv;
return macvalidator.test(mac.trim());
}
function validateHostname(name) {
const namevalidator = /[^<>;"]/v;
return namevalidator.test(name.trim());
}
function validateHostnameStrict(name) {
// Hostnames must not contain spaces, commas, or characters invalid in DNS names
const hostnameValidator =
/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$/v;
return hostnameValidator.test(name.trim());
}
// set bootstrap-select defaults
function setBsSelectDefaults() {
const bsSelectDefaults = $.fn.selectpicker.Constructor.DEFAULTS;
bsSelectDefaults.noneSelectedText = "none selected";
bsSelectDefaults.selectedTextFormat = "count > 1";
bsSelectDefaults.actionsBox = true;
bsSelectDefaults.width = "fit";
bsSelectDefaults.container = "body";
bsSelectDefaults.dropdownAlignRight = "auto";
bsSelectDefaults.selectAllText = "All";
bsSelectDefaults.deselectAllText = "None";
bsSelectDefaults.countSelectedText = function (num, total) {
if (num === total) {
return "All selected (" + num + ")";
}
return num + " selected";
};
}
const backupStorage = {};
function stateSaveCallback(itemName, data) {
if (localStorage === null) {
backupStorage[itemName] = JSON.stringify(data);
} else {
localStorage.setItem(itemName, JSON.stringify(data));
}
}
function stateLoadCallback(itemName) {
let data;
// Receive previous state from client's local storage area
if (localStorage === null) {
const item = backupStorage[itemName];
data = item === "undefined" ? null : item;
} else {
data = localStorage.getItem(itemName);
}
// Return if not available
if (data === null) {
return null;
}
// Parse JSON string
data = JSON.parse(data);
// Clear possible filtering settings
for (const column of Object.values(data.columns)) {
column.search.search = "";
}
// Always start on the first page to show most recent queries
data.start = 0;
// Always start with empty search field
data.search.search = "";
// Apply loaded state to table
return data;
}
function addFromQueryLog(domain, list) {
const alertModal = $("#alertModal");
const alProcessing = alertModal.find(".alProcessing");
const alSuccess = alertModal.find(".alSuccess");
const alFailure = alertModal.find(".alFailure");
const alNetworkErr = alertModal.find(".alFailure #alNetErr");
const alCustomErr = alertModal.find(".alFailure #alCustomErr");
const alList = "#alList";
const alDomain = "#alDomain";
// Exit the function here if the Modal is already shown (multiple running interlock)
if (alertModal.css("display") !== "none") {
return;
}
const listtype = list === "allow" ? "Allowlist" : "Denylist";
alProcessing.children(alDomain).text(domain);
alProcessing.children(alList).text(listtype);
alertModal.modal("show");
// add Domain to List after Modal has faded in
alertModal.one("shown.bs.modal", () => {
$.ajax({
url: document.body.dataset.apiurl + "/domains/" + list + "/exact",
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
domain,
comment: "Added from Query Log",
type: list,
kind: "exact",
}),
success(response) {
alProcessing.hide();
if ("domains" in response && response.domains.length > 0) {
// Success
alSuccess.children(alDomain).text(domain);
alSuccess.children(alList).text(listtype);
alSuccess.fadeIn(1000);
// Update domains counter in the menu
updateFtlInfo();
setTimeout(() => {
alertModal.modal("hide");
}, 2000);
} else {
// Failure
alNetworkErr.hide();
alCustomErr.html(response.message);
alFailure.fadeIn(1000);
setTimeout(() => {
alertModal.modal("hide");
}, 10_000);
}
},
error() {
// Network Error
alProcessing.hide();
alNetworkErr.show();
alFailure.fadeIn(1000);
setTimeout(() => {
alertModal.modal("hide");
}, 8000);
},
});
});
// Reset Modal after it has faded out
alertModal.one("hidden.bs.modal", () => {
alProcessing.show();
alSuccess.add(alFailure).hide();
alProcessing.add(alSuccess).children(alDomain).html("").end().children(alList).html("");
alCustomErr.html("");
});
}
// Helper functions to format the progress bars used on the Dashboard and Long-term Lists
function addTD(content) {
return "<td>" + content + "</td> ";
}
function toPercent(number, fractionDigits = 0) {
const userLocale = navigator.language || "en-US";
return new Intl.NumberFormat(userLocale, {
style: "percent",
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(number / 100);
}
function colorBar(percentage, total, cssClass) {
const formattedPercentage = toPercent(percentage, 1);
const title = `${formattedPercentage} of ${total}`;
const bar = `<div class="progress-bar ${cssClass}" style="width: ${percentage}%"></div>`;
return `<div class="progress progress-sm" title="${title}"> ${bar} </div>`;
}
function checkMessages() {
$.ajax({
url: document.body.dataset.apiurl + "/info/messages/count",
method: "GET",
dataType: "json",
})
.done(data => {
if (data.count > 0) {
const more = '\nAccess "Tools/Pi-hole diagnosis" for further details.';
const title =
data.count > 1
? "There are " + data.count + " warnings." + more
: "There is one warning." + more;
$(".warning-count").prop("title", title);
$(".warning-count").text(data.count);
$(".warning-count").removeClass("hidden");
} else {
$(".warning-count").addClass("hidden");
}
})
.fail(data => {
$(".warning-count").addClass("hidden");
apiFailure(data);
});
}
function doLogout(url) {
$.ajax({
url: document.body.dataset.apiurl + "/auth",
method: "DELETE",
}).always(() => {
globalThis.location = url;
});
}
function renderTimestamp(data, type) {
// Display and search content
if (type === "display" || type === "filter") {
return datetime(data, false, false);
}
// Sorting content
return data;
}
function renderTimespan(data, type) {
// Display and search content
if (type === "display" || type === "filter") {
return datetime(data, false, true);
}
// Sorting content
return data;
}
// Show only the appropriate delete buttons in datatables
function changeTableButtonStates(table) {
const selectAllElements = document.querySelectorAll(".selectAll");
const selectMoreElements = document.querySelectorAll(".selectMore");
const removeAllElements = document.querySelectorAll(".removeAll");
const deleteSelectedElements = document.querySelectorAll(".deleteSelected");
const allRows = table.rows({ filter: "applied" }).data().length;
const pageLength = table.page.len();
const selectedRows = table.rows(".selected").data().length;
if (selectedRows === 0) {
// Nothing selected
for (const el of selectAllElements) el.classList.remove("hidden");
for (const el of selectMoreElements) el.classList.add("hidden");
for (const el of removeAllElements) el.classList.add("hidden");
for (const el of deleteSelectedElements) el.classList.add("hidden");
} else if (selectedRows >= pageLength || selectedRows === allRows) {
// Whole page is selected (or all available messages were selected)
for (const el of selectAllElements) el.classList.add("hidden");
for (const el of selectMoreElements) el.classList.add("hidden");
for (const el of removeAllElements) el.classList.remove("hidden");
for (const el of deleteSelectedElements) el.classList.remove("hidden");
} else {
// Some rows are selected, but not all
for (const el of selectAllElements) el.classList.add("hidden");
for (const el of selectMoreElements) el.classList.remove("hidden");
for (const el of removeAllElements) el.classList.add("hidden");
for (const el of deleteSelectedElements) el.classList.remove("hidden");
}
}
function getCSSval(cssclass, cssproperty) {
const elem = $("<div class='" + cssclass + "'></div>");
const val = elem.appendTo("body").css(cssproperty);
elem.remove();
return val;
}
function parseQueryString() {
const params = new URLSearchParams(globalThis.location.search);
return Object.fromEntries(params.entries());
}
function hexEncode(text) {
if (typeof text !== "string" || text.length === 0) return "";
return [...text].map(char => char.codePointAt(0).toString(16).padStart(4, "0")).join("");
}
function hexDecode(text) {
if (typeof text !== "string" || text.length === 0) return "";
const hexes = text.match(/.{1,4}/gv);
if (!hexes || hexes.length === 0) return "";
return hexes.map(hex => String.fromCodePoint(Number.parseInt(hex, 16))).join("");
}
function listsAlert(type, items, data) {
// Show simple success message if there is no "processed" object in "data" or
// if all items were processed successfully
const successLength = data.processed.success.length;
const errorsLength = data.processed.errors.length;
if (data.processed === undefined || successLength === items.length) {
showAlert(
"success",
"fas fa-plus",
"Successfully added " + type + (items.length !== 1 ? "s" : ""),
items.join(", ")
);
return;
}
// Show a more detailed message if there is a "processed" object in "data" and
// not all items were processed successfully
let message = "";
// Show a list of successful items if there are any
if (successLength > 0) {
message +=
"Successfully added " + successLength + " " + type + (successLength !== 1 ? "s" : "") + ":";
// Loop over data.processed.success and print "item"
for (const item of Object.values(data.processed.success)) {
message += "\n- " + item.item;
}
}
// Add a line break if there are both successful and failed items
if (successLength > 0 && errorsLength > 0) {
message += "\n\n";
}
// Show a list of failed items if there are any
if (errorsLength > 0) {
message +=
"Failed to add " + errorsLength + " " + type + (errorsLength !== 1 ? "s" : "") + ":\n";
// Loop over data.processed.errors and print "item: error"
for (const errorItem of Object.values(data.processed.errors)) {
let error = errorItem.error;
// Replace some error messages with a more user-friendly text
if (error.includes("UNIQUE constraint failed")) {
error = "Already present";
}
message += `\n- ${errorItem.item}: ${error}`;
}
}
// Show the warning message
const total = successLength + errorsLength;
const processed = "(" + total + " " + type + (total !== 1 ? "s" : "") + " processed)";
showAlert(
"warning",
"fas fa-exclamation-triangle",
"Some " + type + (items.length !== 1 ? "s" : "") + " could not be added " + processed,
message
);
}
let waitMe = null;
// Callback function for the loading overlay timeout
function loadingOverlayTimeoutCallback(reloadAfterTimeout) {
// Try to ping FTL to see if it finished restarting
$.ajax({
url: document.body.dataset.apiurl + "/info/login",
method: "GET",
cache: false,
dataType: "json",
})
.done(() => {
// FTL is running again, hide loading overlay
NProgress.done();
if (reloadAfterTimeout) {
location.reload();
} else {
waitMe.hideAll();
}
})
.fail(() => {
// FTL is not running yet, try again in 500ms
setTimeout(loadingOverlayTimeoutCallback, 500, reloadAfterTimeout);
});
}
function loadingOverlay(reloadAfterTimeout = false) {
NProgress.start();
waitMe = new WaitMe(".wrapper", {
effect: "bounce",
text: "Pi-hole is currently applying your changes...",
bg: "rgba(0,0,0,0.7)",
color: "#fff",
maxSize: "",
textPos: "vertical",
});
// Start checking for FTL status after 2 seconds
setTimeout(loadingOverlayTimeoutCallback, 2000, reloadAfterTimeout);
return true;
}
// Function that calls a function only if the page is currently visible. This is
// useful to prevent unnecessary API calls when the page is not visible (e.g.
// when the user is on another tab).
function callIfVisible(func) {
if (document.hidden) {
// Page is not visible, try again in 1 second
globalThis.setTimeout(callIfVisible, 1000, func);
return;
}
// Page is visible, call function instead
func();
}
// Timer that calls a function after <interval> milliseconds but only if the
// page is currently visible. We cancel possibly running timers for the same
// function before starting a new one to prevent multiple timers running at
// the same time causing unnecessary identical API calls when the page is
// visible again.
function setTimer(func, interval) {
// Cancel possibly running timer
globalThis.clearTimeout(func.timer);
// Start new timer
func.timer = globalThis.setTimeout(callIfVisible, interval, func);
}
// Same as setTimer() but calls the function every <interval> milliseconds
function setInter(func, interval) {
// Cancel possibly running timer
globalThis.clearTimeout(func.timer);
// Start new timer
func.timer = globalThis.setTimeout(callIfVisible, interval, func);
// Restart timer
globalThis.setTimeout(setInter, interval, func, interval);
}
/**
* Toggle or set the collapse state of a box element
* @param {HTMLElement} box - The box element
* @param {boolean} [expand=true] - Whether to expand (true) or collapse (false) the box
*/
// Not using the AdminLTE API so that the expansion is not animated
// Otherwise, we could use `$(customBox).boxWidget("expand")`
function toggleBoxCollapse(box, expand = true) {
if (!box) return;
const icon = box.querySelector(".btn-box-tool > i");
const body = box.querySelector(".box-body");
if (expand) {
box.classList.remove("collapsed-box");
if (icon) icon.classList.replace("fa-plus", "fa-minus");
if (body) body.style = "";
} else {
box.classList.add("collapsed-box");
if (icon) icon.classList.replace("fa-minus", "fa-plus");
if (body) body.style.display = "none";
}
}
globalThis.utils = (function () {
return {
escapeHtml,
unescapeHtml,
padNumber,
showAlert,
datetime,
datetimeRelative,
disableAll,
enableAll,
validateIPv4CIDR,
validateIPv4,
validateIPv6CIDR,
validateIPv6,
validateIPv6Brackets,
setBsSelectDefaults,
stateSaveCallback,
stateLoadCallback,
validateMAC,
validateHostname,
validateHostnameStrict,
addFromQueryLog,
addTD,
toPercent,
colorBar,
checkMessages,
doLogout,
renderTimestamp,
renderTimespan,
changeTableButtonStates,
getCSSval,
parseQueryString,
hexEncode,
hexDecode,
listsAlert,
loadingOverlay,
setTimer,
setInter,
toggleBoxCollapse,
base64ToString,
};
})();