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,
|
pub email: String,
|
||||||
// ChanServ SET options.
|
// ChanServ SET options.
|
||||||
pub signkick: bool,
|
pub signkick: bool,
|
||||||
|
pub signkick_level: bool,
|
||||||
pub private: bool,
|
pub private: bool,
|
||||||
pub peace: bool,
|
pub peace: bool,
|
||||||
pub secureops: bool,
|
pub secureops: bool,
|
||||||
|
|
@ -1869,6 +1870,7 @@ pub struct ChannelView {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ChanSetting {
|
pub enum ChanSetting {
|
||||||
SignKick,
|
SignKick,
|
||||||
|
SignKickLevel,
|
||||||
Private,
|
Private,
|
||||||
Peace,
|
Peace,
|
||||||
SecureOps,
|
SecureOps,
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
||||||
return;
|
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 the requester. LEVEL mode signs only kicks
|
||||||
if db.channel(chan).is_some_and(|c| c.signkick) {
|
// 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);
|
reason = format!("{reason} (requested by {})", from.nick);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ctx.kick(me, chan, target, &reason);
|
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));
|
ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason));
|
||||||
}
|
}
|
||||||
let mut opts = Vec::new();
|
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.private { opts.push("PRIVATE"); }
|
||||||
if info.peace { opts.push("PEACE"); }
|
if info.peace { opts.push("PEACE"); }
|
||||||
if info.secureops { opts.push("SECUREOPS"); }
|
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."),
|
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("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("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()),
|
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("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("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()),
|
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.
|
// The SET keyword for a channel option, used in its messages.
|
||||||
fn label(setting: ChanSetting) -> &'static str {
|
fn label(setting: ChanSetting) -> &'static str {
|
||||||
match setting {
|
match setting {
|
||||||
ChanSetting::SignKick => "SIGNKICK",
|
ChanSetting::SignKick | ChanSetting::SignKickLevel => "SIGNKICK",
|
||||||
ChanSetting::Private => "PRIVATE",
|
ChanSetting::Private => "PRIVATE",
|
||||||
ChanSetting::Peace => "PEACE",
|
ChanSetting::Peace => "PEACE",
|
||||||
ChanSetting::SecureOps => "SECUREOPS",
|
ChanSetting::SecureOps => "SECUREOPS",
|
||||||
|
|
@ -120,6 +120,25 @@ fn label(setting: ChanSetting) -> &'static str {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flip one on/off channel option, reporting the new state.
|
// 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>) {
|
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 name = label(setting);
|
||||||
let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() {
|
let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,7 @@ impl Db {
|
||||||
let mut settings = c.settings;
|
let mut settings = c.settings;
|
||||||
match setting {
|
match setting {
|
||||||
ChanSetting::SignKick => settings.signkick = on,
|
ChanSetting::SignKick => settings.signkick = on,
|
||||||
|
ChanSetting::SignKickLevel => settings.signkick_level = on,
|
||||||
ChanSetting::Private => settings.private = on,
|
ChanSetting::Private => settings.private = on,
|
||||||
ChanSetting::Peace => settings.peace = on,
|
ChanSetting::Peace => settings.peace = on,
|
||||||
ChanSetting::SecureOps => settings.secureops = on,
|
ChanSetting::SecureOps => settings.secureops = on,
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,10 @@ pub struct ChanSettings {
|
||||||
// Append "(requested by <nick>)" to ChanServ KICK reasons.
|
// Append "(requested by <nick>)" to ChanServ KICK reasons.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub signkick: bool,
|
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.
|
// Hide the channel from ChanServ LIST.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub private: bool,
|
pub private: bool,
|
||||||
|
|
|
||||||
|
|
@ -650,6 +650,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
||||||
url: c.url.clone(),
|
url: c.url.clone(),
|
||||||
email: c.email.clone(),
|
email: c.email.clone(),
|
||||||
signkick: c.settings.signkick,
|
signkick: c.settings.signkick,
|
||||||
|
signkick_level: c.settings.signkick_level,
|
||||||
private: c.settings.private,
|
private: c.settings.private,
|
||||||
peace: c.settings.peace,
|
peace: c.settings.peace,
|
||||||
secureops: c.settings.secureops,
|
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:?}");
|
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.
|
// ChanServ SET: description and founder transfer, founder-gated.
|
||||||
#[test]
|
#[test]
|
||||||
fn chanserv_set() {
|
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 any = |names: &[&str]| names.iter().any(|n| flag(ci, n));
|
||||||
let settings = ChanSettings {
|
let settings = ChanSettings {
|
||||||
signkick: any(&["SIGNKICK"]),
|
signkick: any(&["SIGNKICK", "SIGNKICK_LEVEL"]),
|
||||||
|
signkick_level: any(&["SIGNKICK_LEVEL"]),
|
||||||
private: any(&["PRIVATE", "CS_PRIVATE"]),
|
private: any(&["PRIVATE", "CS_PRIVATE"]),
|
||||||
peace: any(&["PEACE"]),
|
peace: any(&["PEACE"]),
|
||||||
secureops: any(&["SECUREOPS", "CS_SECUREOPS"]),
|
secureops: any(&["SECUREOPS", "CS_SECUREOPS"]),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue