From f0ce2d9d1b0488f620bc5852384963255ecb936c Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 10:59:15 +0000 Subject: [PATCH] Add ChanServ ACCESS list with auto-op/voice on join ACCESS <#channel> LIST|ADD|DEL manages op/voice by account (founder-only changes). Entries are ChannelAccessAdd/Del events, so they replicate and compact like the rest, and join grants +o/+v from the list. --- modules/chanserv/access.rs | 70 ++++++++++++++++++++++++++ modules/chanserv/chanserv.rs | 5 +- src/engine/db.rs | 96 +++++++++++++++++++++++++++++++++++- src/engine/mod.rs | 38 +++++++++++--- 4 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 modules/chanserv/access.rs diff --git a/modules/chanserv/access.rs b/modules/chanserv/access.rs new file mode 100644 index 0000000..180893b --- /dev/null +++ b/modules/chanserv/access.rs @@ -0,0 +1,70 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// ACCESS <#channel> LIST | ADD | DEL +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD | DEL "); + return; + }; + match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() { + None | Some("LIST") => match db.channel(chan) { + None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")), + Some(info) => { + ctx.notice(me, from.uid, format!("Access list for \x02{}\x02:", info.name)); + ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder)", info.founder)); + for a in &info.access { + ctx.notice(me, from.uid, format!(" \x02{}\x02 ({})", a.account, a.level)); + } + } + }, + Some("ADD") => { + let (Some(&account), Some(&level)) = (args.get(3), args.get(4)) else { + ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> ADD "); + return; + }; + let level = level.to_ascii_lowercase(); + if level != "op" && level != "voice" { + ctx.notice(me, from.uid, "Level must be \x02op\x02 or \x02voice\x02."); + return; + } + if !is_founder(me, from, chan, ctx, db) { + return; + } + match db.access_add(chan, account, &level) { + Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{account}\x02 to \x02{chan}\x02 as \x02{level}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some("DEL") => { + let Some(&account) = args.get(3) else { + ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> DEL "); + return; + }; + if !is_founder(me, from, chan, ctx, db) { + return; + } + match db.access_del(chan, account) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{account}\x02 from \x02{chan}\x02.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no access to \x02{chan}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + _ => ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD | DEL "), + } +} + +// True if `from` is the channel's founder; otherwise notices why and returns false. +fn is_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool { + match db.channel(chan) { + None => { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + false + } + Some(info) if from.account != Some(info.founder.as_str()) => { + ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change access.")); + false + } + Some(_) => true, + } +} diff --git a/modules/chanserv/chanserv.rs b/modules/chanserv/chanserv.rs index 812afea..623d034 100644 --- a/modules/chanserv/chanserv.rs +++ b/modules/chanserv/chanserv.rs @@ -3,6 +3,8 @@ use crate::engine::service::{Sender, Service, ServiceCtx}; #[path = "mode.rs"] mod mode; +#[path = "access.rs"] +mod access; pub struct ChanServ { pub uid: String, @@ -127,7 +129,8 @@ impl Service for ChanServ { } } Some("MODE") => mode::handle(me, from, args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02MODE\x02 <#channel> , \x02MLOCK\x02 <#channel> [modes], \x02DROP\x02 <#channel>."), + Some("ACCESS") => access::handle(me, from, args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02ACCESS\x02 <#channel>, \x02MODE\x02 <#channel> , \x02MLOCK\x02 <#channel> [modes], \x02DROP\x02 <#channel>."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/src/engine/db.rs b/src/engine/db.rs index b42dbc6..96769bf 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -41,6 +41,15 @@ pub enum Event { ChannelRegistered { name: String, founder: String, ts: u64 }, ChannelDropped { name: String }, ChannelMlock { name: String, on: String, off: String }, + ChannelAccessAdd { channel: String, account: String, level: String }, + ChannelAccessDel { channel: String, account: String }, +} + +// An access-list entry: an account and its level ("op" or "voice"). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChanAccess { + pub account: String, + pub level: String, } // A registered channel and who owns it. @@ -54,9 +63,23 @@ pub struct ChannelInfo { pub lock_on: String, #[serde(default)] pub lock_off: String, + #[serde(default)] + pub access: Vec, } impl ChannelInfo { + /// The status mode to give `account` on join, if any: +o for the founder and + /// access-list ops, +v for voices. + pub fn join_mode(&self, account: &str) -> Option<&'static str> { + if self.founder.eq_ignore_ascii_case(account) { + return Some("+o"); + } + self.access + .iter() + .find(|a| a.account.eq_ignore_ascii_case(account)) + .map(|a| if a.level == "voice" { "+v" } else { "+o" }) + } + /// The mode string services keep applied: +r plus the lock. pub fn lock_modes(&self) -> String { let mut s = format!("+r{}", self.lock_on); @@ -345,6 +368,9 @@ impl Db { if !c.lock_on.is_empty() || !c.lock_off.is_empty() { snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() }); } + for a in &c.access { + snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() }); + } } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); @@ -485,7 +511,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new() }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new() }); Ok(()) } @@ -509,6 +535,37 @@ impl Db { Ok(()) } + /// Grant `account` a level ("op"/"voice") on `channel`. + pub fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError> { + let k = key(channel); + if !self.channels.contains_key(&k) { + return Err(ChanError::NoChannel); + } + self.log + .append(Event::ChannelAccessAdd { channel: channel.to_string(), account: account.to_string(), level: level.to_string() }) + .map_err(|_| ChanError::Internal)?; + let c = self.channels.get_mut(&k).unwrap(); + c.access.retain(|a| !a.account.eq_ignore_ascii_case(account)); + c.access.push(ChanAccess { account: account.to_string(), level: level.to_string() }); + Ok(()) + } + + /// Remove `account` from `channel`'s access list. Ok(false) if not present. + pub fn access_del(&mut self, channel: &str, account: &str) -> Result { + let k = key(channel); + let Some(c) = self.channels.get(&k) else { + return Err(ChanError::NoChannel); + }; + if !c.access.iter().any(|a| a.account.eq_ignore_ascii_case(account)) { + return Ok(false); + } + self.log + .append(Event::ChannelAccessDel { channel: channel.to_string(), account: account.to_string() }) + .map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().access.retain(|a| !a.account.eq_ignore_ascii_case(account)); + Ok(true) + } + /// Unregister `name`. pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> { let k = key(name); @@ -541,7 +598,7 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new() }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new() }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -552,6 +609,17 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.access.retain(|a| !a.account.eq_ignore_ascii_case(&account)); + c.access.push(ChanAccess { account, level }); + } + } + Event::ChannelAccessDel { channel, account } => { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.access.retain(|a| !a.account.eq_ignore_ascii_case(&account)); + } + } } } @@ -719,4 +787,28 @@ mod tests { let db = Db::open(&p, "local"); assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen"); } + + // Access list drives join modes, is case-insensitive, and persists. + #[test] + fn access_list_grants_join_modes() { + let p = tmp("access"); + let mut db = Db::open(&p, "local"); + db.register_channel("#c", "boss").unwrap(); + db.access_add("#c", "alice", "op").unwrap(); + db.access_add("#c", "bob", "voice").unwrap(); + + let info = db.channel("#c").unwrap(); + assert_eq!(info.join_mode("boss"), Some("+o")); // founder + assert_eq!(info.join_mode("ALICE"), Some("+o")); // op, case-insensitive + assert_eq!(info.join_mode("bob"), Some("+v")); // voice + assert_eq!(info.join_mode("nobody"), None); + + assert!(db.access_del("#c", "alice").unwrap()); + assert!(!db.access_del("#c", "alice").unwrap()); + + drop(db); + let db = Db::open(&p, "local"); + assert_eq!(db.channel("#c").unwrap().join_mode("bob"), Some("+v"), "survives reopen"); + assert_eq!(db.channel("#c").unwrap().join_mode("alice"), None, "removal survives reopen"); + } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 37737de..8c8e501 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -191,16 +191,16 @@ impl Engine { None => Vec::new(), } } - // Auto-op the founder when they join their registered channel. + // Give the founder and access-list members their status on join. NetEvent::Join { uid, channel } => { - let founder = self.db.channel(&channel).map(|c| c.founder.clone()); let account = self.network.account_of(&uid).map(str::to_string); - match (founder, account) { - (Some(f), Some(a)) if f == a => { + let mode = account.as_deref().and_then(|a| self.db.channel(&channel).and_then(|c| c.join_mode(a))); + match mode { + Some(m) => { let from = self.chan_service.clone().unwrap_or_default(); - vec![NetAction::ChannelMode { from, channel, modes: format!("+o {uid}") }] + vec![NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }] } - _ => Vec::new(), + None => Vec::new(), } } NetEvent::Quit { uid } => { @@ -972,9 +972,14 @@ mod tests { let out = to_cs(&mut e, "000AAAAAB", "MODE #room +S"); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "+S")), "MODE applies: {out:?}"); - // A different, unidentified user cannot change modes, set the lock, or drop it. + // Founder manages the access list. + assert!(notice(&to_cs(&mut e, "000AAAAAB", "ACCESS #room ADD helper op"), "Added")); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "ACCESS #room"), "helper")); + + // A different, unidentified user cannot change modes, access, the lock, or drop it. e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() }); assert!(notice(&to_cs(&mut e, "000AAAAAC", "MODE #room +i"), "founder")); + assert!(notice(&to_cs(&mut e, "000AAAAAC", "ACCESS #room ADD x op"), "founder")); assert!(notice(&to_cs(&mut e, "000AAAAAC", "MLOCK #room +i"), "founder")); assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder")); @@ -1044,4 +1049,23 @@ mod tests { e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() }); assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into() }).is_empty(), "non-founder not opped"); } + + // An access-list voice gets +v on join. + #[test] + fn access_voice_on_join() { + let path = std::env::temp_dir().join("fedserv-access-join.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("carol", "sesame", None).unwrap(); + db.register_channel("#y", "boss").unwrap(); + db.access_add("#y", "carol", "voice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let mut e = Engine::new(vec![Box::new(ns)], db); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "carol".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#y".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#y" && modes == "+v 000AAAAAB")), "{out:?}"); + } }