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

@@ -37,6 +37,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/blocking/pause", post(blocking_pause))
.route("/blocking/allowlist", get(blocking_allowlist))
.route("/blocking/allowlist", post(blocking_allowlist_add))
.route("/blocking/check/{domain}", get(blocking_check))
.route(
"/blocking/allowlist/{domain}",
delete(blocking_allowlist_remove),
@@ -532,6 +533,14 @@ async fn blocking_pause(
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>> {
let list = ctx.blocklist.lock().unwrap().allowlist();
Json(list)

View File

@@ -12,6 +12,44 @@ pub struct BlocklistStore {
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 enabled: bool,
pub paused: bool,
@@ -73,6 +111,36 @@ impl BlocklistStore {
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,
/// then call this to swap — keeps lock hold time sub-microsecond.
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {