/* 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, TomSelect: false, bootstrap: false */
"use strict";
globalThis.toasts ??= {};
$(() => {
// 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
*/
function base64ToString(base64) {
// Remove padding and whitespace
const cleanBase64 = base64.replaceAll(/[=\s]/gu, "");
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) {
// Return early when text is not a string
if (typeof text !== "string") {
return text;
}
const map = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
return text.replaceAll(/[&<>"']/gu, m => map[m]);
}
function unescapeHtml(text) {
if (text === null) {
return null;
}
const map = {
"&": "&",
"<": "<",
">": ">",
""": '"',
"'": "'",
"Ü": "Ü",
"ü": "ü",
"Ä": "Ä",
"ä": "ä",
"Ö": "Ö",
"ö": "ö",
"ß": "ß",
};
return text.replaceAll(
/&(?:amp|lt|gt|quot|#039|Uuml|uuml|Auml|auml|Ouml|ouml|szlig);/gu,
m => map[m]
);
}
function padNumber(num) {
return ("00" + num).substr(-2, 2);
}
// Gets ToastContainer if it exists, otherwise creates a new one and return it
function getOrCreateToastContainer() {
const existing =
document.getElementById("toast-container") || document.querySelector(".toast-container");
if (existing !== null) {
return existing;
}
const container = document.createElement("div");
container.className = "toast-container position-fixed top-0 end-0 p-3";
container.id = "toast-container";
container.style.zIndex = "9999";
document.body.append(container);
return container;
}
// Set the toast element's properties
function createToast(alertState) {
const toast = document.createElement("div");
toast.className = "toast align-items-center border-0 shadow rounded overflow-hidden";
toast.className += ` ${alertState.bgColor}`;
toast.dataset.toastType = alertState.type;
toast.dataset.bsAutohide = String(alertState.autohide);
toast.dataset.bsDelay = String(alertState.delay);
toast.setAttribute("role", alertState.role);
toast.setAttribute("aria-live", alertState.live);
toast.setAttribute("aria-atomic", alertState.ariaAtomic);
const content = document.createElement("div");
content.className = "d-flex flex-column";
const header = document.createElement("div");
header.className = `toast-header ${alertState.bgColor}`;
if (alertState.icon !== "") {
const icon = document.createElement("i");
icon.className = `${alertState.icon} me-2`;
icon.setAttribute("aria-hidden", "true");
header.append(icon);
}
const title = document.createElement("strong");
title.className = "me-auto";
// Title is already escaped in showAlert() function, so we can safely set it as innerHTML
title.innerHTML = alertState.title;
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = `${alertState.closeButtonClass} ms-2 mb-auto`;
closeButton.dataset.bsDismiss = "toast";
closeButton.setAttribute("aria-label", "Close");
header.append(title, closeButton);
const body = document.createElement("div");
body.className = "toast-body";
const message = document.createElement("div");
message.className = "toast-message flex-grow-1";
message.style.whiteSpace = "pre-line";
message.style.overflowWrap = "anywhere";
// Message is already escaped in showAlert() function, so we can safely set it as innerHTML
message.innerHTML = alertState.message;
body.append(message);
content.append(header, body);
toast.append(content);
return toast;
}
function showAlert(type, icon, title, message, oldToastInstance = undefined) {
if (oldToastInstance === undefined) {
throw new Error("oldToastInstance is required; use null for a new toast");
}
// sets all properties of the alertState object based on the type of alert and the provided parameters
const alertState = {
title: escapeHtml(title),
message: escapeHtml(message),
icon,
type,
bgColor: "",
animation: true,
delay: 5000, // default value
autohide: true,
role: "status",
live: "polite",
ariaAtomic: "true",
closeButtonClass: "btn-close",
};
switch (type) {
case "info":
alertState.icon = icon !== null && icon.length > 0 ? icon : "fas fa-clock";
alertState.bgColor = "text-bg-info";
break;
case "success":
alertState.bgColor = "text-bg-success";
alertState.closeButtonClass += " btn-close-white";
break;
case "warning":
alertState.icon = "fas fa-exclamation-triangle";
alertState.delay *= 2;
alertState.bgColor = "text-bg-warning";
break;
case "error":
alertState.icon = "fas fa-times";
if (title.length === 0) {
alertState.title = "Error, something went wrong!";
}
alertState.delay *= 2;
alertState.bgColor = "text-bg-danger";
alertState.role = "alert";
alertState.live = "assertive";
alertState.closeButtonClass += " btn-close-white";
// 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) {
alertState.title = escapeHtml(data.error.message);
if (data.error.hint !== null) {
alertState.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;
}
let toastElement;
// Get or create the toast container
const container = getOrCreateToastContainer();
// If an old toast instance is provided and still shown, replace it with a new one, otherwise create a new toast
if (oldToastInstance?.isShown()) {
// Get the old DOM element
const oldToastElement = oldToastInstance._element;
// Create the replacement element
toastElement = createToast(alertState);
// Replace old element with new element
oldToastElement.replaceWith(toastElement);
// Hide the old Bootstrap instance
// This will trigger the "hidden.bs.toast" event, and the event listener will remove the old element and dispose of the instance
oldToastInstance.hide();
} else {
// No existing toast — create a new one
toastElement = createToast(alertState);
// Prepend the toast element to the container so that new toasts appear at the top
container.prepend(toastElement);
}
const toastInstance = bootstrap.Toast.getOrCreateInstance(toastElement);
// Remove the toast element from the DOM when it is hidden, and dispose of the Bootstrap toast instance to free up resources
toastElement.addEventListener(
"hidden.bs.toast",
() => {
toastElement.remove();
toastInstance.dispose();
},
{ once: true }
);
toastInstance.show();
return toastInstance;
}
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 [ ]HH:mm:ss z";
const timestr = moment.unix(Math.floor(date)).format(format).trim();
return humanReadable
? '' + moment.unix(Math.floor(date)).fromNow() + ""
: 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}$`,
"u"
);
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-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(
// eslint-disable-next-line regexp/no-useless-non-capturing-group, regexp/no-unused-capturing-group, regexp/prefer-named-capture-group
`^(((?:${ipv6elem}))*((?::${ipv6elem}))*::((?:${ipv6elem}))*((?::${ipv6elem}))*|((?:${ipv6elem}))((?::${ipv6elem})){7})${v6cidr}$`,
"iu"
);
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) {
const trimmedIp = ip.trim();
// Check if the IPv6 is enclosed in brackets and return in case of failure
if (!trimmedIp.startsWith("[") || !trimmedIp.endsWith("]")) {
return false;
}
// Strip brackets before validating the IPv6
const ipWithoutBrackets = trimmedIp.slice(1, -1);
// Validate the ip
return validateIPv6(ipWithoutBrackets);
}
function validatePort(port) {
// Ports containing spaces are not valid
if (port.trim() !== port) {
return false;
}
// Check if the port is an integer and within the valid network port range
const portNum = Number(port);
return Number.isSafeInteger(portNum) && portNum >= 1 && portNum <= 65_535;
}
// Validates the IPv4 server address used by dns.revServers, with an optional port
function validateIPv4WithPort(ip) {
// If a slash is present, its a network range, not a server IP
if (ip.includes("/")) {
return false;
}
// The port is optional
// If no "#" is present, validate just the IP
if (!ip.includes("#")) {
return validateIPv4(ip);
}
const parts = ip.split("#");
if (parts.length !== 2) {
return false;
}
const [ipv4, port] = parts;
// Validate IP and port
return validateIPv4(ipv4) && validatePort(port);
}
// Validates the IPv6 server address used by dns.revServers, with an optional port
function validateIPv6WithPort(ip) {
// If a slash is present, its a network range, not a server IP
if (ip.includes("/")) {
return false;
}
// The port is optional
// If no "#" is present, validate just the IP
if (!ip.includes("#")) {
return validateIPv6(ip);
}
const parts = ip.split("#");
if (parts.length !== 2) {
return false;
}
const [ipv6, port] = parts;
// Validate IP and port
return validateIPv6(ipv6) && validatePort(port);
}
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
// eslint-disable-next-line regexp/no-useless-non-capturing-group, regexp/prefer-named-capture-group
const macvalidator = /^(?:[\da-f]{2}([:-]))(?:[\da-f]{2}\1){4}[\da-f]{2}$/iu;
return macvalidator.test(mac.trim());
}
function validateHostname(name) {
const namevalidator = /[^<>;"]/u;
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])?)*$/u;
return hostnameValidator.test(name.trim());
}
/**
* Create a Tom Select multi-select out of a