diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index 082ee18..3cab0b3 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -9,6 +9,8 @@ mod access; mod flags; #[path = "op.rs"] mod op; +#[path = "updown.rs"] +mod updown; #[path = "kick.rs"] mod kick; #[path = "ban.rs"] @@ -55,6 +57,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "AOP/SOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02AOP|SOP|VOP <#channel> ADD | DEL | LIST\x02\nTiered shortcuts over ACCESS: AOP and SOP grant op, VOP grants voice." }, HelpEntry { cmd: "STATUS", summary: "show a user's access", detail: "Syntax: \x02STATUS <#channel> [nick]\x02\nShows a user's access level on a channel." }, HelpEntry { cmd: "OP/DEOP/VOICE/DEVOICE", summary: "give or take op/voice", detail: "Syntax: \x02OP|DEOP|VOICE|DEVOICE <#channel> [nick]\x02\nGives or takes channel op or voice." }, + HelpEntry { cmd: "UP/DOWN", summary: "apply or drop your status", detail: "Syntax: \x02UP|DOWN <#channel>\x02\nUP re-applies the op/voice your access entitles you to; DOWN removes your status." }, HelpEntry { cmd: "KICK", summary: "kick from the channel", detail: "Syntax: \x02KICK <#channel> [reason]\x02\nKicks a user from the channel." }, HelpEntry { cmd: "BAN/UNBAN", summary: "ban or unban a user", detail: "Syntax: \x02BAN <#channel> [reason]\x02, \x02UNBAN <#channel> [nick]\x02\nBans or unbans a user by host." }, HelpEntry { cmd: "AKICK", summary: "manage the auto-kick list", detail: "Syntax: \x02AKICK <#channel> ADD [reason] | DEL | LIST\x02\nManages the auto-kick list; matches are banned and kicked on join." }, @@ -250,6 +253,8 @@ impl Service for ChanServ { Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db), Some("VOICE") => op::handle(me, from, "+v", args, ctx, net, db), Some("DEVOICE") => op::handle(me, from, "-v", args, ctx, net, db), + Some("UP") => updown::handle(me, from, true, args, ctx, net, db), + Some("DOWN") => updown::handle(me, from, false, args, ctx, net, db), Some("KICK") => kick::handle(me, from, args, ctx, net, db), Some("BAN") => ban::handle(me, from, args, ctx, net, db), Some("UNBAN") => unban::handle(me, from, args, ctx, net, db), diff --git a/modules/chanserv/src/updown.rs b/modules/chanserv/src/updown.rs new file mode 100644 index 0000000..49bc53d --- /dev/null +++ b/modules/chanserv/src/updown.rs @@ -0,0 +1,35 @@ +use echo_api::{NetView, Sender, ServiceCtx, Store}; + +// UP <#channel>: (re)apply the status mode your access entitles you to. +// DOWN <#channel>: drop your channel status modes. `up` selects which. +pub fn handle(me: &str, from: &Sender, up: bool, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, if up { "Syntax: UP <#channel>" } else { "Syntax: DOWN <#channel>" }); + return; + }; + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first."); + return; + }; + let Some(info) = db.channel(chan) else { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + }; + if !net.channel_members(chan).iter().any(|u| u.as_str() == from.uid) { + ctx.notice(me, from.uid, format!("You're not in \x02{chan}\x02.")); + return; + } + if up { + match info.join_mode(account) { + Some(mode) => { + ctx.channel_mode(me, chan, &format!("{mode} {}", from.uid)); + ctx.notice(me, from.uid, format!("Your status in \x02{chan}\x02 has been applied.")); + } + None => ctx.notice(me, from.uid, format!("You have no status access in \x02{chan}\x02.")), + } + } else { + // Strip op and voice; the ircd ignores any mode you don't currently hold. + ctx.channel_mode(me, chan, &format!("-ov {} {}", from.uid, from.uid)); + ctx.notice(me, from.uid, format!("Your status in \x02{chan}\x02 has been removed.")); + } +} diff --git a/src/engine/tests.rs b/src/engine/tests.rs index ba59753..928bb6d 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -745,6 +745,37 @@ assert!(out.is_empty(), "no enforcement when SECUREOPS is off: {out:?}"); } + // ChanServ UP applies the status your access entitles you to; DOWN strips it. + #[test] + fn chanserv_up_down_status() { + use echo_chanserv::ChanServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-csupdown.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 mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".into() }), + ], + db, + ); + 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::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false }); + + let cs = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: text.into() }); + let out = cs(&mut e, "UP #c"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")), "UP applies the founder's +o: {out:?}"); + let out = cs(&mut e, "DOWN #c"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-ov"))), "DOWN strips status: {out:?}"); + let out = cs(&mut e, "UP #nope"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't registered"))), "unknown channel refused: {out:?}"); + } + // BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered. #[test] fn botserv_bot_add_list_is_oper_gated() {