├── .github └── FUNDING.yml ├── README.md ├── Userscript ├── NXEnhanced.user.js └── README.md └── WebExtension ├── NXEnhanced.js ├── icon.png ├── manifest.json ├── options-page.html ├── options-page.js ├── popup.html └── utils.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: hjk789 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

NX Enhanced

3 |

Adds "quality-of-life" features to NextDNS website to make the experience of managing lists, domains, logs, etc. more practical.

4 | 5 |

6 |                                  

7 |

8 |

⚠ This project is discontinued and won't receive more updates, although it still works as of this writing.

9 | 10 | ## Current features 11 | 12 | ### Logs page: 13 | 14 | - Allow/Deny buttons in the logs that make it possible to add an exception or block a domain without needing to copy, switch pages, and paste. 15 | 16 | ![Allow and Deny butttons](https://i.imgur.com/3XNMUi1.png) 17 | You can either add the respective domain or the whole root domain, or even edit the domain if you want. 18 | [Read more](https://github.com/hjk789/NXEnhanced/wiki#an-allowdeny-button-for-each-log-entry) 19 | 20 | - Ability to specify domains that should be hidden from the logs 21 | 22 | ![Domain filtering](https://i.imgur.com/l8Ouzh1.png) 23 | You can either manually input domains, or click on the "Hide" button, alongside the Allow/Deny buttons, which lets you hide domains with few clicks. [Read more](https://github.com/hjk789/NXEnhanced/wiki#ability-to-specify-domains-that-should-be-hidden-from-the-logs) 24 | 25 | - Ability to load only the logs that happened before a specified date-time 26 | 27 | ![only logs before](https://i.imgur.com/FChYIoS.png) 28 | 29 | - Option to show only queries from unnamed devices 30 | 31 | ![Other Devices button](https://i.imgur.com/V7HFiJL.png) 32 | 33 | - Refine a search with multiple search terms or exclusion terms 34 | 35 | ![multiple terms](https://i.imgur.com/fBlxR18.png) 36 | You can specify as many terms as you need. [Read more](https://github.com/hjk789/NXEnhanced/wiki#refine-a-search-with-multiple-search-terms-or-exclusion-terms) 37 | 38 | - An option to show the number of entries currently loaded, visible or hidden by filters 39 | 40 | ![counters](https://i.imgur.com/8mTEDt1.png) 41 | 42 | - Show the query's absolute time (HH:MM:SS) along with the relative time ("a minute ago", "few seconds ago") 43 | 44 | ![Absolute time](https://i.imgur.com/I3pGNL8.png) 45 | 46 | - Relative time that counts minutes, then hours, and goes up to "Yesterday" 47 | 48 | ![more relative times](https://i.imgur.com/BhS1B6n.png) 49 | 50 | - A refresh button 51 | 52 | ![refresh button](https://i.imgur.com/yBEo3mV.png) 53 | 54 | ### Allowlist/Denylist pages: 55 | 56 | - Ability to add a description to each domain in the allow/denylists. [Read more](https://github.com/hjk789/NXEnhanced/wiki#ability-to-add-a-description-to-each-domain-in-the-denyallow-lists) 57 | 58 | ![Description input](https://i.imgur.com/wS2kRNG.png) 59 | 60 | - Ability to add a list of domains, instead of one by one 61 | 62 | ![multiline input box](https://i.imgur.com/p5Ovg11.png) 63 | 64 | - Sort the allow/deny lists alphabetically, and styling options for an easier quick reading, such as: lighten subdomains, bold root domain and right-align. 65 | 66 | ![allow/deny options](https://i.imgur.com/HCgekWd.png) 67 | 68 | ### Settings page: 69 | 70 | - Ability to export/import all settings from/to a config. [Read more](https://github.com/hjk789/NXEnhanced/wiki#ability-to-exportimport-all-settings-fromto-a-config) 71 | 72 | ![Export/import buttons](https://i.imgur.com/2oEl8t2.png) 73 | 74 | ### Privacy page: 75 | 76 | - Collapse the list of blocklists enabled and adds a button to unhide them if needed 77 | 78 | ![Hidden lists](https://i.imgur.com/ifnmNiv.png) 79 | This is good for people with a long list of blocklists added. 80 | 81 | - Sort alphabetically the list of blocklists in the "Add a blocklist" screen 82 | 83 | ![Sort a-z blocklists](https://i.imgur.com/rFXduAY.png) 84 | 85 | ### Security page: 86 | 87 | - Collapse the list of added TLDs 88 | 89 | - A button that allows you to add every TLD in the "Add a TLD" screen in one click. 90 | 91 | ![Add all TLDs button](https://i.imgur.com/PDlYlF1.png) 92 | 93 | 94 | ## How to install 95 | 96 | Click the button above that matches your browser, install then confirm installation. If you are using a Chromium-based browser, like Brave, Opera, Vivaldi, and others, use the Chrome Web Store link. 97 | 98 | You also have the option of using the userscript version, but it works only in Chrome, in Firefox it works partially. Also, keep in mind that the userscript is discontinued, so it won't receive any updates. For more information and instructions, read [here](https://github.com/hjk789/NXEnhanced/tree/master/Userscript#how-to-use-it). 99 | 100 | NX Enhanced was tested in Firefox and Chrome. It should work fine in pretty much any browser that accepts Firefox or Chrome extensions, although I didn't tested them. 101 | 102 | ## License 103 | 104 | - You can view the code, download copies to your devices, install, run, use the features and uninstall this software. 105 | - You can modify your downloaded copy as you like, although it's recommended that you suggest this modification to be included in the original, so all users can benefit. 106 | - You can rate and review this project. 107 | - You can make a fork of this project, provided that you fulfill all of the following conditions: 1. You fork it inside GitHub, by clicking on the "Fork" button or the "Edit this file" button of this project's repository web page; and 2. You fork it in order to push changes to this project's repository with a pull request. If you don't fulfill all these conditions, don't fork it, "*Star*" it instead. Any contributed code is owned by the repository owner, [BLBC](https://github.com/hjk789). The credits for the contributed code goes to the contributor. 108 | - You can only do actions expressly allowed in this license. Any other action not mentioned in this license is forbidden, including, but not limited to, redistribution. 109 | - Feel free to refer to NX Enhanced, just make sure to include a link to this project's repository homepage (https://github.com/hjk789/NXEnhanced). This is recommended over linking to an extension store, as the person who clicks the link will be able to choose the extension store they will install from. 110 | 111 | I, [BLBC](https://github.com/hjk789), have no association with NextDNS Inc., I'm just a user of their DNS service who needed the features NX Enhanced provides. NX Enhanced is a completely voluntary and unnoficial work. Neither I, nor NextDNS Inc., are responsible for any damage or leak, directly or indirectly related to the use or misuse of this software. The responsibility is completely on it's users. Use it at your own risk. There are no warranties, either implied or stated. 112 | 113 | Copyright (c) 2020+ BLBC ([hjk789](https://github.com/hjk789)) 114 | 115 | ## Privacy policy 116 | 117 | You can read the full privacy policy [here](https://github.com/hjk789/NXEnhanced/wiki/Privacy-Policy). In brief, most of what you need to know is in the first line: "NX Enhanced does not collect and does not send your data to third-parties, it does not include any kind of tracking or analytics in the code, and it also does not and will not have access to your email or password." 118 | -------------------------------------------------------------------------------- /Userscript/NXEnhanced.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name NX Enhanced 3 | // @description Adds quality-of-life features to NextDNS website for a more practical experience 4 | // @author BLBC (github.com/hjk789, greasyfork.org/users/679182-hjk789, reddit.com/u/dfhg89s7d89) 5 | // @copyright 2020+, BLBC (github.com/hjk789) 6 | // @version 3.7.4 7 | // @homepage https://github.com/hjk789/NXEnhanced 8 | // @license https://github.com/hjk789/NXEnhanced#license 9 | // @supportURL https://github.com/hjk789/NXEnhanced/issues 10 | // @downloadURL https://greasyfork.org/scripts/408934-nx-enhanced/code/NX%20Enhanced.user.js 11 | // @updateURL https://greasyfork.org/scripts/408934-nx-enhanced/code/NX%20Enhanced.user.js 12 | // @grant GM.setValue 13 | // @grant GM.getValue 14 | // @match https://my.nextdns.io/* 15 | // @match https://api.nextdns.io/* 16 | // ==/UserScript== 17 | /* eslint-disable no-multi-spaces, curly, no-loop-func, no-multi-str, no-caller, dot-notation, no-lone-blocks, no-undef, no-implicit-globals */ 18 | 19 | if (window.top == window.self) 20 | { 21 | let currentPage = "" 22 | const intervals = [] 23 | 24 | 25 | // Load all NX Enhanced's settings 26 | getGMsettings() 27 | 28 | // Add some internal functions to the code 29 | extendFunctions() 30 | 31 | // Add some simple styles for a better UX 32 | const style = document.createElement("style") 33 | style.innerHTML = `.list-group-item:hover .btn { visibility: visible !important; } /* Allow/Deny/Hide buttons on hover */ 34 | .tooltipParent:hover .customTooltip { opacity: 1 !important; visibility: visible !important; } /* Show the tooltip when hovering it's container */ 35 | .tooltipParent .customTooltip:hover { opacity: 0 !important; visibility: hidden !important; } /* Hide the tooltip when it's hovered, as it should stay visible only when hovering the parent */ 36 | div:hover #counters { visibility: hidden !important; } /* Hide the log entries counters on hover */ 37 | .btn-light { background-color: #eee; } /* Make the btn-light more visible without affecting the hover */ 38 | .list-group-item div div:hover input.description, input.description:focus { visibility: visible !important; } /* Show the allow/denylist domains description input box on hover, and when the input is focused */ 39 | ` 40 | document.head.appendChild(style) 41 | 42 | window.addEventListener("message", function check(e) // In some cases, such as when using Chrome, for some reason, the API frame has some delay to finish loading, which breaks the script when a request is made before 43 | { // it's finished. So this makes it so that when the frame finishes loading, it sends a message to the top window to make it known and allow the script to continue. 44 | if (e.data == "frame ready") 45 | { 46 | frameReady = true 47 | this.removeEventListener("message", check) 48 | } 49 | }) 50 | 51 | window.addEventListener("message", function(e) { // Run the event listener callback including the response when the HTTP request is completed. Here, PostMessage 52 | if (e.data.callback != "") // is only necessary for asynchronous requests, as it would be pretty hacky to do this in any other way, and 53 | dispatchEvent(new CustomEvent(e.data.callback, {detail: e.data.response})) // asynchronous requests here are only necessary for slow connections and for multiple simultaneous requests. 54 | }, true) 55 | 56 | 57 | const ApiFrame = document.createElement("object") // Here an OBJECT element is being used instead of an IFRAME because of a bug in GreaseMonkey that makes it not run inside IFRAMEs, but it runs fine inside EMBEDs and OBJECTs. 58 | ApiFrame.data = "https://api.nextdns.io/configurations/" 59 | document.body.appendChild(ApiFrame) 60 | ApiFrame.style.display = "none" // The frame needs to be hidden *after* the append, otherwise Chrome won't load it. In Firefox it works fine. 61 | 62 | function sleep(ms) 63 | { 64 | return new Promise(resolve => setTimeout(resolve, ms)); 65 | } 66 | 67 | function main() 68 | { 69 | setIntervalOld(function() 70 | { 71 | if (currentPage == location.href) 72 | return 73 | 74 | currentPage = location.href 75 | 76 | clearAllIntervals() 77 | 78 | 79 | // ---------------------------- Logs page --------------------------- 80 | 81 | 82 | if (/\/logs/i.test(location.href)) 83 | { 84 | let hideDevices = false, filtering = true 85 | let loadingChunk = false, cancelLoading = false 86 | let logsContainer, allowDenyPopup, existingEntries, updateRelativeTimeInterval 87 | let visibleEntriesCountEll, filteredEntriesCountEll, allHiddenEntriesCountEll, loadedEntriesCountEll 88 | let lastBefore, lastAfter = 1, currentDeviceId, searchString, blockedQueriesOnly, simpleLogs = 1 89 | const dateTimeFormatter = new Intl.DateTimeFormat('default', { weekday: "long", month: "long", day: "numeric", year: "numeric", hour: "numeric", minute: "numeric", second: "numeric" }); 90 | 91 | const waitForItems = setInterval(function() 92 | { 93 | const pageContentContainer = document.getElementById("root").secondChild() 94 | 95 | if (!pageContentContainer) 96 | return 97 | 98 | logsContainer = pageContentContainer.getByClass("list-group") 99 | 100 | if (!logsContainer) 101 | return 102 | 103 | const svgs = logsContainer.querySelectorAll(".settings-button svg, .stream-button svg") 104 | 105 | if (svgs.length < 2) // Wait for the SVGs to finish loading before overriding, otherwise they fail to load and leave a blank space. 106 | return 107 | 108 | let searchBarForm = logsContainer.querySelector("form") 109 | 110 | if (!searchBarForm || !logsContainer.querySelector("div.text-center")) // Wait for the search bar and the first log row to finish loading before overriding. 111 | return 112 | 113 | 114 | clearInterval(waitForItems) 115 | 116 | 117 | pageContentContainer.firstChild.outerHTML += "" // Override the content container code (reparse) to remove all events attached to it, so it can be used statically (without interferences from other scripts). 118 | 119 | logsContainer = pageContentContainer.getByClass("list-group") // Update the "pointer" to the new instance. 120 | 121 | existingEntries = logsContainer.getElementsByClassName("list-group-item") 122 | 123 | 124 | // Setup the devices dropdown 125 | { 126 | const waitForDropdown = setInterval(function() 127 | { 128 | let devicesDropdown = pageContentContainer.getByClass("dropdown") 129 | 130 | if (!devicesDropdown) 131 | return 132 | 133 | clearInterval(waitForDropdown) 134 | 135 | let customDevicesDropdown = devicesDropdown.parentElement 136 | 137 | customDevicesDropdown = customDevicesDropdown.firstChild 138 | customDevicesDropdown.id = "customDevicesDropdown" 139 | customDevicesDropdown.firstChild.disabled = false 140 | customDevicesDropdown.firstChild.style.pointerEvents = "initial" 141 | customDevicesDropdown.onclick = function() 142 | { 143 | const classes = this.lastChild.classList 144 | 145 | if (!classes.contains("show")) 146 | classes.add("show") 147 | else classes.remove("show") 148 | } 149 | 150 | const devicesDropdownMenu = document.createElement("div") 151 | devicesDropdownMenu.className = "dropdown-menu" 152 | customDevicesDropdown.appendChild(devicesDropdownMenu) 153 | 154 | 155 | // Get the devices IDs to use for loading the log entries of specific devices. In NextDNS, each device has a randomly 156 | // generated ID (just like the config ID), and this ID is used as parameter to request the log entries of specific devices. 157 | // NX Enhanced loads the log entries by itself instead of letting the site load them, because this way NX Enhanced has access to the log entries' raw data instead of having to depend on what the page 158 | // actually shows, which can change anytime and require constant adaptations for every layout or code change. Also, this makes it possible to implement other features to the logs that require such access. 159 | 160 | const requestString = "analytics/top_devices?selector=true" 161 | makeApiRequestAndAddEvent("GET", requestString, function(e) 162 | { 163 | const devicesData = JSON.parse(e.detail) 164 | 165 | for (let i=0; i < devicesData.length + 1; i++) 166 | { 167 | const deviceCustom = document.createElement("button") 168 | deviceCustom.className = i == 0 ? "dropdown-item active" : "dropdown-item" 169 | deviceCustom.textContent = i == 0 ? "All devices" : devicesData[i-1].name 170 | deviceCustom.onclick = function() 171 | { 172 | const index = Array.from(this.parentElement.children).indexOf(this) // Get the index of this dropdown item. 173 | 174 | cancelLoading = true // Indicates that the chunk currently being loaded should be interrupted. 175 | 176 | if (index == 0) // If it's the "All devices" item. 177 | { 178 | currentDeviceId = "" 179 | reloadLogs() 180 | } 181 | else // If instead it's a specific device. 182 | { 183 | currentDeviceId = devicesData[index-1].id 184 | loadLogChunk({device: currentDeviceId, clear: true}) 185 | } 186 | 187 | 188 | // Update the current device selected 189 | 190 | this.parentElement.querySelector(".active").classList.remove("active") 191 | this.classList.add("active") 192 | 193 | this.parentElement.previousSibling.textContent = this.textContent 194 | 195 | hideDevices = false 196 | allHiddenEntriesCountEll.parentElement.style.display = "none" 197 | } 198 | 199 | devicesDropdownMenu.appendChild(deviceCustom) 200 | } 201 | 202 | // Create the "Other devices" button 203 | 204 | const otherDevicesBtn = document.createElement("button") 205 | otherDevicesBtn.className = "dropdown-item" 206 | otherDevicesBtn.id = "otherDevicesBtn" 207 | otherDevicesBtn.style = "border-top: 1px solid lightgray;" // Separator 208 | otherDevicesBtn.innerHTML = "Other devices" 209 | otherDevicesBtn.onclick = function() 210 | { 211 | this.parentElement.firstChild.click() // Click the "All devices" button. Use the full log to filter the devices 212 | 213 | customDevicesDropdown.firstChild.innerHTML = "Other devices" 214 | this.parentElement.querySelector(".active").classList.remove("active") 215 | this.classList.add("active") 216 | hideDevices = true 217 | allHiddenEntriesCountEll.parentElement.style.display = "initial" 218 | } 219 | 220 | devicesDropdownMenu.appendChild(otherDevicesBtn) 221 | 222 | }) 223 | 224 | 225 | 226 | }, 100) 227 | 228 | } 229 | 230 | 231 | // Setup the filtering's buttons and inputs 232 | { 233 | // Create the "Filters" button 234 | { 235 | const filtersButton = document.createElement("button") 236 | filtersButton.id = "filtersButton" 237 | filtersButton.className = "btn btn-secondary" 238 | filtersButton.style = "align-self: center; margin-right: 15px;" 239 | filtersButton.innerHTML = "Filters" 240 | filtersButton.onclick = function() 241 | { 242 | event.stopPropagation() 243 | 244 | if (this.className.includes("secondary")) 245 | { 246 | filteringOptionsContainer.style.visibility = "visible" 247 | this.innerHTML = "OK" 248 | this.className = this.className.replace("secondary", "primary") 249 | } 250 | else // If it's clicked the second time. 251 | { 252 | updateFilters() 253 | filteringOptionsContainer.style.visibility = "hidden" 254 | this.innerHTML = "Filters" 255 | this.className = this.className.replace("primary", "secondary") 256 | } 257 | } 258 | 259 | var container = pageContentContainer.firstChild.firstChild 260 | container.appendChild(filtersButton) 261 | } 262 | 263 | 264 | // Create the filtering options 265 | { 266 | var filteringOptionsContainer = document.createElement("div") 267 | filteringOptionsContainer.style = "position: absolute; right: 60px; top: 145px; visibility: hidden; display: grid; grid-gap: 10px;" 268 | filteringOptionsContainer.onclick = function() { event.stopPropagation() } 269 | 270 | 271 | // Create the "Enable filtering" switch 272 | { 273 | const enableFilteringSwitch = createSwitchCheckbox("Enable filtering") 274 | enableFilteringSwitch.style.marginLeft = "-7px" 275 | enableFilteringSwitch.firstChild.checked = true 276 | enableFilteringSwitch.firstChild.onchange = function() 277 | { 278 | filtering = this.checked 279 | 280 | if (filtering) 281 | refilterLogEntries() 282 | else 283 | reloadLogs() 284 | } 285 | 286 | filteringOptionsContainer.appendChild(enableFilteringSwitch) 287 | } 288 | 289 | 290 | // Create the filter's inputbox 291 | { 292 | var domainsToHideInput = document.createElement("textarea") 293 | domainsToHideInput.id = "domainsToHideInput" 294 | domainsToHideInput.spellcheck = false 295 | domainsToHideInput.value = domainsToHide.join("\n") 296 | domainsToHideInput.style = "width: 320px; height: 240px; min-width: 250px; min-height: 100px; border-radius: 15px; resize: both; padding-top: 5px;\ 297 | border: 1px groove lightgray; outline: 0px; padding-left: 10px; padding-right: 5px; overflow-wrap: normal;" 298 | 299 | filteringOptionsContainer.appendChild(domainsToHideInput) 300 | } 301 | 302 | 303 | // Create the "Show number of entries" switch 304 | { 305 | const showNumEntriesSwitch = createSwitchCheckbox("Show number of entries") 306 | showNumEntriesSwitch.firstChild.checked = GMsettings.LogsOptions.ShowCounters 307 | showNumEntriesSwitch.firstChild.onchange = function() 308 | { 309 | visibleEntriesCountEll.parentElement.style.visibility = this.checked ? "visible" : "hidden" 310 | GMsettings.LogsOptions.ShowCounters = this.checked 311 | GM.setValue("LogsOptions", JSON.stringify(GMsettings.LogsOptions)) 312 | } 313 | 314 | filteringOptionsContainer.appendChild(showNumEntriesSwitch) 315 | } 316 | 317 | container.appendChild(filteringOptionsContainer) 318 | } 319 | 320 | 321 | function updateFilters() 322 | { 323 | GM.setValue("domainsToHide", domainsToHideInput.value) 324 | domainsToHide = domainsToHideInput.value.split("\n").filter(d => d.trim() != "") // Store each entry in an array, but don't include empty lines 325 | } 326 | } 327 | 328 | 329 | // Create the refresh button 330 | { 331 | const refreshButton = document.createElement("button") 332 | refreshButton.className = "btn btn-primary" 333 | refreshButton.style = "font-size: x-large; padding: 0px 2px; height: 25px; margin-right: 10px; margin-top: 3px;" 334 | refreshButton.onclick = function() { reloadLogs() } 335 | 336 | const icon = document.createElement("div") 337 | icon.style = "margin-top: -9px;" 338 | icon.innerHTML = "⟲" 339 | 340 | refreshButton.appendChild(icon) 341 | 342 | const inputContainer = logsContainer.firstChild.firstChild 343 | inputContainer.insertBefore(refreshButton, inputContainer.firstChild) 344 | } 345 | 346 | 347 | // Create the allow/deny popup 348 | { 349 | const elementsContainer = document.createElement("div") 350 | elementsContainer.onclick = function() { event.stopPropagation() } // Prevent the popup from being hidden when clicking inside it 351 | elementsContainer.style = "background: #f7f7f7; position: absolute; right: 130px; height: max-content; width: max-content; \ 352 | border: 2px solid lightgray; border-radius: 15px; z-index: 99; padding: 5px 15px 15px 15px; visibility: hidden;" 353 | 354 | const errorMsgSpan = document.createElement("span") 355 | errorMsgSpan.style = "display: block; min-height: 25px; line-height: 20px; margin-top: 0px;" 356 | errorMsgSpan.className = "ml-1 my-1 invalid-feedback" 357 | 358 | const input = document.createElement("input") 359 | input.style = "border-radius: 5px; width: 300px; padding: 5px;" 360 | input.className = "form-control mb-3" 361 | input.onkeyup = function() 362 | { 363 | if (event.key == "Enter") allowDenyPopup.fullDomainButton.click() 364 | else if (event.key == "Escape") allowDenyPopup.container.style.cssText += 'visibility: hidden;' 365 | } 366 | input.oninput = function() 367 | { 368 | this.classList.remove("is-invalid") 369 | this.previousSibling.innerHTML = "" 370 | } 371 | 372 | const fullDomainButton = document.createElement("button") 373 | fullDomainButton.onclick = function() 374 | { 375 | allowDenyPopup.errorMsg.classList.remove("invalid-feedback") 376 | 377 | if (allowDenyPopup.listName != "Hide") 378 | { 379 | allowDenyPopup.errorMsg.innerHTML = "Submitting..." 380 | 381 | // In NextDNS site, domains, TLDs, blocklists, and pretty much anything added by clicking an "Add" button, are added by sending these items' id with 382 | // each character converted to hexadecimal, instead of plain text (ASCII). This converts the specified domain to hex then sends it to the respective list. 383 | const requestString = allowDenyPopup.listName + "/hex:" + convertToHex(allowDenyPopup.input.value) 384 | 385 | makeApiRequestAndAddEvent("PUT", requestString, function(e) // Make an asynchronous HTTP request and run this callback when finished 386 | { 387 | if (e.detail.includes(allowDenyPopup.input.value)) // After successfully adding the domain to the allow/denylist, NextDNS responds with the domain added and it's active status. 388 | { // This checks if it was successful. 389 | allowDenyPopup.errorMsg.innerHTML = 'Done!' 390 | 391 | // Auto dismiss the popup after 1 second 392 | setTimeout(function() { 393 | allowDenyPopup.container.style.cssText += 'visibility: hidden; top: 0px' // Top 0px, because otherwise it stays stuck with a top value greater than the body height when the log is refreshed 394 | allowDenyPopup.errorMsg.innerHTML = '' 395 | }, 1000) 396 | 397 | // Update the cached list of domains from the allow/denylist 398 | makeApiRequestAndAddEvent("GET", allowDenyPopup.listName, function(e) { 399 | allowDenyPopup.domainsList[allowDenyPopup.listName] = e.detail 400 | }) 401 | } 402 | else if (e.detail.includes("error")) // If it wasn't successful, get the error from the response and show the respective message above the popup's input box 403 | { 404 | let error = JSON.parse(e.detail).error 405 | 406 | if (error.includes("exist")) 407 | error = "This domain has already been added" 408 | else if (error.includes("invalid")) 409 | error = "Please enter a valid domain" 410 | 411 | allowDenyPopup.errorMsg.textContent = error 412 | allowDenyPopup.errorMsg.classList.add("invalid-feedback") 413 | allowDenyPopup.input.classList.add("is-invalid") 414 | } 415 | 416 | }) 417 | 418 | } 419 | else 420 | { 421 | document.getElementById("domainsToHideInput").value += "\n" + allowDenyPopup.input.value 422 | updateFilters() 423 | allowDenyPopup.errorMsg.innerHTML = 'Done!' 424 | refilterLogEntries() 425 | 426 | setTimeout(function() { 427 | allowDenyPopup.container.style.cssText += 'visibility: hidden;' 428 | }, 1000) 429 | } 430 | } 431 | 432 | const rootDomainButton = document.createElement("button") 433 | rootDomainButton.style = "width: 127px; float: right;" 434 | rootDomainButton.onclick = function() 435 | { 436 | let input = allowDenyPopup.input 437 | input.value = this.title.substring(this.title.indexOf("*") + 2) // Instead of parsing the root domain again, get it from the title set by the Allow/Deny/Hide buttons 438 | 439 | if (allowDenyPopup.listName == "Hide") 440 | input.value = "." + input.value // Add a dot before the root domain to prevent false positives. 441 | 442 | allowDenyPopup.fullDomainButton.click() 443 | } 444 | 445 | elementsContainer.appendChild(errorMsgSpan) 446 | elementsContainer.appendChild(input) 447 | elementsContainer.appendChild(fullDomainButton) 448 | elementsContainer.appendChild(rootDomainButton) 449 | 450 | logsContainer.parentElement.appendChild(elementsContainer) 451 | 452 | // Add all these elements in an object for easy access 453 | allowDenyPopup = { 454 | parent: logsContainer.parentElement, 455 | container: elementsContainer, 456 | errorMsg: errorMsgSpan, 457 | input: input, 458 | fullDomainButton: fullDomainButton, 459 | rootDomainButton: rootDomainButton, 460 | listName: "", 461 | domainsList: { 462 | allowlist: "", 463 | denylist: "" 464 | } 465 | } 466 | 467 | 468 | // Cache the list of domains in the allowlist, then cache the list of domains in the denylist 469 | 470 | makeApiRequestAndAddEvent("GET", "allowlist", function(e) 471 | { 472 | allowDenyPopup.domainsList.allowlist = e.detail; 473 | makeApiRequestAndAddEvent("GET", "denylist", function(e) { allowDenyPopup.domainsList.denylist = e.detail }) 474 | }) 475 | 476 | } 477 | 478 | 479 | // Create the entries countings 480 | { 481 | if (!document.getElementById("visibleEntriesCount")) 482 | { 483 | const countingsContainer = document.createElement("div") 484 | countingsContainer.id = "counters" 485 | countingsContainer.style = "text-align: right; border: solid 2px #aaa; border-radius: 10px; padding: 0px 10px 5px; width: 160px; background: white;" 486 | countingsContainer.style.visibility = GMsettings.LogsOptions.ShowCounters ? "visible" : "hidden" 487 | countingsContainer.innerHTML = ` 488 | Queries count 489 | Listed:
490 | Filtered:
491 | All Hidden:
492 | Loaded: 493 | ` 494 | 495 | const hoverContainer = document.createElement("div") 496 | hoverContainer.style = "position: fixed; bottom: 20px; right: 11.5%;" 497 | hoverContainer.appendChild(countingsContainer) 498 | 499 | document.body.appendChild(hoverContainer) 500 | 501 | visibleEntriesCountEll = document.getElementById("visibleEntriesCount") 502 | filteredEntriesCountEll = document.getElementById("filteredEntriesCount") // Entries filtered by the domains filters 503 | allHiddenEntriesCountEll = document.getElementById("allHiddenEntriesCount") // Entries from named devices 504 | loadedEntriesCountEll = document.getElementById("loadedEntriesCount") // All the entries of the loaded chunks 505 | } 506 | } 507 | 508 | 509 | // Hide popups and dropdowns when the body is clicked 510 | 511 | document.body.onclick = function() 512 | { 513 | if (/\/logs/i.test(location.href)) 514 | { 515 | // Hide the allow/deny popup 516 | allowDenyPopup.container.style.cssText += 'visibility: hidden; top: 0px' 517 | 518 | // Hide the devices dropdown 519 | 520 | const customDevicesDropdown = document.getElementById("customDevicesDropdown") 521 | 522 | if (customDevicesDropdown != null && event.target != customDevicesDropdown.firstChild) 523 | customDevicesDropdown.lastChild.classList.remove("show") 524 | 525 | // Collapse the filtering options 526 | 527 | const filtersButton = document.getElementById("filtersButton") 528 | 529 | if (filtersButton && !filtersButton.className.includes("secondary")) 530 | filtersButton.click() 531 | } 532 | } 533 | 534 | 535 | 536 | // Disable the original trigger that loads the next log chunk and add NXE's trigger 537 | 538 | addEventListener("scroll", function(e) 539 | { 540 | e.stopPropagation() // Don't let the original event listener receive this event 541 | 542 | if (!cancelLoading && document.body.getBoundingClientRect().bottom < window.innerHeight * 3) // Only load the next chunk if the user is three screens above the page bottom. 543 | loadLogChunk({before: lastBefore}) // This big distance makes scrolling the logs much more fluid, because when you 544 | // reach the bottom, the next chunk is already loaded and you don't need to wait. 545 | }, true) 546 | 547 | 548 | // Make the search bar use NXE's code 549 | { 550 | const searchBarForm = logsContainer.querySelector("form") 551 | searchBarForm.outerHTML = searchBarForm.firstChild.outerHTML // Take out the input from inside the form and remove the form element, so that when hitting 552 | // Enter, it doesn't reload the page. This also stripes all the original events of the input box. 553 | var searchBar = logsContainer.querySelector("[type='search']") 554 | searchBar.onkeyup = function(e) 555 | { 556 | if (e.key == "Enter") 557 | { 558 | searchString = this.value.split(" ")[0] // Take only the part before a space character to make the request. This is in preparation for the feature of excluding entries from a search 559 | reloadLogs() 560 | } 561 | else clearSearchButton.style.display = this.value == "" ? "none" : "block" // Hide the clear button when the input box is empty, and display it otherwise. 562 | } 563 | 564 | 565 | 566 | // Recreate the clear button for the search bar, as it's created and deleted in the original code, instead of just hidden 567 | { 568 | var clearSearchButton = document.createElement("div") 569 | clearSearchButton.innerHTML = "X" 570 | clearSearchButton.style = "position: absolute; right: 10px; top: 8px; width: 15px; height: 15px; text-align: center; line-height: 13px; color: white; \ 571 | border-radius: 15px; font-size: 10px; user-select: none; cursor: pointer; background: #bbb; font-weight: bold; display: none;" 572 | clearSearchButton.onclick = function() 573 | { 574 | if (searchBar.value != "") 575 | { 576 | searchString = searchBar.value = "" 577 | this.style.display = "none" 578 | reloadLogs() 579 | } 580 | } 581 | searchBar.parentElement.appendChild(clearSearchButton) 582 | } 583 | } 584 | 585 | 586 | // Adapt the options button 587 | { 588 | const settingsButton = logsContainer.getByClass("settings-button") 589 | settingsButton.onclick = function() 590 | { 591 | if (optionsContainer.style.display == "none") 592 | { 593 | optionsContainer.style.cssText += "display: flex !important;" 594 | settingsButton.classList.add("active") 595 | } 596 | else 597 | { 598 | optionsContainer.style.cssText += "display: none !important;" 599 | settingsButton.classList.remove("active") 600 | } 601 | 602 | } 603 | 604 | const optionsContainer = document.createElement("div") 605 | optionsContainer.className = "d-md-flex mt-3" 606 | optionsContainer.style = "display: none !important; color: #777;" 607 | 608 | // Adapt the "Blocked Queries Only" switch 609 | { 610 | optionsContainer.appendChild(createSwitchCheckbox("Blocked Queries Only")) 611 | optionsContainer.firstChild.classList.add("mr-5") 612 | optionsContainer.firstChild.lastChild.style.fontSize = "80%" 613 | optionsContainer.firstChild.firstChild.onchange = function() 614 | { 615 | blockedQueriesOnly = +this.checked 616 | reloadLogs() 617 | } 618 | } 619 | 620 | // Adapt the "Raw DNS logs" switch 621 | { 622 | optionsContainer.appendChild(createSwitchCheckbox("Raw DNS logs")) 623 | optionsContainer.lastChild.lastChild.style.fontSize = "80%" 624 | optionsContainer.lastChild.firstChild.onchange = function() 625 | { 626 | simpleLogs = +!this.checked 627 | reloadLogs() 628 | } 629 | } 630 | 631 | 632 | settingsButton.parentElement.parentElement.parentElement.appendChild(optionsContainer) 633 | } 634 | 635 | 636 | // Adapt the "Stream" button (real-time log) 637 | { 638 | const streamButton = logsContainer.getByClass("stream-button") 639 | streamButton.onclick = function() 640 | { 641 | if (streamButton.classList.contains("streaming")) 642 | { 643 | streamButton.classList.remove("streaming") 644 | 645 | clearInterval(realTimeLogsPolling) 646 | } 647 | else 648 | { 649 | streamButton.classList.add("streaming") 650 | 651 | realTimeLogsPolling = setInterval(function() // Poll for new log entries each 2 seconds. 652 | { 653 | if (!loadingChunk) 654 | loadLogChunk({after: lastAfter}) 655 | 656 | }, 2000) 657 | } 658 | } 659 | } 660 | 661 | 662 | // Remove leftover of the original logs container 663 | logsContainer.querySelector("div.text-center").remove() 664 | 665 | 666 | 667 | // And finally start loading the logs 668 | reloadLogs() 669 | 670 | 671 | }, 250) 672 | 673 | 674 | 675 | function openAllowDenyPopup(button) 676 | { 677 | const domainContainer = button.parentElement.parentElement.firstChild 678 | const fullDomain = domainContainer.secondChild().textContent 679 | let upperDomain = 680 | allowDenyPopup.input.value = fullDomain 681 | 682 | allowDenyPopup.errorMsg.classList.remove("invalid-feedback") 683 | allowDenyPopup.input.classList.remove("is-invalid") 684 | allowDenyPopup.errorMsg.innerHTML = "" 685 | 686 | if (button.innerText != "Hide") 687 | { 688 | allowDenyPopup.listName = button.innerText.toLowerCase() + "list" 689 | 690 | // Check if there's already an upper domain entry that includes the chosen subdomain 691 | 692 | while (upperDomain.indexOf(".") >= 0) // As long as there's a dot... 693 | { 694 | upperDomain = upperDomain.substring(upperDomain.indexOf(".") + 1) // ... get the domain after the next dot. 695 | 696 | if (allowDenyPopup.domainsList[allowDenyPopup.listName].includes('"' + upperDomain + '"')) // If there's an entry which is included in this upper domain, set a message 697 | { // to warn the user. Otherwise, check the next upper domain. 698 | allowDenyPopup.errorMsg.innerHTML = "This subdomain is already included in another entry!" 699 | allowDenyPopup.input.classList.add("is-invalid") 700 | allowDenyPopup.errorMsg.classList.add("invalid-feedback") 701 | 702 | break 703 | } 704 | } 705 | } 706 | else allowDenyPopup.listName = "Hide" 707 | 708 | const subdomains = allowDenyPopup.input.value.split(".") 709 | let rootDomain = subdomains[subdomains.length-2] 710 | 711 | if (SLDs.includes(rootDomain)) 712 | rootDomain = subdomains[subdomains.length-3] + "." + rootDomain 713 | 714 | rootDomain += "." + subdomains[subdomains.length-1] 715 | 716 | allowDenyPopup.rootDomainButton.title = button.innerText + " any subdomain under *." + rootDomain 717 | 718 | 719 | // Set the button's label and color according to the action 720 | 721 | allowDenyPopup.fullDomainButton.className = 722 | allowDenyPopup.rootDomainButton.className = button.innerText == "Allow" ? "btn btn-success mt-1" : button.innerText == "Deny" ? "btn btn-danger mt-1" : "btn btn-secondary mt-1" 723 | 724 | allowDenyPopup.fullDomainButton.textContent = button.innerText + " domain" 725 | allowDenyPopup.rootDomainButton.textContent = button.innerText + " root" 726 | 727 | allowDenyPopup.container.style.cssText += "visibility: visible; top: " + (button.getBoundingClientRect().y - allowDenyPopup.parent.getBoundingClientRect().y - 170) + "px;" // Show the popup right above the buttons 728 | allowDenyPopup.input.focus() 729 | event.stopPropagation() // Don't raise this event to the body, as the body hides the popup when clicked. 730 | 731 | } 732 | 733 | 734 | function loadLogChunk(params) 735 | { 736 | if (loadingChunk && !cancelLoading) // Load only one chunk at a time 737 | return 738 | else 739 | loadingChunk = true 740 | 741 | // Clear the logs 742 | { 743 | if (params.clear) 744 | { 745 | for (let i = existingEntries.length-1; i > 0; i--) 746 | existingEntries[i].remove() // If clear is true, then remove all the loaded custom log entries to load again 747 | 748 | visibleEntriesCountEll.textContent = filteredEntriesCountEll.textContent = allHiddenEntriesCountEll.textContent = loadedEntriesCountEll.textContent = 0 749 | } 750 | } 751 | 752 | // Build the request string 753 | { 754 | logsRequestString = "logs?" 755 | 756 | buildLogsRequestString("device", currentDeviceId) // The device id when loading the logs of specific devices. 757 | buildLogsRequestString("before", params.before) // NextDNS' logs always responds to a GET with the 100 most recent log entries. The "before" parameter indicates to NextDNS that it should do so with the log entries that happened before the specified timestamp. 758 | buildLogsRequestString("after", params.after) // The "after" parameter indicates to NextDNS that it should respond with the log entries that happened after the specified timestamp. Used by the stream button (real-time log). 759 | buildLogsRequestString("search", searchString) // The search string. Used by the search bar. 760 | buildLogsRequestString("simple", simpleLogs) // Used by the "Raw DNS logs" switch. 761 | buildLogsRequestString("blockedQueriesOnly", blockedQueriesOnly) // Used by the "Blocked queries only" switch. 762 | } 763 | 764 | // Recreate the spinner when loading. It has a different color than the original to indicate that it's being loaded by NXE 765 | { 766 | if (!params.after) // Don't show the spinner when the real-time log is enabled. 767 | { 768 | let spinner = logsContainer.getByClass("spinner-border") 769 | 770 | if (spinner) spinner.remove() 771 | 772 | spinner = document.createElement("span") 773 | spinner.className = "spinner-border text-primary my-4" 774 | spinner.style = "height: 50px; width: 50px; align-self: center;" 775 | logsContainer.appendChild(spinner) 776 | } 777 | } 778 | 779 | // Load the log entries data 780 | makeApiRequestAndAddEvent("GET", logsRequestString, function(e) 781 | { 782 | const response = JSON.parse(e.detail) 783 | const entriesData = response.logs 784 | 785 | if (entriesData.length > 0) 786 | { 787 | lastBefore = entriesData.lastItem().timestamp // Store the timestamp of the last entry, to load the older chunk starting from this timestamp. This timestamp is in Unix time. 788 | lastAfter = entriesData[0].timestamp // Store the timestamp of the first entry, to load the newer chunk starting from this timestamp. 789 | 790 | 791 | const now = Date.now() // Get the current date-time in Unix time 792 | 793 | // Process the chunk's log entries 794 | { 795 | for (let i=0; i < entriesData.length; i++) 796 | { 797 | // Cancel old responses when reloading 798 | { 799 | if (cancelLoading) 800 | { 801 | if (!params.clear) // params.clear and cancelLoading are only true when the logs are being reloaded. So if cancelLoading is true 802 | return // but params.clear is not, this means that this is the response of an old request and it should be canceled. 803 | else 804 | cancelLoading = false 805 | } 806 | } 807 | 808 | loadedEntriesCountEll.textContent++ // textContent is the actual content of the element, while innerText or innerHTML is the content currently being displayed 809 | 810 | // Check if the entry matches any filter, and if so, remove it from the list 811 | { 812 | var domainName = entriesData[i].name 813 | var isNamedDevice = !!entriesData[i].deviceName 814 | 815 | if ((filtering && !domainName.includes(".")) // Chrome's random queries never have a dot 816 | || (hideDevices && isNamedDevice) // If enabled, named devices 817 | || (filtering && domainsToHide.some(d => domainName.includes(d))) ) // If enabled, domains included in the list of domains to hide 818 | { 819 | entriesData.splice(i,1) 820 | i-- 821 | 822 | if (!hideDevices || hideDevices && !isNamedDevice) 823 | filteredEntriesCountEll.textContent++ 824 | 825 | allHiddenEntriesCountEll.textContent++ 826 | 827 | continue 828 | } 829 | } 830 | 831 | 832 | // Otherwise, create all the entry's elements 833 | { 834 | const status = entriesData[i].status == 3 ? "whitelisted" : entriesData[i].status == 2 ? "blocked" : "default" 835 | 836 | const entryContainer = document.createElement("div") 837 | entryContainer.className = "log list-group-item" 838 | entryContainer.style = "display: flex; justify-content: space-between; align-items: center; border-left: 4px solid;" 839 | entryContainer.style.borderLeftColor = status == "whitelisted" ? "limegreen" : status == "blocked" ? "orangered" : "transparent" 840 | 841 | 842 | // Create the elements of the left side of the log entry 843 | { 844 | const leftSideContainer = document.createElement("div") 845 | 846 | // Create the domain's favicon element 847 | { 848 | const imgEll = document.createElement("img") 849 | imgEll.src = "https://favicons.nextdns.io/hex:" + convertToHex(domainName) + "@1x.png" // NextDNS stores in their server every domain's favicon, and the image files are named after 850 | imgEll.className = "mr-2" // the domain's hex and the favicon's size, being 1x the smallest size and 3x the biggest. 851 | imgEll.style.marginTop = "-2px" 852 | imgEll.onerror = function() 853 | { 854 | // Gray globe icon. This happens when either NextDNS doesn't have the domain's favicon, then responding with a 404 "Not found" error, or the domain itself doesn't have a favicon at all. 855 | this.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAxElEQVR42n3RO0vEYBCF4Sfb2iV/QBAW1s52izWQLoKsYMBAilg\ 856 | qfLBk/j9Y5L6FM9XwHuZyhjkKpV6S9EqFXRxUBqFTe/MrDCqHFTdCKwcPbkIIzSyphIsMnHxMOIRqnD1oJ/y0gSEMCkoxNef1ThBKet2y7KOzs6+NoCep9yc5\ 857 | LvhHIi1l0nkGLz5dndQS/d3U46ZXpx+X3OZ1wfm4ZGHYCZoJZ9rxzNGoMdfIXGajZqu3gly7tXp91rd3te7+Wf+++w9XTTyOUFyhzgAAAABJRU5ErkJggg==" 858 | } 859 | leftSideContainer.appendChild(imgEll) 860 | } 861 | 862 | // Create the domain name element 863 | { 864 | const domainEll = document.createElement("span") 865 | domainEll.className = "domainName" 866 | domainEll.innerHTML = "" + domainName.substring(0, entriesData[i].rootDomainStartIndex) + "" 867 | + domainName.substring(entriesData[i].rootDomainStartIndex) // NextDNS stores at which character starts the root domain name, 868 | // so everything before rootDomainStartIndex is a subdomain 869 | leftSideContainer.appendChild(domainEll) 870 | } 871 | 872 | // Create the DNSSEC icon 873 | { 874 | if (entriesData[i].dnssec) 875 | { 876 | const DnsSecIconSrc = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAMCAYAAABbayygAAAAgklEQVQYlWP4jwSm7Lr736H10H+H1kP/fXqO/l998glcjgHGmLzrzn+H1kP/vXuO/s9\ 877 | ffPG/d8/R/w6th/4fvvkGVSFM0efvv////////+0XX/47tB76n7/4IkLh+QcfUASRNTu0HiJR4fxDD+AC+DBD/uKLw0fh9osv/ucvvkgQAwBXBF9KK3QiTQAAAABJRU5ErkJggg==" 878 | 879 | const DnsSecContainer = createStylizedTooltipWithImgParent(DnsSecIconSrc, "Validated with DNSSEC") 880 | DnsSecContainer.style.marginLeft = "10px" 881 | 882 | leftSideContainer.appendChild(DnsSecContainer) 883 | } 884 | } 885 | 886 | // Create the query type element 887 | { 888 | if (entriesData[i].type) 889 | { 890 | const queryTypeEll = document.createElement("span") 891 | queryTypeEll.style = "font-weight: 600; font-size: 10px; opacity: 0.4; margin-left: 10px; background: #eee; padding: 1px 4px;" 892 | queryTypeEll.innerText = entriesData[i].type 893 | 894 | leftSideContainer.appendChild(queryTypeEll) 895 | } 896 | } 897 | 898 | // Create the block/allow reason icon and tooltip 899 | { 900 | if (status != "default") 901 | { 902 | const blockReasonIcon = document.createElement("div") 903 | blockReasonIcon.innerHTML = "i" 904 | blockReasonIcon.style = "display: inline-block; border-radius: 12px; width: 13px; height: 13px; text-align: center; color: white; \ 905 | font-weight: bold; font-family: serif; font-size: 11px; user-select: none; line-height: 13px; margin-left: 10px;" 906 | blockReasonIcon.style.background = entryContainer.style.borderLeftColor 907 | 908 | // matchedName is the CNAME that got blocked. lists is an array containing the name of each list that includes this domain 909 | const blockReasonText = (entriesData[i].matchedName ? "→ " + entriesData[i].matchedName + "": "") 910 | + (status == "whitelisted" ? "Allowed" : "Blocked") + " by " + entriesData[i].lists.join(", ") 911 | blockReasonIcon.createStylizedTooltip(blockReasonText) 912 | 913 | leftSideContainer.appendChild(blockReasonIcon) 914 | } 915 | } 916 | 917 | entryContainer.appendChild(leftSideContainer) 918 | 919 | } 920 | 921 | 922 | // Create the Hide/Allow/Deny buttons 923 | { 924 | const buttonsContainer = document.createElement("div") 925 | buttonsContainer.style = "visibility: hidden; margin-right: 25px; margin-left: auto;" 926 | 927 | const hideButton = document.createElement("button") 928 | hideButton.className = "btn btn-secondary mr-4" 929 | hideButton.innerHTML = "Hide" 930 | hideButton.onclick = function() { openAllowDenyPopup(hideButton) } 931 | 932 | buttonsContainer.appendChild(hideButton) 933 | 934 | if (status == "default") 935 | { 936 | const denyButton = document.createElement("button") 937 | denyButton.className = "btn btn-danger mr-4" 938 | denyButton.innerHTML = "Deny" 939 | denyButton.onclick = function() { openAllowDenyPopup(denyButton) } 940 | 941 | buttonsContainer.appendChild(denyButton) 942 | } 943 | 944 | if (status != "whitelisted") 945 | { 946 | const allowButton = document.createElement("button") 947 | allowButton.className = "btn btn-success" 948 | allowButton.innerHTML = "Allow" 949 | allowButton.onclick = function() { openAllowDenyPopup(allowButton) } 950 | 951 | buttonsContainer.appendChild(allowButton) 952 | } 953 | 954 | entryContainer.appendChild(buttonsContainer) 955 | } 956 | 957 | 958 | // Create the elements of the right side of the log entry 959 | { 960 | const rightSideContainer = document.createElement("div") 961 | rightSideContainer.style = "font-size: 0.9em; display: grid;" 962 | 963 | // Create the device name element 964 | { 965 | const deviceEll = document.createElement("span") 966 | deviceEll.textContent = entriesData[i].deviceName 967 | deviceEll.style = "height: 15px; margin-bottom: 10px; margin-left: auto;" 968 | 969 | if (!isNamedDevice) // If the query was made from an unnamed device, then show the gray empty space 970 | { 971 | deviceEll.innerHTML = " " 972 | deviceEll.style.cssText += "background-color: #eee; width: 90px; margin-bottom: 5px; margin-top: 5px;" 973 | } 974 | 975 | if (entriesData[i].isEncryptedDNS) 976 | { 977 | // Create the DoH/DoT padlock icon 978 | 979 | const encryptedQueryIconSrc = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAALCAYAAABGbhwYAAAAdUlEQVQYlcXPIQ7EMBADwLzWeP8QuqGm+UNC09csDQ51ybVqTjqp7CyZW\ 980 | COtNumTtZaO4xBJ9d4159Qz6UI5ZwG4a2aKiB221gRAtVZFhMYYAiCSO3R3AdhOfW/vIUmZmQColHL32kgqIpSeD/wqyXfQ3f8JT3fXMJ8Ei4pHAAAAAElFTkSuQmCC" 981 | 982 | const encryptedQueryContainer = createStylizedTooltipWithImgParent(encryptedQueryIconSrc, entriesData[i].protocol) 983 | encryptedQueryContainer.lastChild.style.fontSize = "0.9em" 984 | 985 | if (isNamedDevice) 986 | encryptedQueryContainer.style.marginRight = "5px" 987 | else 988 | { 989 | encryptedQueryContainer.style.marginLeft = "-15px" 990 | encryptedQueryContainer.firstChild.style.marginTop = "-4px" 991 | } 992 | 993 | 994 | deviceEll.insertBefore(encryptedQueryContainer, deviceEll.firstChild) 995 | } 996 | 997 | rightSideContainer.appendChild(deviceEll) 998 | } 999 | 1000 | // Create the date-time element. 1001 | { 1002 | const dateTimeEll = document.createElement("span") 1003 | dateTimeEll.style = "font-size: 0.8em; color: #bbb; min-width: 250px; text-align: end;" 1004 | dateTimeEll.setAttribute("time", entriesData[i].timestamp) 1005 | processTimestamp(entriesData[i].timestamp, now, dateTimeEll) 1006 | 1007 | rightSideContainer.appendChild(dateTimeEll) 1008 | } 1009 | 1010 | entryContainer.appendChild(rightSideContainer) 1011 | } 1012 | 1013 | if (params.after) 1014 | logsContainer.insertBefore(entryContainer, logsContainer.children[i+1]) 1015 | else 1016 | logsContainer.appendChild(entryContainer) 1017 | } 1018 | 1019 | 1020 | visibleEntriesCountEll.textContent++ 1021 | 1022 | 1023 | } 1024 | } 1025 | } 1026 | 1027 | if (entriesData.length == 0) // If NextDNS responds with an empty list or all entries were filtered, then show the "No logs yet" message. 1028 | { 1029 | if (!document.getElementById("noLogsSpan") && document.getElementsByClassName("log").length == 0) 1030 | { 1031 | const noLogsSpan = document.createElement("span") 1032 | noLogsSpan.id = "noLogsSpan" 1033 | noLogsSpan.innerHTML = "No logs yet." 1034 | noLogsSpan.style = "text-align: center; margin: 20px; color: #aaa;" 1035 | logsContainer.appendChild(noLogsSpan) 1036 | } 1037 | 1038 | cancelLoading = false 1039 | } 1040 | else 1041 | { 1042 | // Remove the "No logs" message 1043 | const noLogsSpan = document.getElementById("noLogsSpan") 1044 | if (noLogsSpan) noLogsSpan.remove() 1045 | } 1046 | 1047 | // Now that all entries were processed, the spinner can be removed. 1048 | const spinner = logsContainer.getByClass("spinner-border") 1049 | if (spinner) spinner.remove() 1050 | 1051 | loadingChunk = false 1052 | 1053 | if (!response.hasMore && !params.after) // For every chunk, NextDNS sets a property called hasMore, which indicates whether there are more log entries to load. 1054 | cancelLoading = true 1055 | 1056 | if (!cancelLoading && entriesData.length < 25 && document.body.getBoundingClientRect().bottom < window.innerHeight * 5 && !params.after) // Automatically load the next chunk when less than 25 entries of the chunk are listed. 1057 | { 1058 | loadLogChunk({before: lastBefore}) 1059 | 1060 | if (entriesData.length < 7 && document.body.getBoundingClientRect().bottom < window.innerHeight + 400) // 400 is the vertical space taken by 6 entries. 1061 | scrollTo(0, document.body.scrollHeight) // Automatically scroll to bottom when less than 7 entries of the chunk are listed. 1062 | } 1063 | 1064 | 1065 | }) 1066 | } 1067 | 1068 | function reloadLogs() 1069 | { 1070 | cancelLoading = true 1071 | loadLogChunk({clear: true}) 1072 | 1073 | if (typeof updateRelativeTimeInterval != "undefined") 1074 | clearInterval(updateRelativeTimeInterval) 1075 | 1076 | // Set an interval that updates the relative time of the log entries every 20 seconds. 1077 | updateRelativeTimeInterval = setInterval(function() 1078 | { 1079 | const now = Date.now() 1080 | const logEntries = logsContainer.querySelectorAll(".relativeTime") 1081 | 1082 | for (let i=0; i < logEntries.length; i++) 1083 | processTimestamp(+logEntries[i].getAttribute("time"), now, logEntries[i]) 1084 | 1085 | }, 20000) 1086 | } 1087 | 1088 | function refilterLogEntries() 1089 | { 1090 | const entries = logsContainer.getElementsByClassName("log") 1091 | allHiddenEntriesCountEll.textContent -= filteredEntriesCountEll.textContent 1092 | visibleEntriesCountEll.textContent = 0 1093 | 1094 | for (let i=0; i < entries.length; i++) 1095 | { 1096 | const domainName = entries[i].getByClass("domainName").textContent 1097 | 1098 | if (!domainName.includes(".") // Chrome's random queries 1099 | || domainsToHide.some(d => domainName.includes(d)) ) // Domains included in the list of domains to hide. 1100 | { 1101 | entries[i].remove() 1102 | i-- 1103 | filteredEntriesCountEll.textContent++ 1104 | allHiddenEntriesCountEll.textContent++ 1105 | } 1106 | else visibleEntriesCountEll.textContent++ 1107 | } 1108 | } 1109 | 1110 | function processTimestamp(timestamp, now, dateTimeElement) 1111 | { 1112 | const relativeSecs = (now - timestamp) / 1000 // Get the relative time in seconds. 1113 | if (relativeSecs > 1800) // If older than 30 minutes, show the full date-time. 1114 | { 1115 | dateTimeElement.textContent = dateTimeFormatter.format(new Date(timestamp)).replace(/(202\d) /, "$1, ") // Add a comma after the year if there isn't one. 1116 | dateTimeElement.classList.remove("relativeTime") 1117 | } 1118 | else // Otherwise, show the relative time 1119 | { 1120 | dateTimeElement.className = "relativeTime" 1121 | 1122 | if (relativeSecs < 10) 1123 | dateTimeElement.textContent = "a few seconds ago" 1124 | else if (relativeSecs < 60) 1125 | dateTimeElement.textContent = "some seconds ago" 1126 | else if (relativeSecs < 120) 1127 | dateTimeElement.textContent = "a minute ago" 1128 | else 1129 | dateTimeElement.textContent = parseInt(relativeSecs/60) + " minutes ago" 1130 | 1131 | dateTimeElement.innerHTML += "  (" + new Date(timestamp).toLocaleTimeString() + ")" 1132 | } 1133 | } 1134 | 1135 | function buildLogsRequestString(paramName, paramValue) 1136 | { 1137 | if (paramValue) 1138 | { 1139 | if (logsRequestString.includes("=")) 1140 | logsRequestString += "&" 1141 | 1142 | logsRequestString += paramName + "=" + paramValue 1143 | } 1144 | } 1145 | 1146 | 1147 | 1148 | // --------------------------- Privacy page --------------------------- 1149 | 1150 | 1151 | } 1152 | else if (/privacy$/.test(location.href)) 1153 | { 1154 | const waitForLists = setInterval(function() 1155 | { 1156 | if (document.querySelector(".list-group-item") != null) 1157 | { 1158 | clearInterval(waitForLists) 1159 | 1160 | // Hide list of blocklists and create the Show button and the collapse switch 1161 | hideAllListItemsAndCreateButton("Show added lists", "collapseBlocklists", GMsettings.collapseBlocklists) 1162 | 1163 | 1164 | // Sort blocklists alphabetically in the modal 1165 | 1166 | document.querySelector(".card-footer button").onclick = function() 1167 | { 1168 | const waitForListsModal = setInterval(function() 1169 | { 1170 | if (document.querySelector(".modal-body .list-group-item") != null) 1171 | { 1172 | clearInterval(waitForListsModal) 1173 | 1174 | const sortAZSwitch = createSwitchCheckbox("Sort A-Z") 1175 | sortAZSwitch.firstChild.checked = GMsettings.SortBlocklistsAZ 1176 | sortAZSwitch.style = "position: absolute; right: 100px; bottom: 15px;" 1177 | sortAZSwitch.firstChild.onchange = function() 1178 | { 1179 | sortItemsAZ(".modal-body .list-group") 1180 | GMsettings.SortBlocklistsAZ = this.checked 1181 | GM.setValue("SortBlocklistsAZ", this.checked) 1182 | } 1183 | 1184 | const container = document.querySelector(".modal-header") 1185 | container.style.position = "relative" 1186 | container.appendChild(sortAZSwitch) 1187 | 1188 | if (GMsettings.SortBlocklistsAZ) 1189 | sortItemsAZ(".modal-body .list-group") 1190 | 1191 | } 1192 | }, 100) 1193 | } 1194 | } 1195 | }, 500) 1196 | 1197 | 1198 | 1199 | // --------------------------- Security page --------------------------- 1200 | 1201 | 1202 | } 1203 | else if (/security$/.test(location.href)) 1204 | { 1205 | const waitForLists = setInterval(function() 1206 | { 1207 | if (document.querySelector(".list-group-item") != null) 1208 | { 1209 | clearInterval(waitForLists) 1210 | 1211 | // Hide list of TLDs and create the Show button and the collapse switch 1212 | hideAllListItemsAndCreateButton("Show added TLDs", "collapseTLDs", GMsettings.collapseTLDs) 1213 | 1214 | 1215 | // Create the "Add all TLDs" button in the modal 1216 | 1217 | document.querySelector(".card-footer button").onclick = function() 1218 | { 1219 | const waitForListsModal = setInterval(function() 1220 | { 1221 | if (document.querySelector(".modal-body .list-group-item") != null) 1222 | { 1223 | clearInterval(waitForListsModal) 1224 | 1225 | const addAll = document.createElement("button") 1226 | addAll.className = "btn btn-primary" 1227 | addAll.style = "position: absolute; right: 100px; bottom: 10px;" 1228 | addAll.innerHTML = "Add all TLDs" 1229 | addAll.onclick = function() 1230 | { 1231 | const modal = document.getByClass("modal-body") 1232 | const numTLDsToBeAdded = modal.getElementsByClassName("btn-primary").length 1233 | 1234 | if (numTLDsToBeAdded > 0) 1235 | { 1236 | if (confirm("This will add all TLDs to the block list. Are you sure?")) 1237 | { 1238 | createPleaseWaitModal("Adding all TLDs") 1239 | 1240 | // Process the TLDs 1241 | 1242 | const buttons = modal.getElementsByClassName("btn") // Here a getElementsByClassName is required instead of a querySelectorAll, as the former returns a list of 1243 | const buttonsClicked = [] // references, while the latter returns a list of static copies that are applied to the original when set. 1244 | let numTLDsAdded = 0 1245 | 1246 | const checkIfFinished = function() 1247 | { 1248 | if (numTLDsAdded == numTLDsToBeAdded) 1249 | { 1250 | setInterval(function() 1251 | { 1252 | for (let j=0; j < buttonsClicked.length; j++) 1253 | { // If the "Add" button changed to the "Remove" button, this means that the TLD was successfully added. 1254 | if (buttons[buttonsClicked[j]].classList.contains("btn-danger")) // It wouldn't be possible to get an updated classList if querySelectorAll was used. 1255 | buttonsClicked.splice(j,1) 1256 | } 1257 | 1258 | if (buttonsClicked.length == 0) 1259 | location.reload() 1260 | 1261 | }, 500) 1262 | } 1263 | } 1264 | 1265 | for (let i=0; i < buttons.length; i++) 1266 | { 1267 | const TLD = buttons[i].parentElement.previousSibling.textContent.replace(".","") 1268 | 1269 | if (buttons[i].classList.contains("btn-primary")) 1270 | { 1271 | sleep(800) 1272 | 1273 | if (!/[^\w]/.test(TLD)) // If there isn't a character in the TLD which is not a-z, then make the request normally. 1274 | { 1275 | makeApiRequestAndAddEvent("PUT", "security/blocked_tlds/hex:" + convertToHex(TLD), function() { numTLDsAdded++; checkIfFinished() }) 1276 | } 1277 | else // Otherwise, click on the button instead. This is because the hexed string in NextDNS for non-english characters comes from punycode (xn--abcde), 1278 | { // instead of from simple Unicode (\uhex), and I couldn't find any easy way of doing this conversion without using external libraries. 1279 | 1280 | buttons[i].click(); 1281 | buttonsClicked.push(i) // Store in an array the index of all buttons that were clicked, then check whether they finished adding 1282 | numTLDsAdded++ 1283 | 1284 | checkIfFinished() 1285 | } 1286 | } 1287 | } 1288 | } 1289 | } 1290 | else alert("All TLDs are already added.") 1291 | } 1292 | 1293 | 1294 | const header = document.querySelector(".modal-header") 1295 | header.style = "position: relative;" 1296 | header.appendChild(addAll) 1297 | 1298 | } 1299 | }, 500) 1300 | } 1301 | } 1302 | }, 500) 1303 | 1304 | 1305 | 1306 | // ---------------------- Allowlist/Denylist page ------------------------- 1307 | 1308 | 1309 | } 1310 | else if (/allowlist$|denylist$/.test(location.href)) 1311 | { 1312 | const waitForLists = setInterval(function() 1313 | { 1314 | if (document.querySelectorAll(".list-group-item").length > 1) 1315 | { 1316 | clearInterval(waitForLists) 1317 | 1318 | 1319 | // Create the options menu 1320 | 1321 | const sortAZSwitch = createSwitchCheckbox("Sort A-Z") 1322 | sortAZSwitch.firstChild.checked = GMsettings.SortDomainsAZ 1323 | sortAZSwitch.onchange = function() 1324 | { 1325 | sortItemsAZ(".list-group:nth-child(2)", "domain", sortTLDSwitch.firstChild) 1326 | GM.setValue("SortDomainsAZ", this.firstChild.checked) 1327 | } 1328 | 1329 | const sortTLDSwitch = createSwitchCheckbox("Sort by TLD") 1330 | sortTLDSwitch.firstChild.checked = GMsettings.SortTLDs 1331 | sortTLDSwitch.onchange = function() 1332 | { 1333 | sortItemsAZ(".list-group:nth-child(2)", "domain", this.firstChild) 1334 | GM.setValue("SortTLDs", this.firstChild.checked) 1335 | } 1336 | 1337 | const boldRootSwitch = createSwitchCheckbox("Bold root domain") 1338 | boldRootSwitch.firstChild.checked = GMsettings.AllowDenyOptions.bold 1339 | boldRootSwitch.onchange = function() 1340 | { 1341 | styleDomains("bold", this.firstChild.checked) 1342 | GMsettings.AllowDenyOptions.bold = this.firstChild.checked 1343 | GM.setValue("AllowDenyOptions", JSON.stringify(GMsettings.AllowDenyOptions)) 1344 | } 1345 | 1346 | const lightenSwitch = createSwitchCheckbox("Lighten subdomains") 1347 | lightenSwitch.firstChild.checked = GMsettings.AllowDenyOptions.lighten 1348 | lightenSwitch.onchange = function() 1349 | { 1350 | styleDomains("lighten", this.firstChild.checked) 1351 | GMsettings.AllowDenyOptions.lighten = this.firstChild.checked 1352 | GM.setValue("AllowDenyOptions", JSON.stringify(GMsettings.AllowDenyOptions)) 1353 | } 1354 | 1355 | const rightAlignSwitch = createSwitchCheckbox("Right-aligned") 1356 | rightAlignSwitch.firstChild.checked = GMsettings.AllowDenyOptions.rightAligned 1357 | rightAlignSwitch.onchange = function() 1358 | { 1359 | styleDomains("rightAlign", this.firstChild.checked) 1360 | GMsettings.AllowDenyOptions.rightAligned = this.firstChild.checked 1361 | GM.setValue("AllowDenyOptions", JSON.stringify(GMsettings.AllowDenyOptions)) 1362 | } 1363 | 1364 | 1365 | const optionsContainer = document.createElement("div") 1366 | optionsContainer.style = "position:absolute; top: 7px; border: 1px solid lightgray; border-radius: 15px; padding: 5px 15px 5px 0px; left: 1160px; width: max-content; display: none;" 1367 | optionsContainer.appendChild(sortAZSwitch) 1368 | optionsContainer.appendChild(sortTLDSwitch) 1369 | optionsContainer.appendChild(boldRootSwitch) 1370 | optionsContainer.appendChild(lightenSwitch) 1371 | optionsContainer.appendChild(rightAlignSwitch) 1372 | 1373 | const optionsButton = document.createElement("button") 1374 | optionsButton.className = "btn btn-clear" 1375 | optionsButton.style = "position:absolute; right: -42px; bottom: 10px; width: 30px; padding: 1px 0px 3px 3px; border: 1px solid lightgray;" 1376 | optionsButton.innerHTML = "⚙️" 1377 | optionsButton.onclick = function() { 1378 | optionsContainer.style.cssText += optionsContainer.style.cssText.includes("none") ? "display: initial;" : "display: none;" 1379 | this.blur() 1380 | } 1381 | 1382 | const rectangleAboveInput = document.querySelector(".list-group") 1383 | rectangleAboveInput.style = "position: relative;" 1384 | rectangleAboveInput.appendChild(optionsContainer) 1385 | rectangleAboveInput.appendChild(optionsButton) 1386 | rectangleAboveInput.onclick = function() { event.stopPropagation() } 1387 | 1388 | document.body.onclick = function() { optionsContainer.style.cssText += 'display: none;' } 1389 | 1390 | 1391 | // Create the input box for the domain descriptions 1392 | 1393 | const domainsItems = document.querySelectorAll(".list-group-item") 1394 | 1395 | for (let i=1; i < domainsItems.length; i++) 1396 | { 1397 | const descriptionInput = document.createElement("input") 1398 | descriptionInput.className = "description" 1399 | descriptionInput.placeholder = "Add a description. Press Enter to submit" 1400 | descriptionInput.style = "margin-left: 30px; border: 0; background: transparent; color: gray;" 1401 | descriptionInput.onkeypress = function(event) 1402 | { 1403 | if (event.key == "Enter") 1404 | { 1405 | GMsettings.domainDescriptions[this.previousSibling.textContent.substring(2)] = this.value 1406 | GM.setValue("domainDescriptions", JSON.stringify(GMsettings.domainDescriptions)) 1407 | this.style.cssText += this.value != "" ? "visibility: visible;" : "visibility: hidden;" 1408 | this.blur() 1409 | } 1410 | } 1411 | 1412 | descriptionInput.value = GMsettings.domainDescriptions[domainsItems[i].textContent.substring(2)] || "" 1413 | 1414 | if (descriptionInput.value == "") 1415 | descriptionInput.style.cssText += "visibility: hidden;" 1416 | 1417 | domainsItems[i].firstChild.firstChild.appendChild(descriptionInput) 1418 | 1419 | descriptionInput.style.cssText += "width: " + (descriptionInput.parentElement.getBoundingClientRect().width - descriptionInput.previousSibling.getBoundingClientRect().width - 41) + "px;" // Make the input box take all available space 1420 | } 1421 | 1422 | 1423 | // Apply the highlighting options 1424 | 1425 | if (sortAZSwitch.firstChild.checked) 1426 | sortItemsAZ(".list-group:nth-child(2)", "domain", sortTLDSwitch.firstChild) 1427 | 1428 | styleDomains("bold", boldRootSwitch.firstChild.checked) 1429 | styleDomains("lighten", lightenSwitch.firstChild.checked) 1430 | 1431 | if (rightAlignSwitch.firstChild.checked) 1432 | styleDomains("rightAlign", rightAlignSwitch.firstChild.checked) 1433 | 1434 | } 1435 | 1436 | }, 500) 1437 | 1438 | 1439 | 1440 | // --------------------------- Settings page --------------------------- 1441 | 1442 | 1443 | } 1444 | else if (/settings$/.test(location.href)) 1445 | { 1446 | const waitForContent = setInterval(function() 1447 | { 1448 | if (document.querySelector(".card-body") != null) 1449 | { 1450 | clearInterval(waitForContent) 1451 | 1452 | const exportButton = document.createElement("button") 1453 | exportButton.className = "btn btn-primary" 1454 | exportButton.style = "position: absolute; right: 210px; top: 20px;" 1455 | exportButton.innerHTML = "Export this config" 1456 | exportButton.onclick = function() 1457 | { 1458 | const config = {} 1459 | const pages = ["security", "privacy", "parentalcontrol", "denylist", "allowlist", "settings"] 1460 | let numPagesExported = 0 1461 | 1462 | createSpinner(this) // Add a spinning circle beside the button to indicate that something is happening 1463 | 1464 | 1465 | for (let i=0; i < pages.length; i++) 1466 | { 1467 | makeApiRequestAndAddEvent("GET", pages[i], function(e) // Get the settings from each page 1468 | { 1469 | config[pages[i]] = JSON.parse(e.detail) 1470 | numPagesExported++ 1471 | 1472 | if (numPagesExported == pages.length) 1473 | { 1474 | // Export only the relevant data from these settings 1475 | 1476 | config.privacy.blocklists = config.privacy.blocklists.map(b => b.id) 1477 | config.settings.rewrites = config.settings.rewrites.map((r) => {return {name: r.name, answer: r.answer}}) 1478 | config.parentalcontrol.services = config.parentalcontrol.services.map((s) => {return {id: s.id, active: s.active}}) 1479 | 1480 | // Create the file 1481 | 1482 | const blob = URL.createObjectURL(new Blob([JSON.stringify(config, null, 2)], {type: "text/plain"})) 1483 | const a = document.createElement("a") 1484 | a.href = blob 1485 | a.download = location.href.split("/")[3] + "-Export.json" 1486 | document.body.appendChild(a) 1487 | a.click() 1488 | 1489 | exportButton.lastChild.remove() // Remove the spinner when done 1490 | } 1491 | }) 1492 | } 1493 | 1494 | } 1495 | 1496 | const importButton = document.createElement("button") 1497 | importButton.className = "btn btn-primary" 1498 | importButton.style = "position: absolute; right: 40px; top: 20px;" 1499 | importButton.innerHTML = "Import a config" 1500 | importButton.onclick = function() { this.nextSibling.click() } // Click the file input button 1501 | 1502 | const fileInput = document.createElement("input") 1503 | fileInput.type = "file" 1504 | fileInput.style = "display: none;" 1505 | fileInput.onchange = function() 1506 | { 1507 | const file = new FileReader() 1508 | file.onload = async function() 1509 | { 1510 | const config = JSON.parse(this.result); 1511 | delete config.settings.name // Don't import the config name 1512 | 1513 | let importTLDs = true // Most likely the server has a DOS attack prevention system, and this makes the API reject every PATCH request when a certain limit 1514 | // of connections is reached. Because every added TLD is a new connection, cutting down the number of TLDs solves the problem. But if the 1515 | if (config.security.blocked_tlds.length > 500) // user added more than 500 TLDs, then most likely almost every TLD was added, so it's better to just use the "Add all TLDs" button for that. 1516 | { 1517 | alert("WARNING: It seems that you are attempting to import a configuration file that contains a very long list of TLDs. \n"+ 1518 | "Importing long lists overwhelms the server and it starts rejecting connections. \n"+ 1519 | "Because of that, all settings will be imported, except the TLDs. \n"+ 1520 | "If you want to import the TLDs, go to the Security page and use the \"Add all TLDs\" button, then remove the TLDs that you want to allow.") 1521 | 1522 | importTLDs = false 1523 | } 1524 | 1525 | const numItemsImported = { 1526 | allowlist: 0, 1527 | denylist: 0, 1528 | blocked_tlds: 0, 1529 | blocklists: 0, 1530 | "parentalcontrol/services": 0, 1531 | "parentalcontrol/categories": 0 1532 | } 1533 | 1534 | const importAllAndSwitchDisabledItems = async function(listName, idPropName) 1535 | { 1536 | let listObj = config[listName] 1537 | 1538 | if (listName.includes("/")) 1539 | { 1540 | const listSplit = listName.split("/") 1541 | listObj = config[listSplit[0]] 1542 | if (listSplit.length == 2) 1543 | listObj = listObj[listSplit[1]] 1544 | } 1545 | 1546 | for (let i=0; i < listObj.length; i++) 1547 | { 1548 | const item = listObj[i] 1549 | const hexedId = convertToHex(item[idPropName]) 1550 | 1551 | await sleep(1000) 1552 | makeApiRequestAndAddEvent("PUT", listName + "/hex:" + hexedId, function(e) 1553 | { 1554 | numItemsImported[listName]++ 1555 | if (numItemsImported[listName] == listObj.length) 1556 | { 1557 | const disabledItems = listObj.filter(d => !d.active).map(d => convertToHex(d[idPropName])) // Store in an array the hex of each disabled item id 1558 | 1559 | for (let i=0; i < disabledItems.length; i++) 1560 | makeApiRequest("PATCH", listName+"/hex:" + disabledItems[i], {"active":false}) 1561 | } 1562 | }) 1563 | } 1564 | } 1565 | 1566 | 1567 | // Import Security page 1568 | 1569 | makeApiRequest("PATCH", "security", config.security) 1570 | 1571 | if (importTLDs) 1572 | { 1573 | for (let i=0; i < config.security.blocked_tlds.length; i++) // NextDNS doesn't accept multiple TLDs or domains in one go, so every entry need to be added individually 1574 | { 1575 | await sleep(1000) 1576 | makeApiRequestAndAddEvent("PUT", "security/blocked_tlds/hex:" + convertToHex(config.security.blocked_tlds[i]), ()=> numItemsImported.blocked_tlds++) 1577 | } 1578 | } 1579 | 1580 | // Import Privacy page 1581 | 1582 | makeApiRequest("PATCH", "privacy", config.privacy) 1583 | 1584 | for (let i=0; i < config.privacy.blocklists.length; i++) 1585 | { 1586 | await sleep(1000) 1587 | makeApiRequestAndAddEvent("PUT", "privacy/blocklists/hex:" + convertToHex(config.privacy.blocklists[i]), ()=> numItemsImported.blocklists++) 1588 | } 1589 | 1590 | for (let i=0; i < config.privacy.natives.length; i++) 1591 | { 1592 | await sleep(1000) 1593 | makeApiRequest("PUT", "privacy/natives/hex:" + convertToHex(config.privacy.natives[i].id)) 1594 | } 1595 | 1596 | // Import Parental Control page 1597 | 1598 | makeApiRequest("PATCH", "parentalcontrol", config.parentalcontrol) 1599 | 1600 | importAllAndSwitchDisabledItems("parentalcontrol/services", "id") 1601 | importAllAndSwitchDisabledItems("parentalcontrol/categories", "id") 1602 | 1603 | // Import Allow/Denylists 1604 | 1605 | importAllAndSwitchDisabledItems("denylist", "domain") 1606 | importAllAndSwitchDisabledItems("allowlist", "domain") 1607 | 1608 | // Import Settings page 1609 | 1610 | makeApiRequest("PATCH", "settings", config.settings) 1611 | 1612 | for (let i=0; i < config.settings.rewrites.length; i++) 1613 | { 1614 | await sleep(1000) 1615 | makeApiRequest("POST", "settings/rewrites", config.settings.rewrites[i]) 1616 | } 1617 | 1618 | // Check if the longest settings finished importing. The shorter ones most likely already finished 1619 | 1620 | setInterval(function() 1621 | { 1622 | if ((!importTLDs || config.security.blocked_tlds.length == numItemsImported.blocked_tlds) 1623 | && config.privacy.blocklists.length == numItemsImported.blocklists 1624 | && config.denylist.length == numItemsImported.denylist 1625 | && config.allowlist.length == numItemsImported.allowlist) 1626 | { 1627 | setTimeout(()=> location.reload(), 1000) 1628 | } 1629 | }, 1000) 1630 | 1631 | } 1632 | 1633 | file.readAsText(this.files[0]) 1634 | 1635 | createPleaseWaitModal("Importing settings") 1636 | } 1637 | 1638 | const container = document.querySelector(".card-body") 1639 | container.appendChild(exportButton) 1640 | container.appendChild(importButton) 1641 | container.appendChild(fileInput) 1642 | 1643 | } 1644 | 1645 | }, 500) 1646 | } 1647 | 1648 | 1649 | 1650 | }, 500) 1651 | } 1652 | 1653 | 1654 | 1655 | 1656 | 1657 | function getGMsettings() 1658 | { 1659 | GMsettings = {} 1660 | ind = 0 1661 | settings = ["domainDescriptions", "AllowDenyOptions", "LogsOptions"] 1662 | 1663 | GM.getValue("changed").then(function(value) 1664 | { 1665 | if (value != true) 1666 | { 1667 | GM.setValue("domainsToHide", ".nextdns.io\n.in-addr.arpa\n.ip6.arpa") // Hide theses queries by default, but only at the first time 1668 | GM.setValue("changed", true) 1669 | } 1670 | 1671 | GM.getValue("domainsToHide").then(function(value) 1672 | { 1673 | domainsToHide = value.split("\n").filter(d => d.trim() != "") // Create an array with the domains to be hidden, excluding empty lines 1674 | 1675 | GM.getValue("collapseBlocklists").then(function(value) 1676 | { 1677 | if (typeof value == "undefined") 1678 | { 1679 | value = true 1680 | GM.setValue("collapseBlocklists", value) 1681 | } 1682 | 1683 | GMsettings.collapseBlocklists = value 1684 | 1685 | GM.getValue("collapseTLDs").then(function(value) 1686 | { 1687 | if (typeof value == "undefined") 1688 | { 1689 | value = true 1690 | GM.setValue("collapseTLDs", value) 1691 | } 1692 | 1693 | GMsettings.collapseTLDs = value 1694 | 1695 | GM.getValue("SortDomainsAZ").then(function(value) 1696 | { 1697 | GMsettings.SortDomainsAZ = value 1698 | 1699 | GM.getValue("SortTLDs").then(function(value) 1700 | { 1701 | GMsettings.SortTLDs = value 1702 | 1703 | GM.getValue("SortBlocklistsAZ").then(function(value) 1704 | { 1705 | GMsettings.SortBlocklistsAZ = value 1706 | 1707 | getOrCreateGMsetting(settings[0]) 1708 | }) 1709 | }) 1710 | }) 1711 | }) 1712 | }) 1713 | }) 1714 | }) 1715 | } 1716 | 1717 | 1718 | function getOrCreateGMsetting(settingName) 1719 | { 1720 | GM.getValue(settingName).then(function(value) 1721 | { 1722 | if (value == undefined) 1723 | { 1724 | GMsettings[settingName] = new Object() 1725 | GM.setValue(settingName, JSON.stringify(GMsettings[settingName])) 1726 | } 1727 | else GMsettings[settingName] = JSON.parse(value) 1728 | 1729 | ind++ 1730 | 1731 | if (ind < settings.length) 1732 | getOrCreateGMsetting(settings[ind]) // This is to make sure that all settings are loaded before the main function starts 1733 | else 1734 | main() 1735 | 1736 | }) 1737 | } 1738 | 1739 | 1740 | const SLDs = ["co","com","org","edu","gov","mil","net"] 1741 | 1742 | function styleDomains(type, enable) 1743 | { 1744 | if (type == "lighten" || type == "bold") 1745 | { 1746 | const items = document.querySelectorAll(".list-group-item span[class='notranslate']") 1747 | 1748 | for (let i=0; i < items.length; i++) 1749 | { 1750 | const rootSpan = items[i].querySelector("span + span") 1751 | 1752 | if (enable) 1753 | { 1754 | const subdomains = items[i].textContent.split(".") 1755 | let rootDomain = subdomains[subdomains.length-2] 1756 | let domainStyle = "color: black;" 1757 | 1758 | if (type == "lighten") 1759 | items[i].style.cssText += "color: #aaa" 1760 | else 1761 | domainStyle += "font-weight: bold;" 1762 | 1763 | if (SLDs.includes(rootDomain)) 1764 | rootDomain = subdomains[subdomains.length-3] + "." + rootDomain 1765 | 1766 | rootDomain += "." + subdomains[subdomains.length-1] 1767 | 1768 | if (!rootSpan) 1769 | items[i].innerHTML = items[i].innerHTML.replace(rootDomain, "" + rootDomain + "") 1770 | else 1771 | rootSpan.style.cssText += domainStyle 1772 | 1773 | } 1774 | else 1775 | { 1776 | if (rootSpan) 1777 | { 1778 | if (type == "lighten") 1779 | { 1780 | rootSpan.style.cssText += "color: inherit;" 1781 | items[i].style.cssText += "color: inherit;" 1782 | } 1783 | else rootSpan.style.cssText += "font-weight: normal;" 1784 | } 1785 | } 1786 | } 1787 | } 1788 | else if (type == "rightAlign") 1789 | { 1790 | const domainContainers = document.querySelectorAll(".list-group-item > div > div:nth-child(1)") 1791 | for (let i=0; i < domainContainers.length; i++) 1792 | { 1793 | const favicon = domainContainers[i].firstChild.querySelector("img") 1794 | favicon.className = enable ? "ml-2" : "mr-2" 1795 | domainContainers[i].firstChild.appendChild(domainContainers[i].firstChild.firstChild) // Swap places for the favicon and domain 1796 | domainContainers[i].style.cssText += enable ? "justify-content: flex-end;" : "justify-content: initial;" 1797 | domainContainers[i].lastChild.style.cssText += "width: 450px;" 1798 | } 1799 | } 1800 | } 1801 | 1802 | 1803 | function createSwitchCheckbox(text) 1804 | { 1805 | const container = document.createElement("div") 1806 | container.className = "custom-switch" 1807 | 1808 | const checkbox = document.createElement("input") 1809 | checkbox.type = "checkbox" 1810 | checkbox.id = "id" + Date.now() * Math.random() // There's no need to specify a human-readable id, but it needs to be unique 1811 | checkbox.className = "custom-control-input" 1812 | 1813 | const label = document.createElement("label") 1814 | label.textContent = text 1815 | label.style = "margin-left: 10px; user-select: none;" 1816 | label.htmlFor = checkbox.id 1817 | label.className = "custom-control-label" 1818 | 1819 | container.appendChild(checkbox) 1820 | container.appendChild(label) 1821 | 1822 | return container 1823 | } 1824 | 1825 | 1826 | function createPleaseWaitModal(whatIsDoing) 1827 | { 1828 | const hourGlass = document.createElement("span") 1829 | hourGlass.style = "font-size: 40px; margin-top: -6px; margin-right: 20px;" 1830 | hourGlass.innerText = "⏳" 1831 | 1832 | const message = document.createElement("div") 1833 | message.innerText = whatIsDoing + ". This will take some seconds, please wait...\n The page will be reloaded when finished." 1834 | 1835 | const elementsContainer = document.createElement("div") 1836 | elementsContainer.style = "background: white; z-index: 9999; position: fixed; top: 38vh; left: 33.3vw; padding: 20px; border-radius: 10px; display: flex; font-size: large; user-select: none;" 1837 | elementsContainer.appendChild(hourGlass) 1838 | elementsContainer.appendChild(message) 1839 | 1840 | document.body.appendChild(elementsContainer) 1841 | 1842 | const backdrop = document.createElement("div") 1843 | backdrop.style = "background: black; position: fixed; top: 0; left: 0; z-index: 9998; opacity: 0.5; width: 100%; height: 100%;" 1844 | 1845 | document.body.appendChild(backdrop) 1846 | 1847 | const origBackdrop = document.getByClass("modal-backdrop") 1848 | if (origBackdrop != null) origBackdrop.remove() 1849 | } 1850 | 1851 | 1852 | function createSpinner(container) 1853 | { 1854 | const spinner = document.createElement("span") 1855 | spinner.className = "ml-2 spinner-border spinner-border-sm" 1856 | spinner.style = "vertical-align: middle;" 1857 | container.appendChild(spinner) 1858 | } 1859 | 1860 | 1861 | function createStylizedTooltipWithImgParent(imgSrc, innerHTML) 1862 | { 1863 | const container = document.createElement("div") 1864 | container.style.display = "inline-block" 1865 | 1866 | const icon = document.createElement("img") 1867 | icon.src = imgSrc 1868 | 1869 | container.appendChild(icon) 1870 | 1871 | container.createStylizedTooltip(innerHTML) 1872 | 1873 | return container 1874 | } 1875 | 1876 | 1877 | function sortItemsAZ(selector, type = "", element = null) 1878 | { 1879 | const container = document.querySelector(selector) 1880 | const items = Array.from(container.children) 1881 | 1882 | if (type == "domain") 1883 | { 1884 | let startingLevel = 1 // From last to first 1885 | 1886 | if (!element.checked) // If "Sort by TLDs" is disabled, skip the TLD 1887 | startingLevel++ 1888 | 1889 | items.sort(function(a, b) 1890 | { 1891 | const tempA = a.textContent.toLowerCase().substring(2).split(".") 1892 | const tempB = b.textContent.toLowerCase().substring(2).split(".") 1893 | 1894 | let levelA = tempA.length - startingLevel 1895 | let levelB = tempB.length - startingLevel 1896 | 1897 | a = tempA[levelA] 1898 | b = tempB[levelB] 1899 | 1900 | if (startingLevel == 2) 1901 | { 1902 | if (SLDs.includes(tempA[levelA])) // If the domain before the TLD is a SLD, instead of a root domain ... 1903 | a = tempA[--levelA] // ... skip it. 1904 | 1905 | if (SLDs.includes(tempB[levelB])) 1906 | b = tempB[--levelB] 1907 | } 1908 | 1909 | while(true) // Repeat until reaching a return 1910 | { 1911 | if (a < b) return -1 1912 | else if (a > b) return 1 1913 | else if (a == b) // If both items share the same domain ... 1914 | { 1915 | levelA-- // ... then skip to a deeper level ... 1916 | levelB-- 1917 | 1918 | if (typeof tempA[levelA] != "undefined" && typeof tempB[levelB] != "undefined") // ... but only if both have a deeper level. 1919 | { 1920 | a = tempA[levelA] 1921 | b = tempB[levelB] 1922 | } 1923 | else if (typeof tempA[levelA] == "undefined" && typeof tempB[levelB] != "undefined") // This happens when an upper level domain is compared with a deeper level one. 1924 | return -1 // In this case, bring the upper level one to the top 1925 | else if (typeof tempA[levelA] != "undefined" && typeof tempB[levelB] == "undefined") 1926 | return 1 1927 | else return 0 1928 | } 1929 | } 1930 | }) 1931 | } 1932 | else // Simple sorting 1933 | { 1934 | items.sort(function(a, b) 1935 | { 1936 | a = a.textContent.toLowerCase() 1937 | b = b.textContent.toLowerCase() 1938 | 1939 | if (a < b) return -1 1940 | else if (a > b) return 1 1941 | else if (a == b) return 0 1942 | }) 1943 | } 1944 | 1945 | for (let i = 0; i < items.length; i++) 1946 | container.appendChild(items[i]) 1947 | } 1948 | 1949 | 1950 | function extendFunctions() 1951 | { 1952 | Node.prototype.getByClass = function(className) { return this.getElementsByClassName(className)[0] } 1953 | Node.prototype.secondChild = function() { return this.children[1] } 1954 | Array.prototype.lastItem = function() { return this[this.length-1] } 1955 | 1956 | setIntervalOld = setInterval 1957 | setInterval = function(f,t) { intervals.push(setIntervalOld(f,t)); return intervals.lastItem() } 1958 | 1959 | Node.prototype.createStylizedTooltip = function(innerHTML) 1960 | { 1961 | const tooltipDiv = document.createElement("div") 1962 | tooltipDiv.innerHTML = innerHTML 1963 | tooltipDiv.className = "customTooltip" 1964 | tooltipDiv.style = 'position: absolute; background: #333; color: white; z-index: 99; font-family: var(--font-family-sans-serif); padding: 7px; font-size: 11px; font-weight: initial; \ 1965 | text-align: center; border-radius: 5px; line-height: 20px; margin-top: 10px; min-width: 3.2cm; max-width: 5.5cm; visibility: hidden; opacity: 0; transition: 0.2s;' 1966 | 1967 | this.appendChild(tooltipDiv) 1968 | this.classList.add("tooltipParent") 1969 | } 1970 | } 1971 | 1972 | 1973 | function clearAllIntervals() 1974 | { 1975 | for (let i=0; i < intervals.length; i++) 1976 | clearInterval(intervals[i]) 1977 | 1978 | const counters = document.getElementById("visibleEntriesCount") 1979 | if (counters) counters.parentElement.remove() 1980 | } 1981 | 1982 | 1983 | function hideAllListItemsAndCreateButton(text, settingName, settingValue) 1984 | { 1985 | if (settingValue) 1986 | { 1987 | const items = document.querySelector(".list-group").children 1988 | 1989 | // Hide items 1990 | 1991 | for (let i = 1; i < items.length; i++) 1992 | items[i].style.cssText += "display: none;" 1993 | 1994 | // Create "Show" button 1995 | 1996 | const show = document.createElement("button") 1997 | show.id = "showList" 1998 | show.className = "btn btn-light" 1999 | show.style = "margin-top: 20px;" 2000 | show.innerHTML = text 2001 | show.onclick = function() { 2002 | for (let i = 1; i < items.length; i++) 2003 | items[i].style.cssText += "display: block;" 2004 | } 2005 | 2006 | items[0].style += "position: relative;" 2007 | items[0].appendChild(show) 2008 | } 2009 | 2010 | 2011 | // Create the "Collapse the list" switch 2012 | 2013 | const collapseSwitch = createSwitchCheckbox("Collapse the list") 2014 | collapseSwitch.firstChild.checked = settingValue 2015 | collapseSwitch.style = "position: absolute; right: 200px; top: 13%;" 2016 | collapseSwitch.firstChild.onchange = function() 2017 | { 2018 | GM.setValue(settingName, this.checked) 2019 | 2020 | const showButton = document.getElementById("showList") 2021 | 2022 | if (this.checked && !showButton) 2023 | hideAllListItemsAndCreateButton(text, settingName, this.checked) 2024 | else 2025 | showButton.click() 2026 | } 2027 | 2028 | document.querySelector(".list-group-item").appendChild(collapseSwitch) 2029 | } 2030 | 2031 | 2032 | function convertToHex(string) 2033 | { 2034 | let hex = "" 2035 | 2036 | for (let i=0; i < string.length; i++) 2037 | hex += string.charCodeAt(i).toString(16) 2038 | 2039 | return hex 2040 | } 2041 | 2042 | 2043 | function makeApiRequestAndAddEvent(HTTPmethod, requestString, callback, requestBody = null) 2044 | { 2045 | const eventName = Date.now() * Math.random() // There's no need to specify a human-readable event name, but it needs to be unique 2046 | addEventListener(eventName, callback, {once: true, capture: true}) // A one-time event listener that runs the request callback as soon as possible, then removes itself 2047 | makeApiRequest(HTTPmethod, requestString, requestBody, eventName) 2048 | } 2049 | 2050 | 2051 | function makeApiRequest(HTTPmethod, requestString, requestBody = null, callbackEventName = "", contentType = "") 2052 | { 2053 | // The callbackEventName is a string representing the name of the event listener that should be fired when the request is finished, which is handed 2054 | // over the chain: top window's makeApiRequest > frame's message listener > frame's makeRequest function > top window's message listener > dispatchEvent > callback. 2055 | // Because the HTTP request is made asynchronously and requires to be handed to the frame, this ensures that the callback is executed only after this whole chain is completed. 2056 | // None of this is needed with a synchronous request, but when the connection gets slow, it freezes the browser tab until it's completed. 2057 | 2058 | const requestURL = ApiFrame.data + location.href.split("/")[3] + "/" + requestString // Update the URL for each request. This ensures that the request will be made to the correct config 2059 | 2060 | if (HTTPmethod == "PATCH" || HTTPmethod == "POST") requestBody = JSON.stringify(requestBody) 2061 | 2062 | window.frames[0].postMessage({request: requestURL, body: requestBody, type: contentType, method: HTTPmethod, callback: callbackEventName}, "https://api.nextdns.io") 2063 | } 2064 | 2065 | 2066 | 2067 | 2068 | } 2069 | else if (location.href.includes("https://api.nextdns.io/")) 2070 | { 2071 | // This function needs to be in the frame's body, otherwise the request is refused due to CORS. 2072 | // Script engines have the GM_xmlHttpRequest function that allows CORS, but it doesn't include cookies in the request, which are needed to use the API, otherwise the server justs responds "Forbidden" 2073 | 2074 | const script = document.createElement("script") 2075 | script.innerHTML = ` 2076 | function makeRequest(requestURL, requestBody, contentType, HTTPmethod, callback) 2077 | { 2078 | const xmlHttp = new XMLHttpRequest() 2079 | xmlHttp.open(HTTPmethod, requestURL) 2080 | xmlHttp.setRequestHeader("Content-Type", "application/json;charset=utf-8") 2081 | xmlHttp.onload = function() { window.top.postMessage({response: xmlHttp.response, callback: callback}, "https://my.nextdns.io") } 2082 | xmlHttp.onerror = function() 2083 | { 2084 | // When there's a network problem while making a request, try again after 5 seconds 2085 | 2086 | setTimeout(function() { 2087 | makeApiRequest(requestURL, requestBody, contentType, HTTPmethod, callback) 2088 | }, 5000) 2089 | } 2090 | xmlHttp.send(requestBody) 2091 | } 2092 | 2093 | const frameReadyMessage = function() { window.top.postMessage("frame ready", "https://my.nextdns.io") } 2094 | 2095 | if (document.readyState != "complete") 2096 | window.self.onload = () => frameReadyMessage() // This is a workaround for Chrome, as for some reason the frame has some delay to finish loading. In Firefox it works fine. 2097 | else 2098 | frameReadyMessage() 2099 | ` 2100 | document.head.appendChild(script) 2101 | 2102 | window.addEventListener("message", function (e) { 2103 | unsafeWindow.makeRequest(e.data.request, e.data.body, e.data.type, e.data.method, e.data.callback) 2104 | }, false) 2105 | 2106 | 2107 | } 2108 | -------------------------------------------------------------------------------- /Userscript/README.md: -------------------------------------------------------------------------------- 1 | # NX Enhanced 2 | A userscript that adds "quality-of-life" features to NextDNS website to make the experience of managing lists, domains, etc. more practical. 3 | 4 | **NOTE:** This userscript is **DISCONTINUED** and **OUTDATED**, please use the browser extension version instead if you want to continue receiving updates (instructions [here](https://github.com/hjk789/NXEnhanced)). The last time it was tested, the userscript was still working fine in Chrome, and partially in Firefox (see the "How to use it" note below). 5 | 6 | ## Features 7 | 8 | ### Logs page: 9 | 10 | - Allow/Deny buttons in the logs that make it possible to add an exception or block a domain without needing to copy, switch pages, and paste. 11 | ![Allow and Deny butttons](https://i.imgur.com/3XNMUi1.png) 12 | You can either add the respective domain or the whole root domain, or even edit the domain if you want. 13 | [Read more](https://github.com/hjk789/NXEnhanced/wiki#an-allowdeny-button-for-each-log-entry) 14 | 15 | - Option to show only queries from unnamed devices 16 | ![Other Devices button](https://i.imgur.com/V7HFiJL.png) 17 | 18 | - Ability to specify domains that should be hidden from the logs 19 | ![New domain filtering for the logs](https://i.imgur.com/l8Ouzh1.png) 20 | You can either manually input domains, or click on the "Hide" button, alongside the Allow/Deny buttons, which lets you hide domains with few clicks. [Read more](https://github.com/hjk789/NXEnhanced/wiki#ability-to-specify-domains-that-should-be-hidden-from-the-logs) 21 | 22 | - Show the query's absolute time (HH:MM:SS) along with the relative time ("a minute ago", "few seconds ago") 23 | ![Absolute time](https://i.imgur.com/I3pGNL8.png) 24 | 25 | - A refresh button 26 | ![refresh button](https://i.imgur.com/yBEo3mV.png) 27 | 28 | - An option to show the number of entries currently loaded, either visible or hidden by filters 29 | ![counters](https://i.imgur.com/8mTEDt1.png) 30 | 31 | ### Privacy page: 32 | 33 | - Collapse the list of blocklists enabled and adds a button to unhide them if needed 34 | ![Hidden lists](https://i.imgur.com/ifnmNiv.png) 35 | This is good for people with a long list of blocklists added. 36 | 37 | - Sort alphabetically the list of blocklists in the "Add a blocklist" screen 38 | ![Sort a-z blocklists](https://i.imgur.com/rFXduAY.png) 39 | 40 | ### Security page: 41 | 42 | - Collapse the list of added TLDs 43 | 44 | - A button that allows you to add every TLD in the "Add a TLD" screen in one click. [Read more](https://github.com/hjk789/NXEnhanced/wiki#a-button-that-allows-you-to-add-every-tld-in-the-add-a-tld-screen-in-one-click) 45 | ![Add all TLDs button](https://i.imgur.com/PDlYlF1.png) 46 | 47 | ### Allowlist/Denylist pages: 48 | 49 | - Ability to add a description to each domain in the allow/denylists. [Read more](https://github.com/hjk789/NXEnhanced/wiki#ability-to-add-a-description-to-each-domain-in-the-denyallow-lists) 50 | ![Description input](https://i.imgur.com/TqlKWxr.png) 51 | 52 | - Sort the allow/deny lists alphabetically, and styling options to the domains for an easier quick reading, such as: lighten subdomains, bold root domain and right-align. 53 | ![allow/deny options](https://i.imgur.com/DiuO5TB.png) 54 | 55 | ### Settings page: 56 | 57 | - Ability to export/import all settings from/to a config. [Read more](https://github.com/hjk789/NXEnhanced/wiki#ability-to-exportimport-all-settings-fromto-a-config) 58 | ![Export/import buttons](https://i.imgur.com/2oEl8t2.png) 59 | 60 | 61 | ## How to use it 62 | 63 | To use this userscript, just install in your browser Tampermonkey, Violentmonkey or Greasemonkey extension, if you hadn't yet. Having it installed, then just go to the following link: https://greasyfork.org/scripts/408934-nx-enhanced/code/NX%20Enhanced.user.js 64 | 65 | A window will pop asking if you want to install the script, just confirm it, and it's done! 66 | 67 | If you use uMatrix, you have to allow **media** to `api.nextdns.io` to use the allow/deny buttons, Add all TLDs and export/import features. 68 | 69 | **Note:** Although almost all features should work fine with it, Greasemonkey is not supported. NX Enhanced userscript was tested in Firefox and Chrome, in Tampermonkey, Greasemonkey and Violentmonkey. It used to work in Firefox, but it's working partially now. You have to use the [Firefox extension](https://addons.mozilla.org/addon/nx-enhanced?utm_source=github&utm_content=userscript) instead. If you really have to use this userscript in Firefox, you have to disable the `security.csp.enable` setting in about:config (not recommended for security reasons). In Chrome, the last time it was tested it still worked fine. It should work fine in pretty much any other browsers in which you can install any of these three script-managers, although I didn't tested them. 70 | 71 | ## License 72 | 73 | - You can view the code, download copies, install, run, use the features and uninstall this software. 74 | - You can link to this project's repository homepage (https://github.com/hjk789/NXEnhanced). 75 | - You can modify your downloaded copy as you like. 76 | - You can make a fork of this project, provided that: 1. You fork it inside GitHub, by clicking on the "Fork" button of this project's repository web page; and 2. You fork it in order to push changes to this project's repository with a pull request. If you don't fit in these conditions, don't fork it. 77 | - You cannot do any other action not allowed in this license. 78 | 79 | I have no association with NextDNS Inc., I'm just a user of their DNS service who needed the features NX Enhanced provides. NX Enhanced is a completely voluntary work. I am not responsible for any damage or leak, directly or indirectly related to the use or misuse of this software. The responsibility is completely on it's users. Use it at your own risk. There are no warranties, either implied or stated. 80 | 81 | Copyright (c) 2020+ BLBC ([hjk789](https://github.com/hjk789)) 82 | -------------------------------------------------------------------------------- /WebExtension/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjk789/NXEnhanced/0ee17b310ab5bc05b442532bfc5f254d9aca7b84/WebExtension/icon.png -------------------------------------------------------------------------------- /WebExtension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "NX Enhanced (Official)", 4 | "version": "5.1.3", 5 | "description": "Adds quality-of-life features to NextDNS website for a more practical usability", 6 | 7 | "icons": { "128": "icon.png" }, 8 | 9 | "permissions": ["storage"], 10 | 11 | "content_scripts": 12 | [{ 13 | "matches": ["https://my.nextdns.io/*", "https://api.nextdns.io/*"], 14 | "js": ["utils.js", "NXEnhanced.js"] 15 | }], 16 | 17 | "options_ui": { "page": "options-page.html" }, 18 | 19 | "action": 20 | { 21 | "default_title": "NX Enhanced", 22 | "default_icon": "icon.png", 23 | "default_popup": "popup.html" 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /WebExtension/options-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | NX Enhanced 4 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |

Security page

Collapse list of added TLDs

Privacy page

Collapse list of added blocklists
Sort blocklists alphabetically

Allowlist and Denylist pages

Sort domains alphabetically
Sort TLDs alphabetically
Display root domains in bold
Lighten subdomains
Align domains to the right
Multi-domain input

Logs page

Show the number of entries
Domains to hide
73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /WebExtension/options-page.js: -------------------------------------------------------------------------------- 1 | 2 | (async ()=> 3 | { 4 | // Load all NX Enhanced's settings. All these functions are imported from the utils.js script. 5 | await loadNXsettings() 6 | 7 | const settings = 8 | [ 9 | ["collapseListTLDs", "SecurityPage", "CollapseList"], 10 | ["collapseListBlocklists", "PrivacyPage", "CollapseList"], 11 | ["sortAZblocklists", "PrivacyPage", "SortAZ"], 12 | ["sortAZdomains", "AllowDenylistPage", "SortAZ"], 13 | ["sortTLDs", "AllowDenylistPage", "SortTLD"], 14 | ["bold", "AllowDenylistPage", "Bold"], 15 | ["lighten", "AllowDenylistPage", "Lighten"], 16 | ["rightAligned", "AllowDenylistPage", "RightAligned"], 17 | ["multilineTextBox", "AllowDenylistPage", "MultilineTextBox"], 18 | ["showCounters", "LogsPage", "ShowCounters"] 19 | ] 20 | 21 | for (let i=0; i < settings.length; i++) 22 | { 23 | const settingFields = settings[i] 24 | 25 | const checkbox = document.getElementById(settingFields[0]) 26 | checkbox.checked = NXsettings[settingFields[1]][settingFields[2]] // Display the current settings values. 27 | checkbox.onchange = function() { NXsettings[settingFields[1]][settingFields[2]] = this.checked; saveSettings() } // Save the settings when changed. 28 | } 29 | 30 | 31 | const domainsToHide = document.getElementById("domainsToHide") 32 | domainsToHide.value = NXsettings.LogsPage.DomainsToHide.join("\n") 33 | domainsToHide.onchange = function() { NXsettings.LogsPage.DomainsToHide = this.value.split("\n"); saveSettings() } 34 | 35 | })() 36 | -------------------------------------------------------------------------------- /WebExtension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 51 | 52 | 53 | 54 |
55 |
56 | 57 |
Open NextDNS
58 |
59 |
60 |
61 | 62 |
63 | Options
64 | 65 |
66 | 67 | -------------------------------------------------------------------------------- /WebExtension/utils.js: -------------------------------------------------------------------------------- 1 | const isChrome = typeof browser == "undefined" // Whether it's Chrome. Chrome uses the chrome object, while Firefox and Edge use the browser object. 2 | 3 | if (isChrome) 4 | chrome.storage.onChanged.addListener(loadNXsettings) 5 | else 6 | browser.storage.onChanged.addListener(loadNXsettings) 7 | 8 | 9 | function loadNXsettings() 10 | { 11 | return new Promise(resolve => 12 | { 13 | readSetting("NXsettings", function(obj) 14 | { 15 | if (!obj.NXsettings) // If it's running for the first time, store the following default settings. 16 | { 17 | NXsettings = 18 | { 19 | SecurityPage: { CollapseList: true }, 20 | PrivacyPage: 21 | { 22 | CollapseList: true, 23 | SortAZ: false 24 | }, 25 | AllowDenylistPage: 26 | { 27 | SortAZ: false, 28 | SortTLD: false, 29 | Bold: false, 30 | Lighten: false, 31 | RightAligned: false, 32 | MultilineTextBox: false, 33 | DomainsDescriptions: {} // In Chrome it's required to be an object to use named items. In Firefox it works even with an array, but with some bugs. 34 | }, 35 | LogsPage: 36 | { 37 | ShowCounters: false, 38 | DomainsToHide: ["nextdns.io", ".in-addr.arpa", ".ip6.arpa"] 39 | } 40 | } 41 | 42 | saveSettings(NXsettings) 43 | } 44 | else NXsettings = obj.NXsettings 45 | 46 | resolve() 47 | }) 48 | }) 49 | } 50 | 51 | function saveSettings(object) 52 | { 53 | if (!object) object = NXsettings 54 | 55 | if (isChrome) 56 | chrome.storage.local.set({NXsettings: object}) 57 | else 58 | browser.storage.local.set({NXsettings: object}) 59 | } 60 | 61 | function readSetting(settingName, callback) 62 | { 63 | if (isChrome) 64 | chrome.storage.local.get(settingName, callback) 65 | else 66 | browser.storage.local.get(settingName).then(callback) 67 | } --------------------------------------------------------------------------------