An Edit button opens a dialog for a list's name, URL, views and comment, or a rule's action, scope, views and comment. The download-now button is called Refresh. Checkboxes in dialogs are no longer stretched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
18 KiB
JavaScript
319 lines
18 KiB
JavaScript
// Blocking: your block/allow rules and blocklists (docs/blocking.md).
|
||
import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge, confirmDialog, modal, relTime, time } from "../ui.js";
|
||
import { get, change, describeResult, draftQuery, can, api } from "../api.js";
|
||
import { rerender } from "../app.js";
|
||
import { pendingBadge } from "./zones.js";
|
||
|
||
// Well-known lists in Adblock format (they say which entries cover
|
||
// subdomains). Sizes change; these are rough guides.
|
||
const suggestions = [
|
||
["hagezi-light", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt", "Hagezi Light: small, very few false positives"],
|
||
["hagezi-multi", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt", "Hagezi Multi Normal: ads, tracking, malware; a good default"],
|
||
["hagezi-pro", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt", "Hagezi Pro: more thorough, occasionally blocks something wanted"],
|
||
["oisd-small", "https://small.oisd.nl/", "OISD Small: ads and tracking, very few false positives"],
|
||
];
|
||
|
||
const stateBadge = {
|
||
ok: ["in use", "ok"], waiting: ["downloading", "accent"], error: ["update failed", "err"],
|
||
disabled: ["disabled", ""], idle: ["no effect", "warn"],
|
||
};
|
||
|
||
let refreshTimer = null;
|
||
|
||
export async function render(el, params, query) {
|
||
clearTimeout(refreshTimer);
|
||
const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]);
|
||
const writable = can("blocking", true);
|
||
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
||
const autoViews = recursive ? recursive.views : null;
|
||
|
||
const checkOut = h("div");
|
||
const checkName = input({ type: "search", placeholder: "ads.example.com", class: "mono", value: (query && query.get("check")) || "" });
|
||
const runCheck = async (ev) => {
|
||
if (ev) ev.preventDefault();
|
||
const name = checkName.value.trim();
|
||
if (!name) return;
|
||
try {
|
||
const res = await get("/blocking/check", { name });
|
||
mount(checkOut, checkResult(name, res, rules, writable));
|
||
} catch (e) { mount(checkOut, errorBox(e)); }
|
||
};
|
||
|
||
const paused = status.paused_until;
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Blocking"),
|
||
h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")),
|
||
writable && !paused && (lists.length || rules.length) ? pauseControls() : null),
|
||
paused ? h("div", { class: "msg warn" },
|
||
h("strong", null, "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ": "),
|
||
"every name is answered normally. It resumes by itself. ",
|
||
writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null,
|
||
h("div", { class: "panel" },
|
||
h("form", { class: "actions", onsubmit: runCheck },
|
||
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
|
||
checkOut),
|
||
rulesPanel(rules, views, autoViews, writable),
|
||
listsPanel(lists, views, autoViews, writable));
|
||
if (checkName.value) runCheck();
|
||
|
||
// Lists are downloaded in the background, and a pause ends by itself:
|
||
// show it when it happens.
|
||
let wait = 0;
|
||
if (lists.some((l) => l.state === "waiting")) wait = 4000;
|
||
if (paused) wait = Math.max(1000, Math.min(wait || Infinity, new Date(paused) - Date.now() + 3000));
|
||
if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait);
|
||
}
|
||
|
||
function pauseControls() {
|
||
const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"]], "15");
|
||
return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking"));
|
||
}
|
||
|
||
// pause pauses blocking for minutes, or resumes it (0). It applies at
|
||
// once, also with a draft open: it's for trying something now.
|
||
async function pause(minutes) {
|
||
try {
|
||
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body: { minutes } } : {});
|
||
toast(minutes ? "Blocking paused" : "Blocking resumed");
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
// checkResult shows what blocking does with a name, and offers the way
|
||
// to stop it: an allow rule (it beats every list, and a rule for a parent
|
||
// domain), or, when your own block rule is for this very name, removing
|
||
// that rule (a second rule for the same name isn't allowed).
|
||
function checkResult(name, res, rules, writable) {
|
||
if (!res.length) return h("p", { class: "muted" }, "Blocking isn't used in any view yet: add a rule or a blocklist below.");
|
||
const n = name.trim().toLowerCase().replace(/\.$/, "");
|
||
const ruleById = new Map(rules.map((r) => [r.id, r]));
|
||
const blocked = res.filter((m) => m.result === "blocked");
|
||
const ownBlocks = [...new Set(blocked.map((m) => ruleById.get(m.rule_id)).filter((r) => r && r.name === n))];
|
||
const sameName = rules.filter((r) => r.name === n && !ownBlocks.includes(r));
|
||
let action = null;
|
||
if (writable && ownBlocks.length) {
|
||
action = h("p", null, h("button", { class: "small", onclick: () => removeRules(ownBlocks) }, "Remove your block rule for " + n),
|
||
h("span", { class: "muted small" }, ownBlocks.some((r) => !r.exact) ? " (it also covers the names below it)" : ""));
|
||
} else if (writable && blocked.length && !sameName.length) {
|
||
action = h("p", null, h("button", { class: "small", onclick: () => addRule(n, "allow", false, []) }, "Allow " + n),
|
||
h("span", { class: "muted small" }, " adds an allow rule for it and the names below it, which beats every list and any rule for a parent domain."));
|
||
} else if (writable && blocked.length) {
|
||
action = h("p", { class: "muted small" }, "There already is a rule for " + n + " (in other views): change it under Your rules.");
|
||
}
|
||
return h("div", null,
|
||
h("ul", { class: "plain" }, res.map((m) => {
|
||
const rule = ruleById.get(m.rule_id);
|
||
return h("li", null,
|
||
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
|
||
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
|
||
" ",
|
||
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
|
||
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
|
||
})),
|
||
action);
|
||
}
|
||
|
||
async function removeRules(list) {
|
||
try {
|
||
let r;
|
||
for (const rule of list) r = await change("DELETE", "/blocking/rules/" + rule.id);
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
async function addRule(name, action, exact, views, comment) {
|
||
try {
|
||
const r = await change("POST", "/blocking/rules", { name, action, exact, views, comment: comment || "" });
|
||
toast(describeResult(r));
|
||
rerender();
|
||
return true;
|
||
} catch (e) { toast(e.message, "err", 8000); return false; }
|
||
}
|
||
|
||
// viewPicker is a compact choice of views: it shows the choice, and opens
|
||
// a list of checkboxes (none ticked: every view that offers recursion).
|
||
function viewPicker(views, autoViews, selected = []) {
|
||
if (!views.length) return { el: null, picker: null, value: () => [] };
|
||
const boxes = views.map((v) => checkbox(v.name, selected.includes(v.name)));
|
||
const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name);
|
||
const summary = h("summary");
|
||
const show = () => {
|
||
const sel = value();
|
||
summary.textContent = sel.length ? sel.join(", ") : "automatic" + (autoViews && autoViews.length ? " (" + autoViews.join(", ") + ")" : "");
|
||
};
|
||
boxes.forEach((b) => b.input.addEventListener("change", show));
|
||
show();
|
||
const picker = h("details", { class: "view-picker" }, summary,
|
||
h("div", { class: "view-picker-list" }, h("div", { class: "muted small" }, "None ticked: every view that offers recursion"), boxes));
|
||
return { el: h("div", { class: "f" }, h("label", null, "Views"), picker), picker, value };
|
||
}
|
||
|
||
// The views a list or rule names itself ([] when automatic).
|
||
const ownViews = (x) => (x.auto_views ? [] : x.views);
|
||
|
||
// editDialog changes a list or rule: fields are [label, control, help]
|
||
// rows, and data() the PATCH body.
|
||
async function editDialog(title, path, rows, data) {
|
||
const err = h("div");
|
||
await modal(title, h("div", null, h("div", { class: "form-grid" }, rows.filter(Boolean).map(([l, c, help]) => field(l, c, help))), err), [
|
||
{ label: "Cancel", value: null },
|
||
{ label: "Save", kind: "primary", onclick: async () => {
|
||
try {
|
||
const r = await change("PATCH", path, data());
|
||
toast(describeResult(r));
|
||
rerender();
|
||
return true;
|
||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||
} },
|
||
], { sticky: true });
|
||
}
|
||
|
||
function editList(l, views, autoViews) {
|
||
const name = input({ value: l.name, class: "mono" });
|
||
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
|
||
const comment = input({ value: l.comment || "" });
|
||
const vp = viewPicker(views, autoViews, ownViews(l));
|
||
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
|
||
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
|
||
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
|
||
["Comment", comment],
|
||
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value }));
|
||
}
|
||
|
||
function editRule(r, views, autoViews) {
|
||
const name = input({ value: r.name, class: "mono" });
|
||
const action = select([["block", "Block"], ["allow", "Allow"]], r.action);
|
||
const exact = checkbox("only this exact name", !!r.exact);
|
||
const comment = input({ value: r.comment || "" });
|
||
const vp = viewPicker(views, autoViews, ownViews(r));
|
||
editDialog("Edit the rule for " + r.name, "/blocking/rules/" + r.id, [
|
||
["Action", action], ["Domain", name], ["", exact],
|
||
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
|
||
["Comment", comment],
|
||
], () => ({ name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.value }));
|
||
}
|
||
|
||
// An open view picker closes when you click elsewhere.
|
||
document.addEventListener("click", (ev) => {
|
||
for (const d of document.querySelectorAll("details.view-picker[open]")) if (!d.contains(ev.target)) d.open = false;
|
||
});
|
||
|
||
function viewsCell(x) {
|
||
if (!x.views.length) return h("span", { class: "muted" }, "none");
|
||
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
|
||
}
|
||
|
||
function rulesPanel(rules, views, autoViews, writable) {
|
||
const name = input({ placeholder: "ads.example.com", class: "mono" });
|
||
const action = select([["block", "Block"], ["allow", "Allow"]], "block");
|
||
const exact = checkbox("only this exact name", false);
|
||
const comment = input({ placeholder: "optional" });
|
||
const vp = viewPicker(views, autoViews);
|
||
const err = h("div");
|
||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||
ev.preventDefault();
|
||
try {
|
||
const r = await change("POST", "/blocking/rules", { name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.value });
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { mount(err, errorBox(e)); }
|
||
} },
|
||
h("div", { class: "f" }, h("label", null, "Action"), action),
|
||
h("div", { class: "f grow" }, h("label", null, "Domain"), name),
|
||
h("div", { class: "f" }, h("label", null, " "), exact),
|
||
h("div", { class: "f" }, h("label", null, "Comment"), comment),
|
||
vp.el,
|
||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule")));
|
||
return h("div", { class: "panel" },
|
||
h("h2", null, "Your rules"),
|
||
h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."),
|
||
writable ? [form, err] : null,
|
||
table([
|
||
{ label: "Action", render: (r) => [r.action === "block" ? badge("block", "err") : badge("allow", "ok"), r.pending ? [" ", pendingBadge(r.pending)] : null] },
|
||
{ label: "Domain", render: (r) => [h("span", { class: "mono" }, r.name), r.exact ? null : h("span", { class: "muted small" }, " and below")] },
|
||
views.length ? { label: "Views", render: viewsCell } : null,
|
||
{ label: "Comment", render: (r) => h("span", { class: "muted" }, r.comment || "") },
|
||
writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||
h("button", { class: "small", onclick: () => editRule(r, views, autoViews) }, "Edit"), " ",
|
||
h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
||
}
|
||
|
||
function listsPanel(lists, views, autoViews, writable) {
|
||
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
|
||
const url = input({ placeholder: "https://…", class: "mono" });
|
||
const vp = viewPicker(views, autoViews);
|
||
const err = h("div");
|
||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||
ev.preventDefault();
|
||
try {
|
||
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() });
|
||
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
|
||
rerender();
|
||
} catch (e) { mount(err, errorBox(e)); }
|
||
} },
|
||
h("div", { class: "f" }, h("label", null, "Name"), name),
|
||
h("div", { class: "f grow" }, h("label", null, "URL"), url),
|
||
vp.el,
|
||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
|
||
const used = new Set(lists.map((l) => l.url));
|
||
const picks = suggestions.filter(([, u]) => !used.has(u));
|
||
const totalMB = lists.reduce((a, l) => a + (l.memory_mb || 0), 0);
|
||
return h("div", { class: "panel" },
|
||
h("h2", null, "Blocklists"),
|
||
h("p", { class: "muted small" }, "jarld downloads each list, checks it for changes daily, and keeps using the previous version if an update fails. Hosts, plain domain and Adblock (||domain^) lists work; Adblock entries also cover the names below them."),
|
||
writable ? [form, err] : null,
|
||
writable && picks.length ? h("p", { class: "small" }, h("span", { class: "muted" }, "Well-known lists: "),
|
||
picks.map(([n, u, d], i) => [i ? " · " : "", h("a", { href: "#", title: d + "\n" + u, onclick: (ev) => { ev.preventDefault(); name.value = n; url.value = u; name.focus(); } }, n)])) : null,
|
||
table([
|
||
{ label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] },
|
||
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
|
||
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
|
||
views.length ? { label: "Views", render: viewsCell } : null,
|
||
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
|
||
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
|
||
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
|
||
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
|
||
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ",
|
||
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
|
||
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
|
||
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||
].filter(Boolean), lists, { empty: "No blocklists yet.", rowClass: (l) => l.pending ? "pending-" + l.pending : l.disabled ? "disabled" : "" }),
|
||
totalMB ? h("p", { class: "muted small" }, "named needs about " + totalMB + " MB of memory for these lists (an estimate). A list costs memory in every view it applies to, so pick views if some don't need it.") : null);
|
||
}
|
||
|
||
function shortURL(u) {
|
||
const s = u.replace(/^https?:\/\//, "");
|
||
return s.length > 48 ? s.slice(0, 22) + "…" + s.slice(-24) : s;
|
||
}
|
||
|
||
async function refresh(l) {
|
||
toast("Downloading " + l.name + "…", "ok", 2000);
|
||
try {
|
||
const r = await api("POST", "/blocking/lists/" + l.id + "/refresh");
|
||
if (r.status && r.status.error) toast(l.name + ": " + r.status.error, "err", 10000);
|
||
else toast(l.name + ": " + r.status.entries.toLocaleString() + " domains");
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
async function toggle(l) {
|
||
try {
|
||
const r = await change("PATCH", "/blocking/lists/" + l.id, { disabled: !l.disabled });
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
async function del(path, what) {
|
||
if (!(await confirmDialog("Delete", "Delete " + what + "?", "Delete", "danger"))) return;
|
||
try {
|
||
const r = await change("DELETE", path);
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|