add domain check endpoint and dashboard search box

GET /blocking/check/{domain} — returns whether a domain is blocked,
the reason (exact match, parent domain, allowlist, disabled), and
the matching rule. Dashboard sidebar has a "Check Domain" search
box with inline results and one-click allow button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-20 12:58:25 +02:00
parent 0658ed7310
commit 7e29f3cb57
3 changed files with 122 additions and 0 deletions

View File

@@ -519,6 +519,22 @@ body {
<!-- Sidebar --> <!-- Sidebar -->
<div class="sidebar"> <div class="sidebar">
<!-- Blocklist check -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Check Domain</span>
</div>
<div class="panel-body">
<form class="override-form" onsubmit="return checkDomain(event)" style="margin-bottom:0;border-bottom:none;padding-bottom:0;">
<div class="override-form-row">
<input type="text" id="checkDomain" placeholder="Is this domain blocked?" required style="flex:3">
<button type="submit" class="btn" style="background:var(--violet);color:white;flex-shrink:0;">Check</button>
</div>
</form>
<div id="checkResult" style="display:none;margin-top:0.6rem;padding:0.5rem 0.6rem;border-radius:5px;font-family:var(--font-mono);font-size:0.72rem;"></div>
</div>
</div>
<!-- Active overrides --> <!-- Active overrides -->
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -834,6 +850,35 @@ async function allowDomain(domain) {
} catch (err) {} } catch (err) {}
} }
async function checkDomain(event) {
event.preventDefault();
const domain = document.getElementById('checkDomain').value.trim();
const el = document.getElementById('checkResult');
if (!domain) return false;
try {
const result = await fetchJSON('/blocking/check/' + encodeURIComponent(domain));
el.style.display = 'block';
if (result.blocked) {
el.style.background = 'rgba(181, 68, 58, 0.1)';
el.style.color = 'var(--rose)';
el.innerHTML = `<strong>Blocked</strong> — ${result.reason}` +
(result.matched_rule ? `<br>Rule: <code>${result.matched_rule}</code>` : '') +
` <button class="btn-delete" onclick="allowDomain('${domain}')" style="color:var(--emerald);font-size:0.7rem;margin-left:0.4rem;">allow</button>`;
} else {
el.style.background = 'rgba(82, 122, 82, 0.1)';
el.style.color = 'var(--emerald)';
el.innerHTML = `<strong>Allowed</strong> — ${result.reason}` +
(result.matched_rule ? `<br>Rule: <code>${result.matched_rule}</code>` : '');
}
} catch (err) {
el.style.display = 'block';
el.style.background = 'rgba(181, 68, 58, 0.1)';
el.style.color = 'var(--rose)';
el.textContent = 'Error: ' + err.message;
}
return false;
}
// Initial load + polling // Initial load + polling
refresh(); refresh();
setInterval(refresh, 2000); setInterval(refresh, 2000);

View File

@@ -37,6 +37,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/blocking/pause", post(blocking_pause)) .route("/blocking/pause", post(blocking_pause))
.route("/blocking/allowlist", get(blocking_allowlist)) .route("/blocking/allowlist", get(blocking_allowlist))
.route("/blocking/allowlist", post(blocking_allowlist_add)) .route("/blocking/allowlist", post(blocking_allowlist_add))
.route("/blocking/check/{domain}", get(blocking_check))
.route( .route(
"/blocking/allowlist/{domain}", "/blocking/allowlist/{domain}",
delete(blocking_allowlist_remove), delete(blocking_allowlist_remove),
@@ -532,6 +533,14 @@ async fn blocking_pause(
Json(serde_json::json!({ "paused_minutes": req.minutes })) Json(serde_json::json!({ "paused_minutes": req.minutes }))
} }
async fn blocking_check(
State(ctx): State<Arc<ServerCtx>>,
Path(domain): Path<String>,
) -> Json<crate::blocklist::BlockCheckResult> {
let result = ctx.blocklist.lock().unwrap().check(&domain);
Json(result)
}
async fn blocking_allowlist(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<String>> { async fn blocking_allowlist(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<String>> {
let list = ctx.blocklist.lock().unwrap().allowlist(); let list = ctx.blocklist.lock().unwrap().allowlist();
Json(list) Json(list)

View File

@@ -12,6 +12,44 @@ pub struct BlocklistStore {
last_refresh: Option<Instant>, last_refresh: Option<Instant>,
} }
#[derive(serde::Serialize)]
pub struct BlockCheckResult {
pub blocked: bool,
pub reason: String,
pub matched_rule: Option<String>,
}
impl BlockCheckResult {
fn blocked(rule: &str, reason: &str) -> Self {
Self {
blocked: true,
reason: reason.to_string(),
matched_rule: Some(rule.to_string()),
}
}
fn allowed(rule: &str, reason: &str) -> Self {
Self {
blocked: false,
reason: reason.to_string(),
matched_rule: Some(rule.to_string()),
}
}
fn not_blocked() -> Self {
Self {
blocked: false,
reason: "not in blocklist".to_string(),
matched_rule: None,
}
}
fn disabled() -> Self {
Self {
blocked: false,
reason: "blocking is disabled".to_string(),
matched_rule: None,
}
}
}
pub struct BlocklistStats { pub struct BlocklistStats {
pub enabled: bool, pub enabled: bool,
pub paused: bool, pub paused: bool,
@@ -73,6 +111,36 @@ impl BlocklistStore {
false false
} }
/// Check if a domain is blocked and return the reason.
pub fn check(&self, domain: &str) -> BlockCheckResult {
let domain = domain.to_lowercase();
if !self.enabled {
return BlockCheckResult::disabled();
}
if self.allowlist.contains(&domain) {
return BlockCheckResult::allowed(&domain, "exact match in allowlist");
}
if self.domains.contains(&domain) {
return BlockCheckResult::blocked(&domain, "exact match in blocklist");
}
let mut d = domain.as_str();
while let Some(dot) = d.find('.') {
d = &d[dot + 1..];
if self.allowlist.contains(d) {
return BlockCheckResult::allowed(d, "parent domain in allowlist");
}
if self.domains.contains(d) {
return BlockCheckResult::blocked(d, "parent domain in blocklist");
}
}
BlockCheckResult::not_blocked()
}
/// Atomically swap in a new domain set. Build the set outside the lock, /// Atomically swap in a new domain set. Build the set outside the lock,
/// then call this to swap — keeps lock hold time sub-microsecond. /// then call this to swap — keeps lock hold time sub-microsecond.
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) { pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {