├── .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 | 
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 | 
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 | 
28 |
29 | - Option to show only queries from unnamed devices
30 |
31 | 
32 |
33 | - Refine a search with multiple search terms or exclusion terms
34 |
35 | 
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 | 
41 |
42 | - Show the query's absolute time (HH:MM:SS) along with the relative time ("a minute ago", "few seconds ago")
43 |
44 | 
45 |
46 | - Relative time that counts minutes, then hours, and goes up to "Yesterday"
47 |
48 | 
49 |
50 | - A refresh button
51 |
52 | 
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 | 
59 |
60 | - Ability to add a list of domains, instead of one by one
61 |
62 | 
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 | 
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 | 
73 |
74 | ### Privacy page:
75 |
76 | - Collapse the list of blocklists enabled and adds a button to unhide them if needed
77 |
78 | 
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 | 
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 | 
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 | 
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 | 
17 |
18 | - Ability to specify domains that should be hidden from the logs
19 | 
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 | 
24 |
25 | - A refresh button
26 | 
27 |
28 | - An option to show the number of entries currently loaded, either visible or hidden by filters
29 | 
30 |
31 | ### Privacy page:
32 |
33 | - Collapse the list of blocklists enabled and adds a button to unhide them if needed
34 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
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 |
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 | }
--------------------------------------------------------------------------------