diff --git a/api/src/lib.rs b/api/src/lib.rs index 0e518ef..2a45804 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -294,6 +294,7 @@ pub struct ChannelView { // ChanServ SET options. pub signkick: bool, pub private: bool, + pub peace: bool, } // A single ChanServ SET option, named for the typed `set_channel_setting` call. @@ -301,6 +302,7 @@ pub struct ChannelView { pub enum ChanSetting { SignKick, Private, + Peace, } impl ChannelView { @@ -316,6 +318,18 @@ impl ChannelView { .map(|a| if a.level == "voice" { "+v" } else { "+o" }) } + // A comparable access rank for PEACE: founder 3, op 2, voice 1, none 0. + pub fn access_rank(&self, account: Option<&str>) -> u8 { + let Some(acc) = account else { return 0 }; + if self.founder.eq_ignore_ascii_case(acc) { + return 3; + } + self.access + .iter() + .find(|a| a.account.eq_ignore_ascii_case(acc)) + .map_or(0, |a| if a.level == "voice" { 1 } else { 2 }) + } + // Whether this account holds channel-operator access (founder or op level). pub fn is_op(&self, account: &str) -> bool { self.join_mode(account) == Some("+o") diff --git a/chanserv/src/ban.rs b/chanserv/src/ban.rs index 26295c6..b4c2b08 100644 --- a/chanserv/src/ban.rs +++ b/chanserv/src/ban.rs @@ -15,6 +15,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here.")); return; }; + if super::peace_blocks(me, from, chan, &target, ctx, net, db) { + return; + } let host = net.host_of(&target).unwrap_or("*"); ctx.channel_mode(me, chan, &format!("+b *!*@{host}")); let reason = if args.len() > 3 { args[3..].join(" ") } else { "Banned".to_string() }; diff --git a/chanserv/src/kick.rs b/chanserv/src/kick.rs index 5344aef..8ae0403 100644 --- a/chanserv/src/kick.rs +++ b/chanserv/src/kick.rs @@ -15,6 +15,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here.")); return; }; + if super::peace_blocks(me, from, chan, target, ctx, net, db) { + return; + } let mut reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() }; // SIGNKICK: attribute the kick to whoever asked for it. if db.channel(chan).is_some_and(|c| c.signkick) { diff --git a/chanserv/src/lib.rs b/chanserv/src/lib.rs index ac9368a..56f15bd 100644 --- a/chanserv/src/lib.rs +++ b/chanserv/src/lib.rs @@ -104,6 +104,7 @@ impl Service for ChanServ { let mut opts = Vec::new(); if info.signkick { opts.push("SIGNKICK"); } if info.private { opts.push("PRIVATE"); } + if info.peace { opts.push("PEACE"); } if !opts.is_empty() { ctx.notice(me, from.uid, format!(" Options : {}", opts.join(", "))); } @@ -235,6 +236,20 @@ fn require_op(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dy } } +// PEACE: returns true (and notices) if `from` may not act against `target_uid` +// because the channel has PEACE set and the target holds equal-or-higher access. +fn peace_blocks(me: &str, from: &Sender, chan: &str, target_uid: &str, ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) -> bool { + let Some(info) = db.channel(chan) else { return false }; + if !info.peace { + return false; + } + if info.access_rank(net.account_of(target_uid)) >= info.access_rank(from.account) { + ctx.notice(me, from.uid, "\x02PEACE\x02 is set: you can't act against someone with equal or higher access."); + return true; + } + false +} + // Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is // implicit for a registered channel and is ignored here. fn parse_mlock(spec: &str) -> (String, String) { diff --git a/chanserv/src/op.rs b/chanserv/src/op.rs index e62aa6a..52d2b71 100644 --- a/chanserv/src/op.rs +++ b/chanserv/src/op.rs @@ -22,5 +22,9 @@ pub fn handle(me: &str, from: &Sender, mode: &str, args: &[&str], ctx: &mut Serv }, None => from.uid.to_string(), }; + // PEACE only guards removing status (-o/-v) from an equal-or-higher user. + if mode.starts_with('-') && super::peace_blocks(me, from, chan, &target, ctx, net, db) { + return; + } ctx.channel_mode(me, chan, &format!("{mode} {target}")); } diff --git a/chanserv/src/set.rs b/chanserv/src/set.rs index 7ffe211..e77e03d 100644 --- a/chanserv/src/set.rs +++ b/chanserv/src/set.rs @@ -41,7 +41,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } Some("SIGNKICK") => toggle(me, from, ctx, db, chan, ChanSetting::SignKick, args.get(3).copied(), "SIGNKICK"), Some("PRIVATE") => toggle(me, from, ctx, db, chan, ChanSetting::Private, args.get(3).copied(), "PRIVATE"), - _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | DESC | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF}"), + Some("PEACE") => toggle(me, from, ctx, db, chan, ChanSetting::Peace, args.get(3).copied(), "PEACE"), + _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | DESC | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF}"), } } diff --git a/src/engine/db.rs b/src/engine/db.rs index 64f5a52..f8407df 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -157,6 +157,9 @@ pub struct ChanSettings { // Hide the channel from ChanServ LIST. #[serde(default)] pub private: bool, + // Forbid using ChanServ to act against someone with equal-or-higher access. + #[serde(default)] + pub peace: bool, } // A registered channel and who owns it. @@ -586,7 +589,7 @@ impl Db { if !c.entrymsg.is_empty() { snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() }); } - if c.settings.signkick || c.settings.private { + if c.settings.signkick || c.settings.private || c.settings.peace { snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings }); } } @@ -1115,6 +1118,7 @@ impl Db { match setting { ChanSetting::SignKick => settings.signkick = on, ChanSetting::Private => settings.private = on, + ChanSetting::Peace => settings.peace = on, } self.log .append(Event::ChannelSettingsSet { channel: channel.to_string(), settings }) @@ -1479,6 +1483,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView { entrymsg: c.entrymsg.clone(), signkick: c.settings.signkick, private: c.settings.private, + peace: c.settings.peace, } } @@ -1613,6 +1618,21 @@ mod tests { assert!(s.signkick && !s.private, "settings replay from the log"); } + // Access ranks order founder > op > voice > none for PEACE comparisons. + #[test] + fn access_rank_orders_founder_op_voice() { + let mut db = Db::open(&tmp("rank"), "N1"); + db.register_channel("#c", "boss").unwrap(); + db.access_add("#c", "op1", "op").unwrap(); + db.access_add("#c", "v1", "voice").unwrap(); + let cv = Store::channel(&db, "#c").unwrap(); + assert_eq!(cv.access_rank(Some("boss")), 3); + assert_eq!(cv.access_rank(Some("OP1")), 2, "case-insensitive"); + assert_eq!(cv.access_rank(Some("v1")), 1); + assert_eq!(cv.access_rank(Some("nobody")), 0); + assert_eq!(cv.access_rank(None), 0); + } + // A wrong code is tolerated a few times, then the code is burned so it can't // be ground down online even though it is short. #[test]