diff --git a/api/src/lib.rs b/api/src/lib.rs index 22d5ce8..2609683 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -443,13 +443,101 @@ pub struct AccountView { pub greet: String, } -// One channel access-list entry (account -> level, e.g. "op" / "voice"). +// One channel access-list entry (account -> level). `level` is either a legacy +// preset ("op"/"voice"/"founder") or a granular flag string (e.g. "otiv"); both +// resolve through `level_caps`. #[derive(Debug, Clone)] pub struct ChanAccessView { pub account: String, pub level: String, } +// The granular channel-access flags, shared by ChanServ (channel access) and, +// via the same primitive, by GroupServ. Each letter grants a capability: +// f full/co-founder o auto-op O op commands h auto-halfop +// v auto-voice t topic i invite a modify access list +// s channel settings g greet shown +pub const ACCESS_FLAGS: &str = "foOhvtiasg"; + +// The capabilities an access `level` confers (founder is layered on by callers). +#[derive(Debug, Clone, Copy, Default)] +pub struct Caps { + pub auto: Option<&'static str>, // auto status mode on join: +o / +h / +v + pub op: bool, // moderation: kick / ban / op / mode / akick + pub topic: bool, + pub invite: bool, + pub access: bool, // may modify the access / flags / akick lists + pub set: bool, // may change channel settings + pub greet: bool, + pub rank: u8, // 3 co-founder, 2 op, 1 voice/halfop — for PEACE comparisons +} + +// Resolve an access `level` string to its capabilities. Recognises the legacy +// presets, then falls back to treating it as flag letters, so old op/voice +// entries and new flag entries coexist. +pub fn level_caps(level: &str) -> Caps { + match level { + "founder" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 }, + "op" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, rank: 2, ..Caps::default() }, + "voice" => Caps { auto: Some("+v"), rank: 1, ..Caps::default() }, + flags => { + let has = |c: char| flags.contains(c); + let auto = if has('o') { + Some("+o") + } else if has('h') { + Some("+h") + } else if has('v') { + Some("+v") + } else { + None + }; + let founderish = has('f'); + let rank = if founderish { + 3 + } else if has('o') || has('O') { + 2 + } else if !flags.is_empty() { + 1 + } else { + 0 + }; + Caps { + auto, + op: founderish || has('o') || has('O'), + topic: founderish || has('t'), + invite: founderish || has('i'), + access: founderish || has('a'), + set: founderish || has('s'), + greet: founderish || has('g'), + rank, + } + } + } +} + +// Apply a `+ov-h`-style delta to a flag string, returning the new sorted, unique +// flag string. A bare "ov" (no sign) grants. Returns Err with the first invalid +// flag letter. +pub fn apply_flags(current: &str, delta: &str) -> Result { + let mut set: Vec = current.chars().filter(|c| ACCESS_FLAGS.contains(*c)).collect(); + let mut adding = true; + for c in delta.chars() { + match c { + '+' => adding = true, + '-' => adding = false, + f if ACCESS_FLAGS.contains(f) => { + set.retain(|&x| x != f); + if adding { + set.push(f); + } + } + other => return Err(other), + } + } + // A stable, deduplicated order following ACCESS_FLAGS. + Ok(ACCESS_FLAGS.chars().filter(|c| set.contains(c)).collect()) +} + // One auto-kick entry (a hostmask and the reason shown on kick). #[derive(Debug, Clone)] pub struct ChanAkickView { @@ -619,33 +707,32 @@ pub enum Kicker { } impl ChannelView { - // The channel mode this account is entitled to on join (+o founder/op, +v - // voice), or None if it holds no access. - pub fn join_mode(&self, account: &str) -> Option<&'static str> { - if self.founder.eq_ignore_ascii_case(account) { - return Some("+o"); - } - self.access - .iter() - .find(|a| a.account.eq_ignore_ascii_case(account)) - .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 }; + // The account's resolved capabilities, or the empty set if it holds no access. + // The founder holds everything. + pub fn caps_of(&self, account: Option<&str>) -> Caps { + let Some(acc) = account else { return Caps::default() }; if self.founder.eq_ignore_ascii_case(acc) { - return 3; + return level_caps("founder"); } self.access .iter() .find(|a| a.account.eq_ignore_ascii_case(acc)) - .map_or(0, |a| if a.level == "voice" { 1 } else { 2 }) + .map_or(Caps::default(), |a| level_caps(&a.level)) } - // Whether this account holds channel-operator access (founder or op level). + // The status mode this account gets on join (+o / +h / +v), or None. + pub fn join_mode(&self, account: &str) -> Option<&'static str> { + self.caps_of(Some(account)).auto + } + + // A comparable access rank for PEACE: founder 3, op 2, voice/halfop 1, none 0. + pub fn access_rank(&self, account: Option<&str>) -> u8 { + self.caps_of(account).rank + } + + // Whether this account holds channel-operator (moderation) access. pub fn is_op(&self, account: &str) -> bool { - self.join_mode(account) == Some("+o") + self.caps_of(Some(account)).op } // The mode-lock rendered as an applyable mode string, e.g. "+rnt-s". @@ -991,6 +1078,27 @@ pub fn human_time(ts: u64) -> String { mod tests { use super::*; + #[test] + fn access_flags_apply_and_resolve() { + // +/- deltas, dedup, stable order, invalid flag rejected. + assert_eq!(apply_flags("", "+ov").unwrap(), "ov"); + assert_eq!(apply_flags("ov", "-o").unwrap(), "v"); + assert_eq!(apply_flags("v", "+tv").unwrap(), "vt"); // ACCESS_FLAGS order, no dup + assert_eq!(apply_flags("", "ai").unwrap(), "ia"); // bare letters grant + assert_eq!(apply_flags("", "+ox"), Err('x')); + + // Legacy presets and flag strings both resolve to capabilities. + assert_eq!(level_caps("op").auto, Some("+o")); + assert!(level_caps("op").op && level_caps("op").topic); + assert_eq!(level_caps("voice").auto, Some("+v")); + assert_eq!(level_caps("o").auto, Some("+o")); + assert_eq!(level_caps("v").auto, Some("+v")); + assert_eq!(level_caps("h").auto, Some("+h")); + let t = level_caps("t"); // topic-only: can topic, not op + assert!(t.topic && !t.op && t.auto.is_none()); + assert!(level_caps("a").access && level_caps("f").set); + } + #[test] fn privs_bitset_grants_and_checks() { let p = Privs::default().with(Priv::Auspex).with(Priv::Admin); diff --git a/chanserv/src/flags.rs b/chanserv/src/flags.rs new file mode 100644 index 0000000..b3f9e09 --- /dev/null +++ b/chanserv/src/flags.rs @@ -0,0 +1,80 @@ +use fedserv_api::{apply_flags, Sender, ServiceCtx, Store, ACCESS_FLAGS}; + +// FLAGS <#channel> [account [+/-flags]]: the granular access model. With no +// account, list the access entries and their flags; with an account, show or +// change its flags. Letters: f full o auto-op O op h auto-halfop v auto-voice +// t topic i invite a access-list s settings g greet. Viewing needs op access; +// changing needs the founder or the \x02a\x02 flag. +pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(info) = db.channel(chan) else { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + }; + let is_founder = from.account == Some(info.founder.as_str()); + let caps = info.caps_of(from.account); + + // FLAGS <#chan> — list. + let Some(&target) = args.get(2) else { + if !is_founder && !caps.op { + ctx.notice(me, from.uid, format!("You need access to \x02{chan}\x02 to view its flags.")); + return; + } + ctx.notice(me, from.uid, format!("Access flags for \x02{chan}\x02:")); + ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder): \x02f\x02", info.founder)); + for a in &info.access { + ctx.notice(me, from.uid, format!(" \x02{}\x02: \x02{}\x02", a.account, display_flags(&a.level))); + } + ctx.notice(me, from.uid, format!("End of flags ({} entr{}).", info.access.len() + 1, if info.access.is_empty() { "y" } else { "ies" })); + return; + }; + + let current = info.access.iter().find(|a| a.account.eq_ignore_ascii_case(target)).map(|a| display_flags(&a.level)); + + // FLAGS <#chan> — show one. + let Some(&delta) = args.get(3) else { + match current { + Some(f) if !f.is_empty() => ctx.notice(me, from.uid, format!("\x02{target}\x02 on \x02{chan}\x02: \x02{f}\x02")), + _ => ctx.notice(me, from.uid, format!("\x02{target}\x02 has no access to \x02{chan}\x02.")), + } + return; + }; + + // FLAGS <#chan> <+/-flags> — modify. + if !is_founder && !caps.access { + ctx.notice(me, from.uid, format!("You need the founder or the \x02a\x02 flag to change access on \x02{chan}\x02.")); + return; + } + if info.founder.eq_ignore_ascii_case(target) { + ctx.notice(me, from.uid, "The founder's access is set with \x02SET FOUNDER\x02, not flags."); + return; + } + let base = current.unwrap_or_default(); + let updated = match apply_flags(&base, delta) { + Ok(f) => f, + Err(bad) => { + ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid flag. Valid flags: \x02{ACCESS_FLAGS}\x02.")); + return; + } + }; + if updated.is_empty() { + match db.access_del(chan, target) { + Ok(true) => ctx.notice(me, from.uid, format!("Cleared \x02{target}\x02's access to \x02{chan}\x02.")), + _ => ctx.notice(me, from.uid, format!("\x02{target}\x02 had no access to \x02{chan}\x02.")), + } + return; + } + match db.access_add(chan, target, &updated) { + Ok(()) => ctx.notice(me, from.uid, format!("\x02{target}\x02 on \x02{chan}\x02 now holds \x02{updated}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +// Show a stored level as flag letters (mapping the legacy presets). +fn display_flags(level: &str) -> String { + match level { + "op" => "oti".to_string(), + "voice" => "v".to_string(), + "founder" => "f".to_string(), + flags => flags.to_string(), + } +} diff --git a/chanserv/src/lib.rs b/chanserv/src/lib.rs index 5850f54..80e7255 100644 --- a/chanserv/src/lib.rs +++ b/chanserv/src/lib.rs @@ -6,6 +6,7 @@ use fedserv_api::NetView; mod mode; #[path = "access.rs"] mod access; +mod flags; #[path = "op.rs"] mod op; #[path = "kick.rs"] @@ -205,6 +206,13 @@ impl Service for ChanServ { } Some("MODE") => mode::handle(me, from, args, ctx, db), Some("ACCESS") => access::handle(me, from, args, ctx, db), + Some("FLAGS") => { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: FLAGS <#channel> [account [+/-flags]]"); + return; + }; + flags::handle(me, from, chan, args, ctx, db); + } Some("OP") => op::handle(me, from, "+o", args, ctx, net, db), Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db), Some("VOICE") => op::handle(me, from, "+v", args, ctx, net, db), @@ -229,7 +237,7 @@ impl Service for ChanServ { Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db), Some("SOP") => xop::handle(me, from, "SOP", "op", args, ctx, db), Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <#channel> {ON|OFF}."), + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02FLAGS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <#channel> {ON|OFF}."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/src/engine/db.rs b/src/engine/db.rs index 06d5114..072ab38 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -681,7 +681,7 @@ impl ChannelInfo { self.access .iter() .find(|a| a.account.eq_ignore_ascii_case(account)) - .map(|a| if a.level == "voice" { "+v" } else { "+o" }) + .and_then(|a| fedserv_api::level_caps(&a.level).auto) } /// The matching auto-kick entry for `hostmask` (nick!user@host), if any. diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 8d66119..85f62f5 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4831,6 +4831,57 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}"); } + // ChanServ FLAGS: granular access grants flow through the shared resolver, so + // a flag-granted +o gets auto-op like a legacy op entry; changing needs the + // founder or the 'a' flag. + #[test] + fn chanserv_flags_grant_access() { + use fedserv_chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-csflags.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + for a in ["alice", "bob", "carol"] { + db.register(a, "password1", None).unwrap(); + } + db.register_channel("#room", "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, + ); + let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() }); + let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "bob"), ("000AAAAAC", "carol")] { + e.handle(NetEvent::UserConnect { uid: uid.into(), nick: nick.into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + } + + // Founder grants bob auto-op + topic via flags. + assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room bob +ot"), "now holds"), "flags set"); + // The grant confers real access: bob is auto-opped on join. + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes.starts_with("+o"))), "flag-op is auto-opped: {out:?}"); + + // Change to voice-only; now the join mode is +v. + cs(&mut e, "000AAAAAA", "FLAGS #room bob -ot+v"); + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room2".into(), op: false }); + assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("+o"))), "no longer auto-opped elsewhere"); + assert_eq!(e.db.channel("#room").unwrap().join_mode("bob"), Some("+v"), "bob resolves to +v"); + + // A non-founder without the 'a' flag can't change access. + assert!(has(&cs(&mut e, "000AAAAAC", "FLAGS #room bob +o"), "founder or the \x02a\x02 flag"), "carol refused"); + // Grant carol the 'a' flag; now she can. + cs(&mut e, "000AAAAAA", "FLAGS #room carol +a"); + assert!(has(&cs(&mut e, "000AAAAAC", "FLAGS #room bob +i"), "now holds"), "carol with 'a' can change flags"); + + // An invalid flag letter is rejected. + assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room bob +z"), "isn't a valid flag"), "bad flag rejected"); + } + // ChanServ moderation: an op can op/kick/ban users; a non-op is refused. #[test] fn chanserv_moderation() {