chanserv: SET PEACE (block acting against equal-or-higher access)
PEACE stops a channel op from using ChanServ KICK/BAN/DEOP/DEVOICE against someone whose access rank (founder>op>voice) is equal to or above their own. Adds a typed access_rank on ChannelView and a peace_blocks guard in those commands. Same typed-settings path as SIGNKICK/PRIVATE; shown in INFO.
This commit is contained in:
parent
75019c867a
commit
3bf6b2be77
7 changed files with 62 additions and 2 deletions
|
|
@ -294,6 +294,7 @@ pub struct ChannelView {
|
||||||
// ChanServ SET options.
|
// ChanServ SET options.
|
||||||
pub signkick: bool,
|
pub signkick: bool,
|
||||||
pub private: bool,
|
pub private: bool,
|
||||||
|
pub peace: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single ChanServ SET option, named for the typed `set_channel_setting` call.
|
// A single ChanServ SET option, named for the typed `set_channel_setting` call.
|
||||||
|
|
@ -301,6 +302,7 @@ pub struct ChannelView {
|
||||||
pub enum ChanSetting {
|
pub enum ChanSetting {
|
||||||
SignKick,
|
SignKick,
|
||||||
Private,
|
Private,
|
||||||
|
Peace,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelView {
|
impl ChannelView {
|
||||||
|
|
@ -316,6 +318,18 @@ impl ChannelView {
|
||||||
.map(|a| if a.level == "voice" { "+v" } else { "+o" })
|
.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).
|
// Whether this account holds channel-operator access (founder or op level).
|
||||||
pub fn is_op(&self, account: &str) -> bool {
|
pub fn is_op(&self, account: &str) -> bool {
|
||||||
self.join_mode(account) == Some("+o")
|
self.join_mode(account) == Some("+o")
|
||||||
|
|
|
||||||
|
|
@ -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."));
|
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if super::peace_blocks(me, from, chan, &target, ctx, net, db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let host = net.host_of(&target).unwrap_or("*");
|
let host = net.host_of(&target).unwrap_or("*");
|
||||||
ctx.channel_mode(me, chan, &format!("+b *!*@{host}"));
|
ctx.channel_mode(me, chan, &format!("+b *!*@{host}"));
|
||||||
let reason = if args.len() > 3 { args[3..].join(" ") } else { "Banned".to_string() };
|
let reason = if args.len() > 3 { args[3..].join(" ") } else { "Banned".to_string() };
|
||||||
|
|
|
||||||
|
|
@ -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."));
|
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
|
||||||
return;
|
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() };
|
let mut reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() };
|
||||||
// SIGNKICK: attribute the kick to whoever asked for it.
|
// SIGNKICK: attribute the kick to whoever asked for it.
|
||||||
if db.channel(chan).is_some_and(|c| c.signkick) {
|
if db.channel(chan).is_some_and(|c| c.signkick) {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ impl Service for ChanServ {
|
||||||
let mut opts = Vec::new();
|
let mut opts = Vec::new();
|
||||||
if info.signkick { opts.push("SIGNKICK"); }
|
if info.signkick { opts.push("SIGNKICK"); }
|
||||||
if info.private { opts.push("PRIVATE"); }
|
if info.private { opts.push("PRIVATE"); }
|
||||||
|
if info.peace { opts.push("PEACE"); }
|
||||||
if !opts.is_empty() {
|
if !opts.is_empty() {
|
||||||
ctx.notice(me, from.uid, format!(" Options : {}", opts.join(", ")));
|
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
|
// 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.
|
// implicit for a registered channel and is ignored here.
|
||||||
fn parse_mlock(spec: &str) -> (String, String) {
|
fn parse_mlock(spec: &str) -> (String, String) {
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,9 @@ pub fn handle(me: &str, from: &Sender, mode: &str, args: &[&str], ctx: &mut Serv
|
||||||
},
|
},
|
||||||
None => from.uid.to_string(),
|
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}"));
|
ctx.channel_mode(me, chan, &format!("{mode} {target}"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("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"),
|
Some("PRIVATE") => toggle(me, from, ctx, db, chan, ChanSetting::Private, args.get(3).copied(), "PRIVATE"),
|
||||||
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | DESC <text> | 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 <account> | DESC <text> | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,9 @@ pub struct ChanSettings {
|
||||||
// Hide the channel from ChanServ LIST.
|
// Hide the channel from ChanServ LIST.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub private: bool,
|
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.
|
// A registered channel and who owns it.
|
||||||
|
|
@ -586,7 +589,7 @@ impl Db {
|
||||||
if !c.entrymsg.is_empty() {
|
if !c.entrymsg.is_empty() {
|
||||||
snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() });
|
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 });
|
snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1115,6 +1118,7 @@ impl Db {
|
||||||
match setting {
|
match setting {
|
||||||
ChanSetting::SignKick => settings.signkick = on,
|
ChanSetting::SignKick => settings.signkick = on,
|
||||||
ChanSetting::Private => settings.private = on,
|
ChanSetting::Private => settings.private = on,
|
||||||
|
ChanSetting::Peace => settings.peace = on,
|
||||||
}
|
}
|
||||||
self.log
|
self.log
|
||||||
.append(Event::ChannelSettingsSet { channel: channel.to_string(), settings })
|
.append(Event::ChannelSettingsSet { channel: channel.to_string(), settings })
|
||||||
|
|
@ -1479,6 +1483,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
||||||
entrymsg: c.entrymsg.clone(),
|
entrymsg: c.entrymsg.clone(),
|
||||||
signkick: c.settings.signkick,
|
signkick: c.settings.signkick,
|
||||||
private: c.settings.private,
|
private: c.settings.private,
|
||||||
|
peace: c.settings.peace,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1613,6 +1618,21 @@ mod tests {
|
||||||
assert!(s.signkick && !s.private, "settings replay from the log");
|
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
|
// 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.
|
// be ground down online even though it is short.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue