use super::*; impl Db { /// Add (or refresh) a `kind` network ban. Returns whether it was newly added. pub fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { // Storage/gossip keep the ircd's line token; the API is typed. let wire = kind.wire(); // One timestamp for the logged event AND the in-memory record: two `now()` // calls could straddle a second, so a peer replaying the event would store a // different ts than us and gossip state would never converge. let ts = now(); let same = |a: &Akill| a.kind == wire && a.mask.eq_ignore_ascii_case(mask); let fresh = !self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > ts)); self.log .append(Event::AkillAdded { kind: wire.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts, expires }) .map_err(|_| RegError::Internal)?; self.net.akills.retain(|a| !same(a)); self.net.akills.push(Akill { kind: wire.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts, expires }); Ok(fresh) } /// Lift a `kind` network ban. Returns whether a live one was removed. pub fn akill_del(&mut self, kind: XlineKind, mask: &str) -> Result { let wire = kind.wire(); let same = |a: &Akill| a.kind == wire && a.mask.eq_ignore_ascii_case(mask); let existed = self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now())); if !existed { return Ok(false); } self.log.append(Event::AkillRemoved { kind: wire.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?; self.net.akills.retain(|a| !same(a)); Ok(true) } /// The live network bans (expired ones hidden lazily), oldest first. A stored /// kind we don't model (peer-gossiped) is skipped from the typed view. pub fn akills(&self) -> Vec { let now = now(); self.net.akills .iter() .filter(|a| a.expires.is_none_or(|e| e > now)) .filter_map(|a| { XlineKind::from_wire(&a.kind).map(|kind| AkillView { kind, mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }) }) .collect() } /// Add (or refresh) a spam filter. Returns whether it was newly added. pub fn filter_add(&mut self, pattern: &str, action: &str, flags: &str, setter: &str, reason: &str, expires: Option) -> Result { // One timestamp for the event and the in-memory record (see akill_add). let ts = now(); let same = |f: &Filter| f.pattern.eq_ignore_ascii_case(pattern); let fresh = !self.net.filters.iter().any(|f| same(f) && f.expires.is_none_or(|e| e > ts)); self.log .append(Event::FilterAdded { pattern: pattern.to_string(), action: action.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires }) .map_err(|_| RegError::Internal)?; self.net.filters.retain(|f| !same(f)); self.net.filters.push(Filter { pattern: pattern.to_string(), action: action.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires }); Ok(fresh) } /// Remove a spam filter by pattern. Returns whether a live one existed. pub fn filter_del(&mut self, pattern: &str) -> Result { let same = |f: &Filter| f.pattern.eq_ignore_ascii_case(pattern); let existed = self.net.filters.iter().any(|f| same(f) && f.expires.is_none_or(|e| e > now())); if !existed { return Ok(false); } self.log.append(Event::FilterRemoved { pattern: pattern.to_string() }).map_err(|_| RegError::Internal)?; self.net.filters.retain(|f| !same(f)); Ok(true) } /// The live spam filters (expired ones hidden lazily), oldest first. pub fn filters(&self) -> Vec { let now = now(); self.net.filters .iter() .filter(|f| f.expires.is_none_or(|e| e > now)) .map(|f| echo_api::FilterView { pattern: f.pattern.clone(), action: f.action.clone(), flags: f.flags.clone(), reason: f.reason.clone(), setter: f.setter.clone(), ts: f.ts, expires: f.expires }) .collect() } /// Add a registration ban of `kind` ("NICK"/"CHAN"/"EMAIL") for `mask`. /// Returns whether it was new. pub fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result { // The store persists/gossips the wire token; the API is typed. let wire = kind.wire(); // One timestamp for the event AND the in-memory record (see akill_add). let ts = now(); let same = |f: &Forbid| f.kind == wire && f.mask.eq_ignore_ascii_case(mask); let fresh = !self.net.forbids.iter().any(same); self.log .append(Event::ForbidAdded { kind: wire.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts }) .map_err(|_| RegError::Internal)?; self.net.forbids.retain(|f| !same(f)); self.net.forbids.push(Forbid { kind: wire.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts }); Ok(fresh) } /// Remove a registration ban of `kind` for `mask`. Returns whether one existed. pub fn forbid_del(&mut self, kind: ForbidKind, mask: &str) -> Result { let wire = kind.wire(); let same = |f: &Forbid| f.kind == wire && f.mask.eq_ignore_ascii_case(mask); if !self.net.forbids.iter().any(same) { return Ok(false); } self.log.append(Event::ForbidRemoved { kind: wire.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?; self.net.forbids.retain(|f| !same(f)); Ok(true) } /// All registration bans, oldest first. A stored kind that no longer parses /// (corrupt/old log) is skipped rather than shown. pub fn forbids(&self) -> Vec { self.net.forbids .iter() .filter_map(|f| { ForbidKind::from_name(&f.kind).map(|kind| ForbidView { kind, mask: f.mask.clone(), setter: f.setter.clone(), reason: f.reason.clone(), ts: f.ts }) }) .collect() } /// The reason `name` is forbidden for `kind` registration (glob), if it is. pub fn is_forbidden(&self, kind: ForbidKind, name: &str) -> Option { let wire = kind.wire(); self.net.forbids .iter() .find(|f| f.kind == wire && glob_match(&f.mask, name)) .map(|f| f.reason.clone()) } /// Add (or replace) an OperServ NOTIFY watch. Returns whether it was new. pub fn notify_add(&mut self, mask: &str, flags: &str, reason: &str, setter: &str, expires: Option) -> Result { let ts = now(); let same = |n: &Notify| n.mask.eq_ignore_ascii_case(mask); let fresh = !self.net.notifies.iter().any(same); self.log .append(Event::NotifyAdded { mask: mask.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires }) .map_err(|_| RegError::Internal)?; self.net.notifies.retain(|n| !same(n)); self.net.notifies.push(Notify { mask: mask.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires }); Ok(fresh) } /// Remove a NOTIFY watch by exact mask. Returns whether one existed. pub fn notify_del(&mut self, mask: &str) -> Result { let same = |n: &Notify| n.mask.eq_ignore_ascii_case(mask); if !self.net.notifies.iter().any(same) { return Ok(false); } self.log.append(Event::NotifyRemoved { mask: mask.to_string() }).map_err(|_| RegError::Internal)?; self.net.notifies.retain(|n| !same(n)); Ok(true) } /// Drop every NOTIFY watch. Returns how many were removed. One event per entry /// so a peer replaying the log folds to the same empty state. pub fn notify_clear(&mut self) -> Result { let masks: Vec = self.net.notifies.iter().map(|n| n.mask.clone()).collect(); let count = masks.len(); for mask in masks { self.log.append(Event::NotifyRemoved { mask }).map_err(|_| RegError::Internal)?; } self.net.notifies.clear(); Ok(count) } /// All live (unexpired) NOTIFY watches, oldest first. pub fn notifies(&self) -> Vec { let now = now(); self.net.notifies .iter() .filter(|n| n.expires.is_none_or(|e| e > now)) .map(|n| NotifyView { mask: n.mask.clone(), flags: n.flags.clone(), reason: n.reason.clone(), setter: n.setter.clone(), ts: n.ts, expires: n.expires }) .collect() } /// Whether any live watch exists — a cheap gate so the engine can skip the /// per-event identity lookup when the list is empty. pub fn any_notifies(&self) -> bool { let now = now(); self.net.notifies.iter().any(|n| n.expires.is_none_or(|e| e > now)) } /// Union of the flag letters of every live watch matching this user and/or /// channel: a `#`/`&` mask tests the channel name, any other tests the user's /// identity. The engine tests the result for a given event's letter. pub fn notify_flags(&self, target: Option<&echo_api::BanTarget>, chan: Option<&str>) -> String { // Exclusions come first, same mask grammar as a watch: a `#channel` mask // mutes events in that channel, any other mask mutes a user (e.g. `*/*` for // PyLink relay clients). A match means the event never reaches the feed. let excluded = self.notify_exclude.iter().any(|m| { if let Some(srv) = m.strip_prefix("server:").or_else(|| m.strip_prefix("via:")) { // `server:` mutes everyone on a matching server — the only handle // on a relay whose users carry clean nicks (no /network suffix). target.is_some_and(|t| glob_match(&srv.to_ascii_lowercase(), &t.server.to_ascii_lowercase())) } else if m.starts_with('#') || m.starts_with('&') { chan.is_some_and(|c| glob_match(m, c)) } else { target.is_some_and(|t| echo_api::akick_matches(m, t) || (!m.contains(['@', '!']) && glob_match(m, t.nick))) } }); if excluded { return String::new(); } let now = now(); let mut flags = String::new(); for n in self.net.notifies.iter().filter(|n| n.expires.is_none_or(|e| e > now)) { let hit = if n.mask.starts_with('#') || n.mask.starts_with('&') { chan.is_some_and(|c| glob_match(&n.mask, c)) } else { // Host/extban masks go through the ban matcher; a bare nick glob // (no '@'/'!') also matches the nick, so `baddie*` works too. target.is_some_and(|t| { echo_api::akick_matches(&n.mask, t) || (!n.mask.contains(['@', '!']) && glob_match(&n.mask, t.nick)) }) }; if hit { flags.push_str(&n.flags); } } flags } /// Add (or replace) a session-limit exception for an IP-mask. pub fn session_except_add(&mut self, mask: &str, limit: u32, reason: &str) { let _ = self.log.append(Event::SessionExceptionAdded { mask: mask.to_string(), limit, reason: reason.to_string() }); self.net.sess_exceptions.retain(|e| !e.mask.eq_ignore_ascii_case(mask)); self.net.sess_exceptions.push(SessionException { mask: mask.to_string(), limit, reason: reason.to_string() }); } /// Remove a session-limit exception. Returns whether one existed. pub fn session_except_del(&mut self, mask: &str) -> bool { let existed = self.net.sess_exceptions.iter().any(|e| e.mask.eq_ignore_ascii_case(mask)); if existed { let _ = self.log.append(Event::SessionExceptionRemoved { mask: mask.to_string() }); self.net.sess_exceptions.retain(|e| !e.mask.eq_ignore_ascii_case(mask)); } existed } /// The session-limit exceptions, as (mask, limit, reason). pub fn session_exceptions(&self) -> Vec<(String, u32, String)> { self.net.sess_exceptions.iter().map(|e| (e.mask.clone(), e.limit, e.reason.clone())).collect() } /// The session allowance for `ip` from any matching exception (the most /// permissive wins), or None if none matches. A limit of 0 means unlimited. pub fn session_exception_for(&self, ip: &str) -> Option { self.net .sess_exceptions .iter() .filter(|e| glob_match(&e.mask.to_ascii_lowercase(), &ip.to_ascii_lowercase())) .map(|e| e.limit) .max_by_key(|&l| if l == 0 { u32::MAX } else { l }) } /// Grant runtime operator privileges to an account (replaces any existing), /// optionally expiring at an absolute unix time. pub fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option) { let _ = self.log.append(Event::OperGranted { account: account.to_string(), privs: privs.clone(), expires }); self.net.opers.insert(key(account), OperGrant { privs, expires }); } /// Revoke a runtime operator grant. Returns whether one existed. pub fn oper_revoke(&mut self, account: &str) -> bool { if !self.net.opers.contains_key(&key(account)) { return false; } let _ = self.log.append(Event::OperRevoked { account: account.to_string() }); self.net.opers.remove(&key(account)); true } /// The live runtime operator grants, as (account, privilege-names, expiry); /// expired ones are hidden. pub fn opers_list(&self) -> Vec<(String, Vec, Option)> { let now = now(); self.net .opers .iter() .filter(|(_, g)| g.expires.is_none_or(|e| e > now)) .map(|(a, g)| (a.clone(), g.privs.clone(), g.expires)) .collect() } /// The runtime privileges granted to an account as of `now`, if the grant is /// present and unexpired (config opers are merged in separately by the engine). pub fn oper_privs_of(&self, account: &str, now: u64) -> Option { self.net .opers .get(&key(account)) .filter(|g| g.expires.is_none_or(|e| e > now)) .map(|g| Privs::from_names(&g.privs)) } /// Add a news item of `kind`. Returns its stable id. pub fn news_add(&mut self, kind: NewsKind, text: &str, setter: &str) -> u64 { let id = self.net.news_seq; let ts = now(); let wire = kind.wire(); let _ = self.log.append(Event::NewsAdded { id, kind: wire.to_string(), text: text.to_string(), setter: setter.to_string(), ts }); self.net.news_seq = id + 1; self.net.news.push(News { id, kind: wire.to_string(), text: text.to_string(), setter: setter.to_string(), ts }); id } /// Delete a news item by id. Returns whether one existed. pub fn news_del(&mut self, id: u64) -> bool { let existed = self.net.news.iter().any(|n| n.id == id); if existed { let _ = self.log.append(Event::NewsDeleted { id }); self.net.news.retain(|n| n.id != id); } existed } /// The news items of `kind`, oldest first. pub fn news(&self, kind: NewsKind) -> Vec { let wire = kind.wire(); self.net .news .iter() .filter(|n| n.kind == wire) .map(|n| NewsView { id: n.id, text: n.text.clone(), setter: n.setter.clone(), ts: n.ts }) .collect() } /// Whether account identity is owned by an external authority. pub fn external_accounts(&self) -> bool { self.external_accounts } /// Set external-account mode (from config, at startup). pub fn set_external_accounts(&mut self, on: bool) { self.external_accounts = on; } /// Toggle the look-alike / mixed-script REGISTER guard (from `[register]`). pub fn set_confusable_check(&mut self, on: bool) { self.confusable_check = on; } /// The network defence level (5 = normal, 1 = full lockdown). pub fn defcon(&self) -> u8 { self.defcon } /// Set the defence level, clamped to 1..=5. pub fn set_defcon(&mut self, level: u8) { self.defcon = level.clamp(1, 5); } /// Whether the services are in operator read-only lockdown. pub fn readonly(&self) -> bool { self.log.readonly } /// Enter or leave read-only lockdown: while on, locally-authored writes are /// refused. Ephemeral (never persisted); gossip ingestion is unaffected. pub fn set_readonly(&mut self, on: bool) { self.log.readonly = on; } /// Whether new nick/account registrations are frozen (defcon 3 or lower). pub fn registrations_frozen(&self) -> bool { self.defcon <= 3 } /// Whether new channel registrations are frozen (defcon 4 or lower). pub fn channel_regs_frozen(&self) -> bool { self.defcon <= 4 } /// Jupe a server name: allocate a fake sid, store it, return the sid to /// introduce (or the existing sid if the name is already juped). pub fn jupe_add(&mut self, name: &str, reason: &str) -> String { if let Some(j) = self.net.jupes.iter().find(|j| j.name.eq_ignore_ascii_case(name)) { return j.sid.clone(); } let sid = jupe_sid(self.net.jupe_seq); // Persisted (Local scope) so a juped server stays juped across a restart. let _ = self.log.append(Event::JupeAdded { name: name.to_string(), sid: sid.clone(), reason: reason.to_string() }); self.net.jupe_seq += 1; self.net.jupes.push(Jupe { name: name.to_string(), sid: sid.clone(), reason: reason.to_string() }); sid } /// Lift a jupe. Returns the sid to squit if it existed. pub fn jupe_del(&mut self, name: &str) -> Option { let sid = self.net.jupes.iter().find(|j| j.name.eq_ignore_ascii_case(name)).map(|j| j.sid.clone())?; let _ = self.log.append(Event::JupeRemoved { name: name.to_string() }); self.net.jupes.retain(|j| !j.name.eq_ignore_ascii_case(name)); Some(sid) } /// The juped servers, as (name, sid, reason). pub fn jupes(&self) -> Vec<(String, String, String)> { self.net.jupes.iter().map(|j| (j.name.clone(), j.sid.clone(), j.reason.clone())).collect() } /// The persisted stat counters, to seed the live registry on startup. pub fn persisted_stats(&self) -> std::collections::BTreeMap { self.net.stats.clone() } /// The persisted per-channel activity, to seed BOTSTATS on startup. pub fn persisted_chan_stats(&self) -> ChanStats { self.net.chan_stats.clone() } /// Snapshot the live stats (shared counters + per-channel activity) to the log /// so they survive a restart. pub fn persist_stats( &mut self, counters: &std::collections::BTreeMap, chan_stats: ChanStats, ) -> std::io::Result<()> { self.log.append(Event::StatsSet { counters: counters.iter().map(|(k, v)| (k.clone(), *v)).collect(), channels: chan_stats.clone(), })?; self.net.stats = counters.clone(); self.net.chan_stats = chan_stats; Ok(()) } /// Snapshot the recent-incident ring to the log so OperServ LOGSEARCH survives /// a restart. `seq` is the id counter; `incidents` are (id, ts, summary). pub fn persist_incidents(&mut self, seq: u64, incidents: Vec<(String, u64, String)>) -> std::io::Result<()> { self.log.append(Event::IncidentsSet { incident_seq: seq, incidents: incidents.clone() })?; self.net.incident_seq = seq; self.net.incidents = incidents; Ok(()) } /// The persisted incident snapshot (id counter, incidents), for reseeding the /// live ring at startup. pub fn persisted_incidents(&self) -> (u64, Vec<(String, u64, String)>) { (self.net.incident_seq, self.net.incidents.clone()) } /// File an abuse report, rate-limited per reporter. Returns the new report's /// id, or None if the reporter filed one too recently. pub fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option { const COOLDOWN: u64 = 30; let now = now(); let key = reporter.to_ascii_lowercase(); if self.report_times.get(&key).is_some_and(|&t| now.saturating_sub(t) < COOLDOWN) { return None; } self.report_times.insert(key, now); let id = self.net.report_seq; let _ = self.log.append(Event::ReportFiled { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now }); self.net.report_seq = id + 1; self.net.reports.push(Report { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now, open: true }); Some(id) } /// Close (resolve) a report. Returns whether an open one was closed. pub fn report_close(&mut self, id: u64) -> bool { let closed = self.net.reports.iter().any(|r| r.id == id && r.open); if closed { let _ = self.log.append(Event::ReportClosed { id }); if let Some(r) = self.net.reports.iter_mut().find(|r| r.id == id) { r.open = false; } } closed } /// Delete a report entirely. Returns whether one existed. pub fn report_del(&mut self, id: u64) -> bool { let existed = self.net.reports.iter().any(|r| r.id == id); if existed { let _ = self.log.append(Event::ReportDeleted { id }); self.net.reports.retain(|r| r.id != id); } existed } /// The reports, newest first. `open_only` hides closed ones. pub fn reports(&self, open_only: bool) -> Vec { self.net .reports .iter() .rev() .filter(|r| !open_only || r.open) .map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open }) .collect() } /// A single report by id, if present. pub fn report(&self, id: u64) -> Option { self.net.reports.iter().find(|r| r.id == id).map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open }) } /// Open a help-desk ticket, rate-limited per requester (shares the report /// throttle namespace). Returns the new ticket's id, or None if too soon. pub fn help_request(&mut self, requester: &str, message: &str) -> Option { const COOLDOWN: u64 = 30; let now = now(); let tkey = format!("help:{}", requester.to_ascii_lowercase()); if self.report_times.get(&tkey).is_some_and(|&t| now.saturating_sub(t) < COOLDOWN) { return None; } self.report_times.insert(tkey, now); let id = self.net.help_seq; let _ = self.log.append(Event::HelpRequested { id, requester: requester.to_string(), message: message.to_string(), ts: now }); self.net.help_seq = id + 1; self.net.help.push(HelpTicket { id, requester: requester.to_string(), message: message.to_string(), ts: now, handler: None, open: true }); Some(id) } /// Assign an open ticket to a handler. Returns whether an open one was taken. pub fn help_take(&mut self, id: u64, handler: &str) -> bool { let ok = self.net.help.iter().any(|t| t.id == id && t.open); if ok { let _ = self.log.append(Event::HelpTaken { id, handler: handler.to_string() }); if let Some(t) = self.net.help.iter_mut().find(|t| t.id == id) { t.handler = Some(handler.to_string()); } } ok } /// Close a ticket. Returns whether an open one was closed. pub fn help_close(&mut self, id: u64) -> bool { let ok = self.net.help.iter().any(|t| t.id == id && t.open); if ok { let _ = self.log.append(Event::HelpClosed { id }); if let Some(t) = self.net.help.iter_mut().find(|t| t.id == id) { t.open = false; } } ok } /// The tickets, newest first. `open_only` hides closed ones. pub fn help_tickets(&self, open_only: bool) -> Vec { self.net .help .iter() .rev() .filter(|t| !open_only || t.open) .map(|t| HelpView { id: t.id, requester: t.requester.clone(), message: t.message.clone(), ts: t.ts, handler: t.handler.clone(), open: t.open }) .collect() } /// A single ticket by id. pub fn help_ticket(&self, id: u64) -> Option { self.net.help.iter().find(|t| t.id == id).map(|t| HelpView { id: t.id, requester: t.requester.clone(), message: t.message.clone(), ts: t.ts, handler: t.handler.clone(), open: t.open }) } /// The id of the oldest open, unassigned ticket (for HelpServ NEXT). pub fn help_next_open(&self) -> Option { self.net.help.iter().find(|t| t.open && t.handler.is_none()).map(|t| t.id) } /// Register a new group (name must start with `!`). Founder is an account. pub fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { if !name.starts_with('!') || name.len() < 2 { return Err(ChanError::InvalidPattern); } if self.group(name).is_some() { return Err(ChanError::Exists); } let ts = now(); self.log.append(Event::GroupRegistered { name: name.to_string(), founder: founder.to_string(), ts }).map_err(|_| ChanError::Internal)?; self.net.groups.push(Group { name: name.to_string(), founder: founder.to_string(), ts, members: Vec::new() }); Ok(()) } /// Drop a group. pub fn group_drop(&mut self, name: &str) -> Result<(), ChanError> { if self.group(name).is_none() { return Err(ChanError::NoChannel); } self.log.append(Event::GroupDropped { name: name.to_string() }).map_err(|_| ChanError::Internal)?; let k = key(name); self.net.groups.retain(|g| key(&g.name) != k); Ok(()) } /// Upsert a group member with `flags` (empty = a plain member). pub fn group_set_flags(&mut self, name: &str, account: &str, flags: &str) -> Result<(), ChanError> { let k = key(name); let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else { return Err(ChanError::NoChannel); }; self.log.append(Event::GroupFlagsSet { name: name.to_string(), account: account.to_string(), flags: flags.to_string() }).map_err(|_| ChanError::Internal)?; g.members.retain(|m| !m.account.eq_ignore_ascii_case(account)); g.members.push(GroupMember { account: account.to_string(), flags: flags.to_string() }); Ok(()) } /// Remove a member from a group. Returns whether one was present. pub fn group_del_member(&mut self, name: &str, account: &str) -> Result { let k = key(name); let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else { return Err(ChanError::NoChannel); }; if !g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account)) { return Ok(false); } self.log.append(Event::GroupMemberDel { name: name.to_string(), account: account.to_string() }).map_err(|_| ChanError::Internal)?; g.members.retain(|m| !m.account.eq_ignore_ascii_case(account)); Ok(true) } /// Transfer a group's founder to another account. pub fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { let k = key(name); let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else { return Err(ChanError::NoChannel); }; self.log.append(Event::GroupFounderSet { name: name.to_string(), founder: founder.to_string() }).map_err(|_| ChanError::Internal)?; g.founder = founder.to_string(); Ok(()) } /// A group view (founder + members), if it exists. pub fn group(&self, name: &str) -> Option { let k = key(name); self.net.groups.iter().find(|g| key(&g.name) == k).map(|g| GroupView { name: g.name.clone(), founder: g.founder.clone(), members: g.members.iter().map(|m| echo_api::GroupMemberView { account: m.account.clone(), flags: m.flags.clone() }).collect(), }) } /// Every group's name, sorted. pub fn groups(&self) -> Vec { let mut names: Vec = self.net.groups.iter().map(|g| g.name.clone()).collect(); names.sort(); names } /// The groups an account belongs to (founder or member). pub fn groups_of(&self, account: &str) -> Vec { self.net .groups .iter() .filter(|g| g.founder.eq_ignore_ascii_case(account) || g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account))) .map(|g| g.name.clone()) .collect() } /// Whether an account is in a group (its founder or a member). // Whether `account` inherits a group's channel access (when the group is on a // channel's access list): the group's founder, or a member holding the 'c' // (channel-access) flag. Plain membership is not enough — the flag gates it. fn group_grants_channel_access(&self, name: &str, account: &str) -> bool { let k = key(name); self.net.groups.iter().find(|g| key(&g.name) == k).is_some_and(|g| { g.founder.eq_ignore_ascii_case(account) || g.members.iter().any(|m| { m.account.eq_ignore_ascii_case(account) && echo_api::GroupFlags::parse(&m.flags).has(echo_api::GroupFlag::Channel) }) }) } /// An account's effective channel capabilities, combining its direct access /// entry with any `!group` access entry it belongs to (the interconnection /// that lets a channel grant access to a whole group). pub fn channel_caps(&self, channel: &str, account: &str) -> Caps { let Some(c) = self.channels.get(&key(channel)) else { return Caps::default() }; if c.founder.eq_ignore_ascii_case(account) { return echo_api::level_caps("founder"); } let mut caps = Caps::default(); for a in &c.access { let applies = match a.account.strip_prefix('!') { Some(g) => self.group_grants_channel_access(&format!("!{g}"), account), None => a.account.eq_ignore_ascii_case(account), }; if applies { caps = caps.union(echo_api::level_caps(&a.level)); } } caps } /// A user's join status mode for a channel, group-aware. pub fn channel_join_mode(&self, channel: &str, account: &str) -> Option<&'static str> { self.channel_caps(channel, account).auto } /// Add a services ignore, replacing any existing entry for the same mask. pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option) { self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask)); self.ignores.push(Ignore { mask: mask.to_string(), reason: reason.to_string(), expires }); } /// Remove a services ignore. Returns whether a live one was removed. pub fn ignore_del(&mut self, mask: &str) -> bool { let now = now(); let existed = self.ignores.iter().any(|i| i.mask.eq_ignore_ascii_case(mask) && i.expires.is_none_or(|e| e > now)); self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask)); existed } /// The live services ignores (expired hidden lazily), oldest first. pub fn ignores(&self) -> Vec { let now = now(); self.ignores .iter() .filter(|i| i.expires.is_none_or(|e| e > now)) .map(|i| IgnoreView { mask: i.mask.clone(), reason: i.reason.clone(), expires: i.expires }) .collect() } /// Whether a user is currently ignored by services. A mask with an `@` is /// matched against `nick!*@host` (we don't track ident); a bare mask against /// the nick. Expired entries are swept as they're encountered. pub fn is_ignored(&mut self, nick: &str, host: &str) -> bool { let now = now(); self.ignores.retain(|i| i.expires.is_none_or(|e| e > now)); let full = format!("{}!*@{}", nick.to_ascii_lowercase(), host.to_ascii_lowercase()); let nick_lc = nick.to_ascii_lowercase(); self.ignores.iter().any(|i| { let m = i.mask.to_ascii_lowercase(); if m.contains('@') { glob_match(&m, &full) } else { glob_match(&m, &nick_lc) } }) } /// The account's suspension record, if any (shown in INFO even once expired). pub fn suspension(&self, account: &str) -> Option { self.accounts .get(&key(account)) .and_then(|a| a.suspension.as_ref()) .map(|s| SuspensionView { by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires }) } }