Add SIGNKICK LEVEL mode: sign only kicks by users without op access
All checks were successful
CI / check (push) Successful in 5m19s
All checks were successful
CI / check (push) Successful in 5m19s
This commit is contained in:
parent
ae90388fbf
commit
140b19ee04
9 changed files with 72 additions and 8 deletions
|
|
@ -1846,6 +1846,7 @@ pub struct ChannelView {
|
|||
pub email: String,
|
||||
// ChanServ SET options.
|
||||
pub signkick: bool,
|
||||
pub signkick_level: bool,
|
||||
pub private: bool,
|
||||
pub peace: bool,
|
||||
pub secureops: bool,
|
||||
|
|
@ -1869,6 +1870,7 @@ pub struct ChannelView {
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChanSetting {
|
||||
SignKick,
|
||||
SignKickLevel,
|
||||
Private,
|
||||
Peace,
|
||||
SecureOps,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
|||
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) {
|
||||
// SIGNKICK: attribute the kick to the requester. LEVEL mode signs only kicks
|
||||
// by users without op-level access, leaving trusted ops' kicks unsigned.
|
||||
if let Some(c) = db.channel(chan) {
|
||||
let kicker_op = from.account.is_some_and(|a| c.is_op(a));
|
||||
if c.signkick && (!c.signkick_level || !kicker_op) {
|
||||
reason = format!("{reason} (requested by {})", from.nick);
|
||||
}
|
||||
}
|
||||
ctx.kick(me, chan, target, &reason);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ impl Service for ChanServ {
|
|||
ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason));
|
||||
}
|
||||
let mut opts = Vec::new();
|
||||
if info.signkick { opts.push("SIGNKICK"); }
|
||||
if info.signkick { opts.push(if info.signkick_level { "SIGNKICK (level)" } else { "SIGNKICK" }); }
|
||||
if info.private { opts.push("PRIVATE"); }
|
||||
if info.peace { opts.push("PEACE"); }
|
||||
if info.secureops { opts.push("SECUREOPS"); }
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
|||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("SIGNKICK") => toggle(me, from, ctx, db, chan, ChanSetting::SignKick, args.get(3).copied()),
|
||||
Some("SIGNKICK") => signkick_set(me, from, ctx, db, chan, args.get(3).copied()),
|
||||
Some("PRIVATE") => toggle(me, from, ctx, db, chan, ChanSetting::Private, args.get(3).copied()),
|
||||
Some("PEACE") => toggle(me, from, ctx, db, chan, ChanSetting::Peace, args.get(3).copied()),
|
||||
Some("SECUREOPS") => toggle(me, from, ctx, db, chan, ChanSetting::SecureOps, args.get(3).copied()),
|
||||
|
|
@ -98,14 +98,14 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
|||
Some("AUTOOP") => toggle(me, from, ctx, db, chan, ChanSetting::AutoOp, args.get(3).copied()),
|
||||
Some("KEEPTOPIC") => toggle(me, from, ctx, db, chan, ChanSetting::KeepTopic, args.get(3).copied()),
|
||||
Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()),
|
||||
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | SUCCESSOR <account>|OFF | DESC <text> | URL [address] | EMAIL [address] | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | SECUREVOICES {ON|OFF} | RESTRICTED {ON|OFF} | AUTOOP {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"),
|
||||
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | SUCCESSOR <account>|OFF | DESC <text> | URL [address] | EMAIL [address] | SIGNKICK {ON|OFF|LEVEL} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | SECUREVOICES {ON|OFF} | RESTRICTED {ON|OFF} | AUTOOP {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"),
|
||||
}
|
||||
}
|
||||
|
||||
// The SET keyword for a channel option, used in its messages.
|
||||
fn label(setting: ChanSetting) -> &'static str {
|
||||
match setting {
|
||||
ChanSetting::SignKick => "SIGNKICK",
|
||||
ChanSetting::SignKick | ChanSetting::SignKickLevel => "SIGNKICK",
|
||||
ChanSetting::Private => "PRIVATE",
|
||||
ChanSetting::Peace => "PEACE",
|
||||
ChanSetting::SecureOps => "SECUREOPS",
|
||||
|
|
@ -120,6 +120,25 @@ fn label(setting: ChanSetting) -> &'static str {
|
|||
}
|
||||
|
||||
// Flip one on/off channel option, reporting the new state.
|
||||
// SIGNKICK is three-state: ON (sign every CS KICK), LEVEL (sign only kicks by
|
||||
// users without op access), OFF. Stored as the signkick + signkick_level pair.
|
||||
fn signkick_set(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, chan: &str, arg: Option<&str>) {
|
||||
let (on, level, state) = match arg.map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("ON") => (true, false, "on"),
|
||||
Some("LEVEL") => (true, true, "level"),
|
||||
Some("OFF") => (false, false, "off"),
|
||||
_ => {
|
||||
ctx.notice(me, from.uid, "Syntax: SET <#channel> SIGNKICK {ON|OFF|LEVEL}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = db.set_channel_setting(chan, ChanSetting::SignKick, on);
|
||||
match db.set_channel_setting(chan, ChanSetting::SignKickLevel, level) {
|
||||
Ok(()) => ctx.notice(me, from.uid, t!(ctx, "\x02{name}\x02 for \x02{chan}\x02 is now \x02{state}\x02.", name = "SIGNKICK", chan = chan, state = state)),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, chan: &str, setting: ChanSetting, arg: Option<&str>) {
|
||||
let name = label(setting);
|
||||
let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ impl Db {
|
|||
let mut settings = c.settings;
|
||||
match setting {
|
||||
ChanSetting::SignKick => settings.signkick = on,
|
||||
ChanSetting::SignKickLevel => settings.signkick_level = on,
|
||||
ChanSetting::Private => settings.private = on,
|
||||
ChanSetting::Peace => settings.peace = on,
|
||||
ChanSetting::SecureOps => settings.secureops = on,
|
||||
|
|
|
|||
|
|
@ -419,6 +419,10 @@ pub struct ChanSettings {
|
|||
// Append "(requested by <nick>)" to ChanServ KICK reasons.
|
||||
#[serde(default)]
|
||||
pub signkick: bool,
|
||||
// With signkick on: sign only kicks by users WITHOUT op-level access (trusted
|
||||
// ops' kicks stay unsigned). Ignored when signkick is off.
|
||||
#[serde(default)]
|
||||
pub signkick_level: bool,
|
||||
// Hide the channel from ChanServ LIST.
|
||||
#[serde(default)]
|
||||
pub private: bool,
|
||||
|
|
|
|||
|
|
@ -650,6 +650,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
|||
url: c.url.clone(),
|
||||
email: c.email.clone(),
|
||||
signkick: c.settings.signkick,
|
||||
signkick_level: c.settings.signkick_level,
|
||||
private: c.settings.private,
|
||||
peace: c.settings.peace,
|
||||
secureops: c.settings.secureops,
|
||||
|
|
|
|||
|
|
@ -5312,6 +5312,38 @@
|
|||
assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-v"))), "founder keeps voice: {out:?}");
|
||||
}
|
||||
|
||||
// SIGNKICK ON signs every CS KICK; LEVEL exempts kickers with op access.
|
||||
#[test]
|
||||
fn signkick_level_exempts_ops() {
|
||||
use echo_chanserv::ChanServ;
|
||||
let path = std::env::temp_dir().join("echo-signkick-level.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut db = Db::open(&path, "42S");
|
||||
db.scram_iterations = 4096;
|
||||
db.register("alice", "sesame", None).unwrap();
|
||||
db.register_channel("#c", "alice").unwrap();
|
||||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||||
};
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAE".into(), nick: "eve".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||
e.handle(NetEvent::Join { uid: "000AAAAAE".into(), channel: "#c".into(), op: false });
|
||||
|
||||
to_cs(&mut e, "000AAAAAB", "SET #c SIGNKICK ON");
|
||||
let out = to_cs(&mut e, "000AAAAAB", "KICK #c eve");
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { reason, .. } if reason.contains("requested by alice"))), "signed on ON: {out:?}");
|
||||
|
||||
e.handle(NetEvent::Join { uid: "000AAAAAE".into(), channel: "#c".into(), op: false });
|
||||
to_cs(&mut e, "000AAAAAB", "SET #c SIGNKICK LEVEL");
|
||||
let out = to_cs(&mut e, "000AAAAAB", "KICK #c eve");
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { .. })), "kick still happens: {out:?}");
|
||||
assert!(!out.iter().any(|a| matches!(a, NetAction::Kick { reason, .. } if reason.contains("requested by"))), "op founder exempt on LEVEL: {out:?}");
|
||||
}
|
||||
|
||||
// ChanServ SET: description and founder transfer, founder-gated.
|
||||
#[test]
|
||||
fn chanserv_set() {
|
||||
|
|
|
|||
|
|
@ -269,7 +269,8 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re
|
|||
}
|
||||
let any = |names: &[&str]| names.iter().any(|n| flag(ci, n));
|
||||
let settings = ChanSettings {
|
||||
signkick: any(&["SIGNKICK"]),
|
||||
signkick: any(&["SIGNKICK", "SIGNKICK_LEVEL"]),
|
||||
signkick_level: any(&["SIGNKICK_LEVEL"]),
|
||||
private: any(&["PRIVATE", "CS_PRIVATE"]),
|
||||
peace: any(&["PEACE"]),
|
||||
secureops: any(&["SECUREOPS", "CS_SECUREOPS"]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue