Manage Allow/Deny lists in NextDNS
目前為
// ==UserScript==
// @name NextDNS Manager
// @description Manage Allow/Deny lists in NextDNS
// @namespace https://nextdns.io/
// @license MIT
// @version 2.7
// @match https://my.nextdns.io/*
// @grant none
// ==/UserScript==
(async function() {
'use strict';
const parseDomains = text => [...new Set((text.match(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi)||[]).map(d=>d.toLowerCase()))];
const getProfiles = async () => {
try{
const res = await fetch("https://api.nextdns.io/accounts/@me?withProfiles=true", {credentials:"include"});
if(!res.ok) return null;
const data = await res.json();
return data.profiles || [];
}catch(e){return null;}
};
const getProfileInfo = async profileId => {
const profiles = await getProfiles();
if(!profiles) return null;
return profiles.find(p => p.id === profileId);
};
const getProfileIdFromURL = () => {
const match = window.location.pathname.match(/^\/([a-z0-9]+)\//i);
return match ? match[1] : null;
};
const getListTypeFromURL = () => {
if (window.location.pathname.includes("/allowlist")) return "allowlist";
if (window.location.pathname.includes("/denylist")) return "denylist";
return null;
};
const fetchList = async (profileId, listType) => {
try{
const res = await fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}`, {credentials:"include"});
const data = await res.json();
return data.data || [];
}catch(e){return [];}
};
const addDomain = async (profileId, listType, domain) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}`, {
method:"POST",
headers:{"Content-Type":"application/json"},
credentials:"include",
body:JSON.stringify({id:domain, active:true})
});
const removeDomain = async (profileId, listType, domain) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}/hex:${Array.from(domain).map(c=>c.charCodeAt(0).toString(16)).join('')}`, {
method:"DELETE",
credentials:"include"
});
const toggleDomainActive = async (profileId, listType, domain, active) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}/hex:${Array.from(domain).map(c=>c.charCodeAt(0).toString(16)).join('')}`, {
method:"PATCH",
headers:{"Content-Type":"application/json"},
credentials:"include",
body: JSON.stringify({active})
});
let guiBox = null;
const createGUI = async () => {
if(guiBox) guiBox.remove();
guiBox = document.createElement("div");
guiBox.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
z-index: 2147483647;
width: 400px;
background: #111;
color: #fff;
padding: 12px;
border-radius: 8px;
font-family: monospace;
box-shadow: 0 0 15px rgba(0,0,0,0.8);
display: flex;
flex-direction: column;
max-height: 650px;
`;
guiBox.innerHTML = `
<div id="ndns-header" style="cursor:move; font-weight:bold; display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<div>
<div id="ndns-profile-name" style="font-size:16px;"></div>
<div id="ndns-profile-info" style="font-size:11px;opacity:0.7;"></div>
<div id="ndns-list-type" style="font-size:13px;font-weight:bold;margin-top:2px;"></div>
</div>
<button id="ndns-toggle" style="background:#222;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Hide</button>
</div>
<div id="ndns-content" style="flex:1; display:flex; flex-direction:column; overflow:hidden;"></div>
`;
document.documentElement.appendChild(guiBox);
const toggleBtn = guiBox.querySelector("#ndns-toggle");
const contentDiv = guiBox.querySelector("#ndns-content");
const profileNameEl = guiBox.querySelector("#ndns-profile-name");
const profileInfoEl = guiBox.querySelector("#ndns-profile-info");
const listTypeEl = guiBox.querySelector("#ndns-list-type");
let hidden=false;
toggleBtn.onclick = () => {
hidden=!hidden;
contentDiv.style.display=hidden?"none":"flex";
toggleBtn.innerText=hidden?"Show":"Hide";
};
// Dragging
const header = guiBox.querySelector("#ndns-header");
let offsetX=0, offsetY=0, dragging=false;
header.onmousedown = e => {dragging=true;offsetX=e.clientX - guiBox.getBoundingClientRect().left;offsetY=e.clientY - guiBox.getBoundingClientRect().top;};
document.onmouseup = ()=>dragging=false;
document.onmousemove = e => {if(dragging){guiBox.style.left=e.clientX-offsetX+"px"; guiBox.style.top=e.clientY-offsetY+"px";}};
const refreshGUI = async () => {
const currentProfileId = getProfileIdFromURL();
const currentListType = getListTypeFromURL();
const onSetupPage = window.location.pathname.includes("/setup");
// Check login
const profiles = await getProfiles();
if(!profiles){
profileNameEl.innerText = "Not logged in";
profileInfoEl.innerText = "";
listTypeEl.innerText = "";
contentDiv.innerHTML = "<div style='color:red;'>Please log in to view your profiles and lists.</div>";
document.title = "NextDNS - Not logged in";
return;
}
// Update profile info
const profile = await getProfileInfo(currentProfileId);
if(profile){
profileNameEl.innerText = profile.name;
profileInfoEl.innerText = `ID: ${profile.id} | Role: ${profile.role}`;
document.title = `NextDNS - ${profile.name} (${currentListType ? currentListType.toUpperCase() : ''})`;
} else {
profileNameEl.innerText = "Profile not found";
profileInfoEl.innerText = "";
}
// Setup page
if(onSetupPage && profile){
const res = await fetch(`https://api.nextdns.io/profiles/${currentProfileId}/setup`, {credentials:"include"});
const data = await res.json();
listTypeEl.innerText = "SETUP";
listTypeEl.style.color = "#66ccff";
contentDiv.innerHTML = `
<div style="margin-bottom:6px;font-weight:bold;">Setup Info</div>
<div><span style="color:#ffcc00">IPv4:</span> ${data.data.ipv4.join(", ") || "None"}</div>
<div><span style="color:#ffcc00">IPv6:</span> ${data.data.ipv6.join(", ") || "None"}</div>
<div><span style="color:#66ff66">Linked IP:</span> ${data.data.linkedIp?.ip || "None"}</div>
<div><span style="color:#66ff66">Servers:</span> ${data.data.linkedIp?.servers.join(", ") || "None"}</div>
<div><span style="color:#66ccff">DNSCrypt:</span> ${data.data.dnscrypt || "None"}</div>
`;
}
// Allow/Deny list page
else if(currentListType && profile){
listTypeEl.innerText = currentListType.toUpperCase();
listTypeEl.style.color = currentListType==="denylist"?"#ff4136":"#2ecc40";
contentDiv.innerHTML = `
<div style="margin-bottom:8px;">
<textarea id="ndns-bulk" placeholder="Paste multiple domains..." style="width:100%;height:80px;padding:6px;resize:none;background:#1c1c1c;color:#fff;border:none;border-radius:4px;"></textarea>
<button id="ndns-add-bulk" style="width:100%;margin-top:4px;background:#333;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;transition:0.2s;">Add Domains</button>
</div>
<div id="ndns-list" style="flex:1;overflow-y:auto;max-height:400px;"></div>
<div id="ndns-status" style="margin-top:6px;font-size:12px;opacity:0.8;"></div>
`;
const listDiv = contentDiv.querySelector("#ndns-list");
const bulkInput = contentDiv.querySelector("#ndns-bulk");
const addBulkBtn = contentDiv.querySelector("#ndns-add-bulk");
const status = contentDiv.querySelector("#ndns-status");
addBulkBtn.onmouseenter = ()=>{addBulkBtn.style.background="#444";}
addBulkBtn.onmouseleave = ()=>{addBulkBtn.style.background="#333";}
const refreshList = async () => {
const list = await fetchList(currentProfileId,currentListType);
listDiv.innerHTML="";
const borderColor = currentListType==="denylist"?"rgb(255,65,54)":"rgb(46,204,64)";
list.forEach(d=>{
const item = document.createElement("div");
item.style.cssText=`
display:flex;
align-items:center;
justify-content:space-between;
padding:6px;
border-left:4px solid ${borderColor};
margin-bottom:4px;
background:#1a1a1a;
border-radius:4px;
`;
const domainContainer = document.createElement("div");
domainContainer.style.cssText="display:flex;align-items:center;gap:6px;";
const img = document.createElement("img");
img.src=`https://favicons.nextdns.io/hex:${Array.from(d.id).map(c=>c.charCodeAt(0).toString(16)).join('')}@2x.png`;
img.style.cssText="width:16px;height:16px;";
const span = document.createElement("span");
span.style.cssText="word-break: break-all;";
span.innerText=d.id;
domainContainer.appendChild(img);
domainContainer.appendChild(span);
const controls = document.createElement("div");
controls.style.cssText="display:flex;align-items:center;gap:6px;";
const checkbox = document.createElement("input");
checkbox.type="checkbox";
checkbox.checked=d.active;
checkbox.onchange = async () => { await toggleDomainActive(currentProfileId,currentListType,d.id,checkbox.checked); };
const delBtn = document.createElement("button");
delBtn.innerText="🗑️";
delBtn.style.cssText="background:none;border:none;color:#fff;cursor:pointer;";
delBtn.onclick = async () => { await removeDomain(currentProfileId,currentListType,d.id); item.remove(); };
controls.appendChild(checkbox);
controls.appendChild(delBtn);
item.appendChild(domainContainer);
item.appendChild(controls);
listDiv.appendChild(item);
});
};
addBulkBtn.onclick = async () => {
const domains = parseDomains(bulkInput.value);
if(!domains.length) return alert("No valid domains found");
status.innerText=`Adding ${domains.length} domains...`;
for(let i=0;i<domains.length;i++){
await addDomain(currentProfileId,currentListType,domains[i]);
status.innerText=`Added ${i+1}/${domains.length}: ${domains[i]}`;
await new Promise(r=>setTimeout(r,150));
}
bulkInput.value="";
status.innerText=`Done! Added ${domains.length} domains.`;
refreshList();
};
await refreshList();
// --- LIVE SYNC OBSERVER ---
const pageList = document.querySelector(".list-group.list-group-flush");
if(pageList){
const observer = new MutationObserver(() => {
const pageItems = Array.from(pageList.querySelectorAll(".list-group-item"));
const pageDomains = pageItems.map(item=>{
const span = item.querySelector("span.notranslate");
if(!span) return null;
const domain = span.innerText.replace(/^\*\./,"").trim();
const active = parseFloat(item.querySelector(".flex-grow-1")?.style.opacity||"1") > 0.5;
return {domain, active};
}).filter(d=>d);
// Current GUI items
const uiItems = Array.from(listDiv.querySelectorAll("div"));
// Remove deleted
uiItems.forEach(uiItem=>{
const domain = uiItem.querySelector("span")?.innerText;
if(domain && !pageDomains.some(d=>d.domain===domain)) uiItem.remove();
});
// Add new or update active state
pageDomains.forEach(d=>{
let uiItem = Array.from(listDiv.querySelectorAll("div")).find(i=>i.querySelector("span")?.innerText===d.domain);
if(!uiItem){
// Add new
const item = document.createElement("div");
item.style.cssText=`
display:flex;
align-items:center;
justify-content:space-between;
padding:6px;
border-left:4px solid ${currentListType==="denylist"?"rgb(255,65,54)":"rgb(46,204,64)"};
margin-bottom:4px;
background:#1a1a1a;
border-radius:4px;
`;
const domainContainer = document.createElement("div");
domainContainer.style.cssText="display:flex;align-items:center;gap:6px;";
const img = document.createElement("img");
img.src=`https://favicons.nextdns.io/hex:${Array.from(d.domain).map(c=>c.charCodeAt(0).toString(16)).join('')}@2x.png`;
img.style.cssText="width:16px;height:16px;";
const span = document.createElement("span");
span.style.cssText="word-break: break-all;";
span.innerText=d.domain;
domainContainer.appendChild(img);
domainContainer.appendChild(span);
const controls = document.createElement("div");
controls.style.cssText="display:flex;align-items:center;gap:6px;";
const checkbox = document.createElement("input");
checkbox.type="checkbox";
checkbox.checked=d.active;
checkbox.onchange = async () => { await toggleDomainActive(currentProfileId,currentListType,d.domain,checkbox.checked); };
const delBtn = document.createElement("button");
delBtn.innerText="🗑️";
delBtn.style.cssText="background:none;border:none;color:#fff;cursor:pointer;";
delBtn.onclick = async () => { await removeDomain(currentProfileId,currentListType,d.domain); item.remove(); };
controls.appendChild(checkbox);
controls.appendChild(delBtn);
item.appendChild(domainContainer);
item.appendChild(controls);
listDiv.appendChild(item);
} else {
const checkbox = uiItem.querySelector("input[type=checkbox]");
if(checkbox) checkbox.checked = d.active;
}
});
});
observer.observe(pageList, {childList:true, subtree:true});
}
}
};
await refreshGUI();
let lastURL = location.href;
setInterval(()=>{
if(location.href!==lastURL){lastURL=location.href; refreshGUI(); lastURL=location.href;}
},1000);
};
setTimeout(createGUI,1500);
})();