diff --git a/api/src/lib.rs b/api/src/lib.rs index f32f6b7..2571176 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -457,6 +457,15 @@ pub struct IgnoreView { pub expires: Option, } +// A news item held by OperServ (its kind is implied by which list it came from). +#[derive(Debug, Clone)] +pub struct NewsView { + pub id: u64, + pub text: String, + pub setter: String, + pub ts: u64, +} + #[derive(Debug, Clone)] pub struct BotView { pub nick: String, @@ -718,6 +727,10 @@ pub trait Store { fn account_note(&self, account: &str) -> Option; fn set_channel_note(&mut self, channel: &str, note: Option) -> bool; fn channel_note(&self, channel: &str) -> Option; + // News items shown on connect (logon) / login (oper), oper-only to manage. + fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64; + fn news_del(&mut self, id: u64) -> bool; + fn news(&self, kind: &str) -> Vec; fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>; fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>; fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>; diff --git a/operserv/src/lib.rs b/operserv/src/lib.rs index 9b8e168..690ef02 100644 --- a/operserv/src/lib.rs +++ b/operserv/src/lib.rs @@ -23,6 +23,8 @@ mod stats; mod svs; #[path = "info.rs"] mod info; +#[path = "news.rs"] +mod news; pub struct OperServ { pub uid: String, @@ -58,6 +60,7 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("SVSNICK") => svs::nick(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("SVSJOIN") => svs::join(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("INFO") => info::handle(me, from, args, ctx, db), + Some(cmd) if cmd.eq_ignore_ascii_case("NEWS") => news::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx), None => help(me, from, ctx), Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")), @@ -66,5 +69,5 @@ impl Service for OperServ { } fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { - ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes)."); + ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements)."); } diff --git a/operserv/src/news.rs b/operserv/src/news.rs new file mode 100644 index 0000000..0054c2c --- /dev/null +++ b/operserv/src/news.rs @@ -0,0 +1,72 @@ +use fedserv_api::{Priv, Sender, ServiceCtx, Store}; + +// NEWS ADD | NEWS DEL | NEWS LIST +// [LOGON|OPER]: manage the announcements shown to users on connect (logon) and +// to operators on login (oper). Admin-only. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — NEWS needs the \x02admin\x02 privilege."); + return; + } + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => add(me, from, args.get(2).copied(), args.get(3..).unwrap_or(&[]), ctx, db), + Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), args.get(3).copied(), ctx, db), + Some("LIST") | Some("VIEW") => list(me, from, args.get(2).copied(), ctx, db), + _ => ctx.notice(me, from.uid, "Syntax: NEWS ADD | NEWS DEL | NEWS LIST [LOGON|OPER]"), + } +} + +fn add(me: &str, from: &Sender, kind: Option<&str>, text: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let (Some(kind), false) = (kind.and_then(parse_kind), text.is_empty()) else { + ctx.notice(me, from.uid, "Syntax: NEWS ADD "); + return; + }; + let setter = from.account.unwrap_or(from.nick); + db.news_add(kind, &text.join(" "), setter); + ctx.notice(me, from.uid, format!("Added a \x02{kind}\x02 news item.")); +} + +fn del(me: &str, from: &Sender, kind: Option<&str>, num: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let (Some(kind), Some(n)) = (kind.and_then(parse_kind), num.and_then(|n| n.parse::().ok())) else { + ctx.notice(me, from.uid, "Syntax: NEWS DEL "); + return; + }; + match db.news(kind).get(n.wrapping_sub(1)) { + Some(item) if n >= 1 => { + db.news_del(item.id); + ctx.notice(me, from.uid, format!("Removed \x02{kind}\x02 news item \x02{n}\x02.")); + } + _ => ctx.notice(me, from.uid, format!("There's no \x02{kind}\x02 news item \x02{n}\x02.")), + } +} + +fn list(me: &str, from: &Sender, kind: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let kinds: &[&str] = match kind.map(|k| k.to_ascii_uppercase()) { + Some(k) if k == "LOGON" => &["logon"], + Some(k) if k == "OPER" => &["oper"], + Some(_) => { + ctx.notice(me, from.uid, "Kind must be \x02LOGON\x02 or \x02OPER\x02."); + return; + } + None => &["logon", "oper"], + }; + let mut any = false; + for k in kinds { + let items = db.news(k); + for (i, item) in items.iter().enumerate() { + any = true; + ctx.notice(me, from.uid, format!("{}. [\x02{k}\x02] {} — by {}", i + 1, item.text, item.setter)); + } + } + if !any { + ctx.notice(me, from.uid, "No news items."); + } +} + +fn parse_kind(s: &str) -> Option<&'static str> { + match s.to_ascii_uppercase().as_str() { + "LOGON" => Some("logon"), + "OPER" => Some("oper"), + _ => None, + } +} diff --git a/src/engine/db.rs b/src/engine/db.rs index c2180bb..bafe7cd 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -30,7 +30,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, + AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -185,6 +185,9 @@ pub enum Event { // A staff note set or cleared (OperServ INFO). AccountOperNoteSet { account: String, note: Option }, ChannelOperNoteSet { channel: String, note: Option }, + // News items (OperServ NEWS). The stable `id` makes deletion order-independent. + NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 }, + NewsDeleted { id: u64 }, // Network bans (OperServ AKILL / SQLINE). Global: a ban covers the whole // network, so every node holds the list and re-applies it at burst. `kind` // is the ircd X-line type and defaults to "G" for records predating it. @@ -243,7 +246,9 @@ impl Event { | Event::AccountExpiryWarned { .. } | Event::AccountOperNoteSet { .. } | Event::AkillAdded { .. } - | Event::AkillRemoved { .. } => Scope::Global, + | Event::AkillRemoved { .. } + | Event::NewsAdded { .. } + | Event::NewsDeleted { .. } => Scope::Global, Event::ChannelRegistered { .. } | Event::ChannelDropped { .. } | Event::ChannelMlock { .. } @@ -333,6 +338,29 @@ pub struct Ignore { pub expires: Option, } +// A news item (OperServ NEWS): a `kind` ("logon" shown to everyone on connect, +// "oper" shown to operators on login), the text, who set it, and when. Carries a +// stable id (assigned at add time, embedded in the log) so deletion is order- +// independent under replay. +#[derive(Debug, Clone)] +pub struct News { + pub id: u64, + pub kind: String, + pub text: String, + pub setter: String, + pub ts: u64, +} + +// The network-wide lists that aren't accounts/channels/grouped/bots: the network +// bans and the news items. Bundled so `apply` threads one parameter for them +// (and stays within its argument budget as more are added). +#[derive(Debug, Clone, Default)] +pub struct NetData { + pub akills: Vec, + pub news: Vec, + pub news_seq: u64, +} + // A memo left for an account (MemoServ). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Memo { @@ -900,8 +928,8 @@ pub struct Db { // HostServ node config: the self-serve offer menu, the forbidden-pattern // blocklist, and the auto-vhost template. host_cfg: HostConfig, - // Network bans (OperServ AKILL), in insertion order. - akills: Vec, + // Network-wide replicated lists: bans (AKILL/SQLINE) and news. + net: NetData, // Services ignores (OperServ IGNORE), node-local and in-memory. ignores: Vec, } @@ -954,12 +982,12 @@ impl Db { let mut grouped = HashMap::new(); let mut bots = HashMap::new(); let mut host_cfg = HostConfig::default(); - let mut akills = Vec::new(); + let mut net = NetData::default(); for event in events { - apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut akills, event); + apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, akills, ignores: Vec::new() } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new() } } /// Fold an entry authored by another node into the store — the services-side @@ -975,7 +1003,7 @@ impl Db { _ => None, }; if let Some(event) = self.log.ingest(entry)? { - apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.akills, event); + apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.net, event); } if let Some((name, prev_home)) = watched { match (prev_home, self.account(&name).map(|c| c.home.clone())) { @@ -1050,9 +1078,12 @@ impl Db { } // Compaction is a good moment to forget akills that have already expired. let now = now(); - for a in self.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) { + for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) { snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }); } + for n in &self.net.news { + snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts }); + } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); Ok(()) @@ -1822,37 +1853,67 @@ impl Db { /// Add (or refresh) a `kind` network ban. Returns whether it was newly added. pub fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask); - let fresh = !self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now())); + let fresh = !self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now())); self.log .append(Event::AkillAdded { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }) .map_err(|_| RegError::Internal)?; - self.akills.retain(|a| !same(a)); - self.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }); + self.net.akills.retain(|a| !same(a)); + self.net.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires }); Ok(fresh) } /// Lift a `kind` network ban. Returns whether a live one was removed. pub fn akill_del(&mut self, kind: &str, mask: &str) -> Result { let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask); - let existed = self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now())); + 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: kind.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?; - self.akills.retain(|a| !same(a)); + self.net.akills.retain(|a| !same(a)); Ok(true) } /// The live network bans (expired ones hidden lazily), oldest first. pub fn akills(&self) -> Vec { let now = now(); - self.akills + self.net.akills .iter() .filter(|a| a.expires.is_none_or(|e| e > now)) .map(|a| AkillView { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }) .collect() } + /// Add a news item of `kind` ("logon"/"oper"). Returns its stable id. + pub fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 { + let id = self.net.news_seq; + let ts = now(); + let _ = self.log.append(Event::NewsAdded { id, kind: kind.to_string(), text: text.to_string(), setter: setter.to_string(), ts }); + self.net.news_seq = id + 1; + self.net.news.push(News { id, kind: kind.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: &str) -> Vec { + self.net + .news + .iter() + .filter(|n| n.kind == kind) + .map(|n| NewsView { id: n.id, text: n.text.clone(), setter: n.setter.clone(), ts: n.ts }) + .collect() + } + /// 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)); @@ -2535,7 +2596,7 @@ fn owns_over(held: &Account, claim: &Account) -> bool { // Fold one event into the store. Shared by log replay (`open`) and gossip // ingest, so both routes reconstruct identical state. -fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, bots: &mut HashMap, host_cfg: &mut HostConfig, akills: &mut Vec, event: Event) { +fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, bots: &mut HashMap, host_cfg: &mut HostConfig, net: &mut NetData, event: Event) { match event { Event::AccountRegistered(a) => { // Resolve a concurrent registration of the same name deterministically: @@ -2796,11 +2857,11 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { // Keyed by (kind, mask), case-insensitive: a re-add refreshes in // place, so replaying over a snapshot stays idempotent. - akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask))); - akills.push(Akill { kind, mask, setter, reason, ts, expires }); + net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask))); + net.akills.push(Akill { kind, mask, setter, reason, ts, expires }); } Event::AkillRemoved { kind, mask } => { - akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask))); + net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask))); } Event::AccountExpiryWarned { account } => { if let Some(a) = accounts.get_mut(&key(&account)) { @@ -2822,6 +2883,15 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + net.news_seq = net.news_seq.max(id + 1); + if !net.news.iter().any(|n| n.id == id) { + net.news.push(News { id, kind, text, setter, ts }); + } + } + Event::NewsDeleted { id } => { + net.news.retain(|n| n.id != id); + } } } @@ -3084,6 +3154,15 @@ impl Store for Db { fn channel_note(&self, channel: &str) -> Option { Db::channel_note(self, channel) } + fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 { + Db::news_add(self, kind, text, setter) + } + fn news_del(&mut self, id: u64) -> bool { + Db::news_del(self, id) + } + fn news(&self, kind: &str) -> Vec { + Db::news(self, kind) + } fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { Db::register_channel(self, name, founder) } @@ -3287,9 +3366,9 @@ mod tests { ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None, }; let converge = |first: &Account, second: &Account| { - let (mut acc, mut ch, mut gr, mut bo, mut hc, mut ak) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), Vec::new()); - apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(first.clone()))); - apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(second.clone()))); + let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default()); + apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(first.clone()))); + apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(second.clone()))); acc["alice"].password_hash.clone() }; // Earlier registration wins, regardless of which claim applies first. diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 69da789..80dd025 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -596,6 +596,19 @@ impl Engine { } } + // The news items of `kind` as server-sourced notices to `uid`, each tagged + // with `label` so it reads as an announcement. Empty if we have no SID yet. + fn news_notices(&self, kind: &str, label: &str, uid: &str) -> Vec { + if self.sid.is_empty() { + return Vec::new(); + } + self.db + .news(kind) + .into_iter() + .map(|n| NetAction::Notice { from: self.sid.clone(), to: uid.to_string(), text: format!("[\x02{label}\x02] {}", n.text) }) + .collect() + } + // Announce a line to the staff audit channel, if one is configured, sourced // from the services server. Used for actions that aren't tied to a command. fn audit(&self, text: String) { @@ -700,8 +713,9 @@ impl Engine { let evout = match event { NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], NetEvent::UserConnect { uid, nick, host } => { - self.network.user_connect(uid, nick, host); - Vec::new() + self.network.user_connect(uid.clone(), nick, host); + // Greet the arriving user with the logon news. + self.news_notices("logon", "News", &uid) } NetEvent::NickChange { uid, nick } => { self.network.user_nick_change(&uid, nick); @@ -888,6 +902,12 @@ impl Engine { self.network.set_account(target, value); // A login is activity: keep the account from expiring. self.db.mark_account_seen(value, self.now_secs()); + // An operator logging in is shown the staff (oper) news. + if self.oper_privs(value).any() { + for note in self.news_notices("oper", "Staff News", target) { + self.emit_irc(note); + } + } } } } @@ -1582,6 +1602,8 @@ fn audit_summary(event: &db::Event) -> Option { Some(_) => format!("set a staff note on \x02{channel}\x02"), None => format!("cleared the staff note on \x02{channel}\x02"), }, + NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 news item"), + NewsDeleted { .. } => "removed a news item".to_string(), AccountNoExpire { account, on } => { let verb = if *on { "pinned" } else { "unpinned" }; format!("{verb} account \x02{account}\x02 against expiry") @@ -4024,6 +4046,70 @@ mod tests { } } + // OperServ NEWS: logon news greets everyone on connect, oper news greets an + // operator on login; ADD/DEL/LIST are admin-only. + #[test] + fn operserv_news_logon_and_oper() { + use fedserv_operserv::OperServ; + let path = std::env::temp_dir().join("fedserv-osnews.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("staff", "password1", None).unwrap(); + db.register("boss", "password1", None).unwrap(); + db.register("plain", "password1", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(OperServ { uid: "42SAAAAAH".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + e.set_opers(opers); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + e.set_irc_out(tx); + let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); + let has = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + + os(&mut e, "000AAAAAS", "NEWS ADD LOGON Welcome to the network"); + os(&mut e, "000AAAAAS", "NEWS ADD OPER Staff meeting at 5"); + + // A new user is greeted with the logon news on connect, not the oper news. + let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAP" && text.contains("Welcome to the network"))), "logon news on connect: {out:?}"); + assert!(!has(&out, "Staff meeting"), "a plain user doesn't get oper news"); + + // An operator logging in is shown the oper news (over the outbound path). + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() }); + while rx.try_recv().is_ok() {} // drain the connect's logon news + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + let mut oper_news = false; + while let Ok(a) = rx.try_recv() { + if let NetAction::Notice { to, text, .. } = a { + if to == "000AAAAAB" && text.contains("Staff meeting at 5") { + oper_news = true; + } + } + } + assert!(oper_news, "oper news shown to an operator on login"); + + // LIST shows both; DEL removes the logon item so a later connect is quiet. + assert!(has(&os(&mut e, "000AAAAAS", "NEWS LIST"), "Welcome to the network"), "list shows logon"); + os(&mut e, "000AAAAAS", "NEWS DEL LOGON 1"); + let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAQ".into(), nick: "late".into(), host: "h".into() }); + assert!(!has(&out, "Welcome to the network"), "logon news gone after DEL"); + + // A non-admin (the unidentified plain user) can't manage news. + assert!(has(&os(&mut e, "000AAAAAP", "NEWS ADD LOGON sneaky"), "Access denied"), "non-admin refused"); + } + // OperServ INFO staff notes: an admin annotates an account/channel; the note // shows in that service's INFO to opers only, never to the account owner. #[test] diff --git a/src/grpc.rs b/src/grpc.rs index a68ca72..e499918 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -170,7 +170,9 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::AccountExpiryWarned { .. } | Event::ChannelExpiryWarned { .. } | Event::AccountOperNoteSet { .. } - | Event::ChannelOperNoteSet { .. } => return None, + | Event::ChannelOperNoteSet { .. } + | Event::NewsAdded { .. } + | Event::NewsDeleted { .. } => return None, }; Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) }) }