diff --git a/Cargo.lock b/Cargo.lock index 5ed3c83..cd279e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,7 @@ dependencies = [ "fedserv-chanserv", "fedserv-diceserv", "fedserv-example", + "fedserv-groupserv", "fedserv-hostserv", "fedserv-infoserv", "fedserv-inspircd", @@ -390,6 +391,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-groupserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-hostserv" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 08594a3..28eb947 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv"] [package] name = "fedserv" @@ -21,6 +21,7 @@ fedserv-operserv = { path = "operserv" } fedserv-diceserv = { path = "diceserv" } fedserv-infoserv = { path = "infoserv" } fedserv-reportserv = { path = "reportserv" } +fedserv-groupserv = { path = "groupserv" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/api/src/lib.rs b/api/src/lib.rs index 2609683..b4cbd5a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -515,17 +515,60 @@ pub fn level_caps(level: &str) -> Caps { } } -// 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(); +// The granular group-membership flags (GroupServ), same primitive, different +// letters: F founder, f modify the member/flags list, i invite members, s set +// group options, c the group may be granted channel access, m group memos. +pub const GROUP_FLAGS: &str = "Fficsm"; + +impl Caps { + // Combine two capability sets (a direct access entry and a group's), taking + // the union of privileges, the higher rank, and the strongest auto-mode. + pub fn union(self, other: Caps) -> Caps { + let rank_of = |m: Option<&'static str>| match m { + Some("+o") => 3, + Some("+h") => 2, + Some("+v") => 1, + _ => 0, + }; + let auto = if rank_of(self.auto) >= rank_of(other.auto) { self.auto } else { other.auto }; + Caps { + auto, + op: self.op || other.op, + topic: self.topic || other.topic, + invite: self.invite || other.invite, + access: self.access || other.access, + set: self.set || other.set, + greet: self.greet || other.greet, + rank: self.rank.max(other.rank), + } + } +} + +// A user group (GroupServ) and its members, as a read view. +#[derive(Debug, Clone)] +pub struct GroupMemberView { + pub account: String, + pub flags: String, +} + +#[derive(Debug, Clone)] +pub struct GroupView { + pub name: String, + pub founder: String, + pub members: Vec, +} + +// Apply a `+ov-h`-style delta to a flag string against a `valid` letter set, +// returning the new deduplicated flag string ordered by `valid`. A bare "ov" +// (no sign) grants. Returns Err with the first letter not in `valid`. +pub fn apply_flags(current: &str, delta: &str, valid: &str) -> Result { + let mut set: Vec = current.chars().filter(|c| valid.contains(*c)).collect(); let mut adding = true; for c in delta.chars() { match c { '+' => adding = true, '-' => adding = false, - f if ACCESS_FLAGS.contains(f) => { + f if valid.contains(f) => { set.retain(|&x| x != f); if adding { set.push(f); @@ -534,8 +577,7 @@ pub fn apply_flags(current: &str, delta: &str) -> Result { other => return Err(other), } } - // A stable, deduplicated order following ACCESS_FLAGS. - Ok(ACCESS_FLAGS.chars().filter(|c| set.contains(c)).collect()) + Ok(valid.chars().filter(|c| set.contains(c)).collect()) } // One auto-kick entry (a hostmask and the reason shown on kick). @@ -892,6 +934,17 @@ pub trait Store { fn report_del(&mut self, id: u64) -> bool; fn reports(&self, open_only: bool) -> Vec; fn report(&self, id: u64) -> Option; + // User groups (GroupServ). + fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError>; + fn group_drop(&mut self, name: &str) -> Result<(), ChanError>; + fn group_set_flags(&mut self, name: &str, account: &str, flags: &str) -> Result<(), ChanError>; + fn group_del_member(&mut self, name: &str, account: &str) -> Result; + fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError>; + fn group(&self, name: &str) -> Option; + fn groups(&self) -> Vec; + fn groups_of(&self, account: &str) -> Vec; + // A user's effective channel capabilities (direct access unioned with groups). + fn channel_caps(&self, channel: &str, account: &str) -> Caps; // Runtime operator grants (OperServ OPER), merged with config opers. `expires` // is an absolute unix time (None = permanent); opers_list hides expired ones. fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option); @@ -1081,11 +1134,11 @@ mod tests { #[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')); + assert_eq!(apply_flags("", "+ov", ACCESS_FLAGS).unwrap(), "ov"); + assert_eq!(apply_flags("ov", "-o", ACCESS_FLAGS).unwrap(), "v"); + assert_eq!(apply_flags("v", "+tv", ACCESS_FLAGS).unwrap(), "vt"); // ACCESS_FLAGS order, no dup + assert_eq!(apply_flags("", "ai", ACCESS_FLAGS).unwrap(), "ia"); // bare letters grant + assert_eq!(apply_flags("", "+ox", ACCESS_FLAGS), Err('x')); // Legacy presets and flag strings both resolve to capabilities. assert_eq!(level_caps("op").auto, Some("+o")); diff --git a/chanserv/src/flags.rs b/chanserv/src/flags.rs index b3f9e09..377e42a 100644 --- a/chanserv/src/flags.rs +++ b/chanserv/src/flags.rs @@ -49,7 +49,7 @@ pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut Serv return; } let base = current.unwrap_or_default(); - let updated = match apply_flags(&base, delta) { + let updated = match apply_flags(&base, delta, ACCESS_FLAGS) { 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.")); diff --git a/groupserv/Cargo.toml b/groupserv/Cargo.toml new file mode 100644 index 0000000..328052c --- /dev/null +++ b/groupserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-groupserv" +version = "0.0.1" +edition = "2021" +description = "GroupServ: user groups (!name) whose members can be granted channel access together." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/groupserv/src/lib.rs b/groupserv/src/lib.rs new file mode 100644 index 0000000..5d8b947 --- /dev/null +++ b/groupserv/src/lib.rs @@ -0,0 +1,226 @@ +//! GroupServ manages user groups — a `!name` owning a set of member accounts. +//! A group's real purpose is the interconnection: a channel can grant access to +//! a `!group` (via ChanServ FLAGS/ACCESS), and every member then inherits that +//! channel access. Members carry group-access flags (the same flag primitive +//! ChanServ uses): F founder, f manage members, i invite, c channel-access, +//! s set, m memo. Managing members needs the founder or the `f` flag. + +use fedserv_api::{apply_flags, NetView, Sender, Service, ServiceCtx, Store, GROUP_FLAGS}; + +pub struct GroupServ { + pub uid: String, +} + +impl Service for GroupServ { + fn nick(&self) -> &str { + "GroupServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Group Service" + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { + let me = self.uid.as_str(); + match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("REGISTER") => register(me, from, args.get(1).copied(), ctx, db), + Some("DROP") => drop(me, from, args.get(1).copied(), ctx, db), + Some("INFO") => info(me, from, args.get(1).copied(), ctx, db), + Some("LIST") => list(me, from, ctx, db), + Some("ADD") => add(me, from, args.get(1).copied(), args.get(2).copied(), ctx, db), + Some("DEL") => del(me, from, args.get(1).copied(), args.get(2).copied(), ctx, db), + Some("FLAGS") => flags(me, from, args.get(1).copied(), args.get(2).copied(), args.get(3).copied(), ctx, db), + Some("HELP") | None => help(me, from, ctx), + Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")), + } + } +} + +fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, "GroupServ manages user groups. \x02REGISTER\x02 , \x02DROP\x02 , \x02INFO\x02 , \x02LIST\x02, \x02ADD\x02/\x02DEL\x02 , \x02FLAGS\x02 [account [+/-flags]]. Grant a group channel access with ChanServ \x02FLAGS #chan !group +o\x02 — every member then inherits it."); +} + +// The caller must be logged in; returns their account. +fn account<'a>(me: &str, from: &'a Sender, ctx: &mut ServiceCtx) -> Option<&'a str> { + match from.account { + Some(a) => Some(a), + None => { + ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first."); + None + } + } +} + +// Whether `who` may manage `group` (founder, or holds the F/f flag). +fn can_manage(group: &fedserv_api::GroupView, who: &str) -> bool { + group.founder.eq_ignore_ascii_case(who) + || group.members.iter().any(|m| m.account.eq_ignore_ascii_case(who) && (m.flags.contains('F') || m.flags.contains('f'))) +} + +fn register(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(acc) = account(me, from, ctx) else { return }; + let Some(name) = name else { + ctx.notice(me, from.uid, "Syntax: REGISTER "); + return; + }; + if !name.starts_with('!') || name.len() < 2 { + ctx.notice(me, from.uid, "A group name starts with \x02!\x02, e.g. \x02!staff\x02."); + return; + } + let acc = acc.to_string(); + match db.group_register(name, &acc) { + Ok(()) => ctx.notice(me, from.uid, format!("Group \x02{name}\x02 registered — you're the founder.")), + Err(fedserv_api::ChanError::Exists) => ctx.notice(me, from.uid, format!("\x02{name}\x02 is already registered.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn drop(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(acc) = account(me, from, ctx) else { return }; + let Some(name) = name else { + ctx.notice(me, from.uid, "Syntax: DROP "); + return; + }; + let Some(g) = db.group(name) else { + ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); + return; + }; + if !g.founder.eq_ignore_ascii_case(acc) { + ctx.notice(me, from.uid, format!("Only \x02{name}\x02's founder can drop it.")); + return; + } + match db.group_drop(name) { + Ok(()) => ctx.notice(me, from.uid, format!("Group \x02{name}\x02 has been dropped.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn info(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(name) = name else { + ctx.notice(me, from.uid, "Syntax: INFO "); + return; + }; + let Some(g) = db.group(name) else { + ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); + return; + }; + ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", g.name)); + ctx.notice(me, from.uid, format!(" Founder : \x02{}\x02", g.founder)); + ctx.notice(me, from.uid, format!(" Members : {}", g.members.len())); +} + +fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { + // Operators see every group; others see the ones they belong to. + let names = if from.privs.any() { + db.groups() + } else if let Some(acc) = from.account { + db.groups_of(acc) + } else { + Vec::new() + }; + if names.is_empty() { + ctx.notice(me, from.uid, "No groups to show."); + return; + } + for n in &names { + ctx.notice(me, from.uid, format!(" \x02{n}\x02")); + } + ctx.notice(me, from.uid, format!("End of list ({} group(s)).", names.len())); +} + +fn add(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(acc) = account(me, from, ctx) else { return }; + let (Some(name), Some(target)) = (name, target) else { + ctx.notice(me, from.uid, "Syntax: ADD "); + return; + }; + let Some(g) = db.group(name) else { + ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); + return; + }; + if !can_manage(&g, acc) { + ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to manage \x02{name}\x02.")); + return; + } + let Some(canonical) = db.resolve_account(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); + return; + }; + match db.group_set_flags(name, &canonical, "") { + Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{canonical}\x02 to \x02{name}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn del(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(acc) = account(me, from, ctx) else { return }; + let (Some(name), Some(target)) = (name, target) else { + ctx.notice(me, from.uid, "Syntax: DEL "); + return; + }; + let Some(g) = db.group(name) else { + ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); + return; + }; + if !can_manage(&g, acc) { + ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to manage \x02{name}\x02.")); + return; + } + match db.group_del_member(name, target) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{target}\x02 from \x02{name}\x02.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't in \x02{name}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn flags(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, delta: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(acc) = account(me, from, ctx) else { return }; + let Some(name) = name else { + ctx.notice(me, from.uid, "Syntax: FLAGS [account [+/-flags]]"); + return; + }; + let Some(g) = db.group(name) else { + ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); + return; + }; + // List. + let Some(target) = target else { + ctx.notice(me, from.uid, format!("Flags for \x02{}\x02:", g.name)); + ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder): \x02F\x02", g.founder)); + for m in &g.members { + ctx.notice(me, from.uid, format!(" \x02{}\x02: \x02{}\x02", m.account, if m.flags.is_empty() { "(member)" } else { &m.flags })); + } + return; + }; + let current = g.members.iter().find(|m| m.account.eq_ignore_ascii_case(target)).map(|m| m.flags.clone()); + // Show one. + let Some(delta) = delta else { + match current { + Some(f) => ctx.notice(me, from.uid, format!("\x02{target}\x02 in \x02{name}\x02: \x02{}\x02", if f.is_empty() { "(member)" } else { &f })), + None => ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't in \x02{name}\x02.")), + } + return; + }; + // Change — needs founder or f flag. + if !can_manage(&g, acc) { + ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to change \x02{name}\x02.")); + return; + } + let Some(canonical) = db.resolve_account(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); + return; + }; + let updated = match apply_flags(current.as_deref().unwrap_or(""), delta, GROUP_FLAGS) { + Ok(f) => f, + Err(bad) => { + ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid group flag. Valid: \x02{GROUP_FLAGS}\x02.")); + return; + } + }; + match db.group_set_flags(name, &canonical, &updated) { + Ok(()) => ctx.notice(me, from.uid, format!("\x02{canonical}\x02 in \x02{name}\x02 now holds \x02{}\x02.", if updated.is_empty() { "(member)" } else { &updated })), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} diff --git a/src/engine/db.rs b/src/engine/db.rs index 072ab38..ebcc8dd 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -30,7 +30,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, + AccountView, AjoinView, AkillView, BotView, Caps, GroupView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -192,6 +192,14 @@ pub enum Event { ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 }, ReportClosed { id: u64 }, ReportDeleted { id: u64 }, + // User groups (GroupServ). Global — groups are account identity. + GroupRegistered { name: String, founder: String, ts: u64 }, + GroupDropped { name: String }, + GroupFounderSet { name: String, founder: String }, + // Upsert a member (empty flags = a plain member, still present). + GroupFlagsSet { name: String, account: String, flags: String }, + // Remove a member from a group entirely. + GroupMemberDel { name: String, account: String }, // Runtime operator grants (OperServ OPER). OperGranted { account: String, @@ -267,6 +275,11 @@ impl Event { | Event::ReportFiled { .. } | Event::ReportClosed { .. } | Event::ReportDeleted { .. } + | Event::GroupRegistered { .. } + | Event::GroupDropped { .. } + | Event::GroupFounderSet { .. } + | Event::GroupFlagsSet { .. } + | Event::GroupMemberDel { .. } | Event::OperGranted { .. } | Event::OperRevoked { .. } | Event::SessionExceptionAdded { .. } @@ -382,6 +395,22 @@ pub struct News { pub ts: u64, } +// A user group (GroupServ): a `!name`, its founder account, and member accounts +// each with group-access flags. Groups can be granted channel access. +#[derive(Debug, Clone)] +pub struct Group { + pub name: String, // "!name", casefolded + pub founder: String, + pub ts: u64, + pub members: Vec, +} + +#[derive(Debug, Clone)] +pub struct GroupMember { + pub account: String, + pub flags: String, +} + // An abuse report (ReportServ): who filed it, the nick/channel it's about, why, // when, and whether it's still open. Stable id like News. #[derive(Debug, Clone)] @@ -405,6 +434,8 @@ pub struct NetData { // Abuse reports (ReportServ), oldest first. pub reports: Vec, pub report_seq: u64, + // User groups (GroupServ). + pub groups: Vec, // Runtime services operators (OperServ OPER), casefolded account -> grant. // Merged with the declarative [[oper]] config at the engine. pub opers: HashMap, @@ -1179,6 +1210,12 @@ impl Db { snapshot.push(Event::ReportClosed { id: r.id }); } } + for g in &self.net.groups { + snapshot.push(Event::GroupRegistered { name: g.name.clone(), founder: g.founder.clone(), ts: g.ts }); + for m in &g.members { + snapshot.push(Event::GroupFlagsSet { name: g.name.clone(), account: m.account.clone(), flags: m.flags.clone() }); + } + } for (account, grant) in &self.net.opers { snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires }); } @@ -2236,6 +2273,127 @@ impl Db { self.net.reports.iter().find(|r| r.id == id).map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open }) } + /// Register a new group (name must start with `!`). Founder is an account. + pub fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { + if !name.starts_with('!') || name.len() < 2 { + return Err(ChanError::InvalidPattern); + } + if self.group(name).is_some() { + return Err(ChanError::Exists); + } + let ts = now(); + self.log.append(Event::GroupRegistered { name: name.to_string(), founder: founder.to_string(), ts }).map_err(|_| ChanError::Internal)?; + self.net.groups.push(Group { name: name.to_string(), founder: founder.to_string(), ts, members: Vec::new() }); + Ok(()) + } + + /// Drop a group. + pub fn group_drop(&mut self, name: &str) -> Result<(), ChanError> { + if self.group(name).is_none() { + return Err(ChanError::NoChannel); + } + self.log.append(Event::GroupDropped { name: name.to_string() }).map_err(|_| ChanError::Internal)?; + let k = key(name); + self.net.groups.retain(|g| key(&g.name) != k); + Ok(()) + } + + /// Upsert a group member with `flags` (empty = a plain member). + pub fn group_set_flags(&mut self, name: &str, account: &str, flags: &str) -> Result<(), ChanError> { + let k = key(name); + let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else { + return Err(ChanError::NoChannel); + }; + self.log.append(Event::GroupFlagsSet { name: name.to_string(), account: account.to_string(), flags: flags.to_string() }).map_err(|_| ChanError::Internal)?; + g.members.retain(|m| !m.account.eq_ignore_ascii_case(account)); + g.members.push(GroupMember { account: account.to_string(), flags: flags.to_string() }); + Ok(()) + } + + /// Remove a member from a group. Returns whether one was present. + pub fn group_del_member(&mut self, name: &str, account: &str) -> Result { + let k = key(name); + let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else { + return Err(ChanError::NoChannel); + }; + if !g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account)) { + return Ok(false); + } + self.log.append(Event::GroupMemberDel { name: name.to_string(), account: account.to_string() }).map_err(|_| ChanError::Internal)?; + g.members.retain(|m| !m.account.eq_ignore_ascii_case(account)); + Ok(true) + } + + /// Transfer a group's founder to another account. + pub fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { + let k = key(name); + let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else { + return Err(ChanError::NoChannel); + }; + self.log.append(Event::GroupFounderSet { name: name.to_string(), founder: founder.to_string() }).map_err(|_| ChanError::Internal)?; + g.founder = founder.to_string(); + Ok(()) + } + + /// A group view (founder + members), if it exists. + pub fn group(&self, name: &str) -> Option { + let k = key(name); + self.net.groups.iter().find(|g| key(&g.name) == k).map(|g| GroupView { + name: g.name.clone(), + founder: g.founder.clone(), + members: g.members.iter().map(|m| fedserv_api::GroupMemberView { account: m.account.clone(), flags: m.flags.clone() }).collect(), + }) + } + + /// Every group's name, sorted. + pub fn groups(&self) -> Vec { + let mut names: Vec = self.net.groups.iter().map(|g| g.name.clone()).collect(); + names.sort(); + names + } + + /// The groups an account belongs to (founder or member). + pub fn groups_of(&self, account: &str) -> Vec { + self.net + .groups + .iter() + .filter(|g| g.founder.eq_ignore_ascii_case(account) || g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account))) + .map(|g| g.name.clone()) + .collect() + } + + /// Whether an account is in a group (its founder or a member). + pub fn is_group_member(&self, name: &str, account: &str) -> bool { + let k = key(name); + self.net.groups.iter().find(|g| key(&g.name) == k).is_some_and(|g| g.founder.eq_ignore_ascii_case(account) || g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account))) + } + + /// An account's effective channel capabilities, combining its direct access + /// entry with any `!group` access entry it belongs to (the interconnection + /// that lets a channel grant access to a whole group). + pub fn channel_caps(&self, channel: &str, account: &str) -> Caps { + let Some(c) = self.channels.get(&key(channel)) else { return Caps::default() }; + if c.founder.eq_ignore_ascii_case(account) { + return fedserv_api::level_caps("founder"); + } + let mut caps = Caps::default(); + for a in &c.access { + let applies = match a.account.strip_prefix('!') { + Some(g) => self.is_group_member(&format!("!{g}"), account), + None => a.account.eq_ignore_ascii_case(account), + }; + if applies { + caps = caps.union(fedserv_api::level_caps(&a.level)); + } + } + caps + } + + /// A user's join status mode for a channel, group-aware. + pub fn channel_join_mode(&self, channel: &str, account: &str) -> Option<&'static str> { + self.channel_caps(channel, account).auto + } + /// Add a services ignore, replacing any existing entry for the same mask. pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option) { self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask)); @@ -3228,6 +3386,35 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { net.reports.retain(|r| r.id != id); } + Event::GroupRegistered { name, founder, ts } => { + let k = key(&name); + if !net.groups.iter().any(|g| key(&g.name) == k) { + net.groups.push(Group { name, founder, ts, members: Vec::new() }); + } + } + Event::GroupDropped { name } => { + let k = key(&name); + net.groups.retain(|g| key(&g.name) != k); + } + Event::GroupFounderSet { name, founder } => { + let k = key(&name); + if let Some(g) = net.groups.iter_mut().find(|g| key(&g.name) == k) { + g.founder = founder; + } + } + Event::GroupFlagsSet { name, account, flags } => { + let k = key(&name); + if let Some(g) = net.groups.iter_mut().find(|g| key(&g.name) == k) { + g.members.retain(|m| !m.account.eq_ignore_ascii_case(&account)); + g.members.push(GroupMember { account, flags }); + } + } + Event::GroupMemberDel { name, account } => { + let k = key(&name); + if let Some(g) = net.groups.iter_mut().find(|g| key(&g.name) == k) { + g.members.retain(|m| !m.account.eq_ignore_ascii_case(&account)); + } + } Event::OperGranted { account, privs, expires } => { net.opers.insert(key(&account), OperGrant { privs, expires }); } @@ -3551,6 +3738,33 @@ impl Store for Db { fn report(&self, id: u64) -> Option { Db::report(self, id) } + fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { + Db::group_register(self, name, founder) + } + fn group_drop(&mut self, name: &str) -> Result<(), ChanError> { + Db::group_drop(self, name) + } + fn group_set_flags(&mut self, name: &str, account: &str, flags: &str) -> Result<(), ChanError> { + Db::group_set_flags(self, name, account, flags) + } + fn group_del_member(&mut self, name: &str, account: &str) -> Result { + Db::group_del_member(self, name, account) + } + fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { + Db::group_set_founder(self, name, founder) + } + fn group(&self, name: &str) -> Option { + Db::group(self, name) + } + fn groups(&self) -> Vec { + Db::groups(self) + } + fn groups_of(&self, account: &str) -> Vec { + Db::groups_of(self, account) + } + fn channel_caps(&self, channel: &str, account: &str) -> Caps { + Db::channel_caps(self, channel, account) + } fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option) { Db::oper_grant(self, account, privs, expires) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 85f62f5..32d8358 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -816,7 +816,9 @@ impl Engine { } let account = self.network.account_of(&uid).map(str::to_string); let from = self.chan_service.clone().unwrap_or_default(); - let mode = account.as_deref().and_then(|a| self.db.channel(&channel).and_then(|c| c.join_mode(a))); + // Group-aware: a member of a group that holds channel access gets + // the group's status mode too. + let mode = account.as_deref().and_then(|a| self.db.channel_join_mode(&channel, a)); let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty()); // Computed before the match below moves `channel` into its actions. let greet = self.greet_on_join(&channel, account.as_deref()); @@ -1699,6 +1701,17 @@ fn audit_summary(event: &db::Event) -> Option { ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"), ReportClosed { id } => format!("closed report #{id}"), ReportDeleted { id } => format!("deleted report #{id}"), + GroupRegistered { name, founder, .. } => format!("registered group \x02{name}\x02 (founder \x02{founder}\x02)"), + GroupDropped { name } => format!("dropped group \x02{name}\x02"), + GroupFounderSet { name, founder } => format!("set founder of \x02{name}\x02 to \x02{founder}\x02"), + GroupFlagsSet { name, account, flags } => { + if flags.is_empty() { + format!("added \x02{account}\x02 to group \x02{name}\x02") + } else { + format!("set \x02{account}\x02 in group \x02{name}\x02 to \x02{flags}\x02") + } + } + GroupMemberDel { name, account } => format!("removed \x02{account}\x02 from group \x02{name}\x02"), OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")), OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"), SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"), @@ -4831,6 +4844,57 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}"); } + // GroupServ interconnection: a channel grants access to a !group, and every + // group member inherits that channel access (auto-op on join). + #[test] + fn groupserv_grants_channel_access() { + use fedserv_chanserv::ChanServ; + use fedserv_groupserv::GroupServ; + let path = std::env::temp_dir().join("fedserv-groupserv.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() }), + Box::new(GroupServ { uid: "42SAAAAAL".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 gs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAL".into(), text: t.into() }); + let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + let opped = |out: &[NetAction], who: &str| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == &format!("+o {who}"))); + + 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() }); + } + + // alice makes a group, adds bob, and grants the group op access on #room. + assert!(has(&gs(&mut e, "000AAAAAA", "REGISTER !team"), "registered"), "group registered"); + assert!(has(&gs(&mut e, "000AAAAAA", "ADD !team bob"), "Added"), "bob added"); + assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room !team +o"), "now holds"), "group granted channel op"); + + // bob, a group member, is auto-opped on join; carol (not in the group) isn't. + assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false }), "000AAAAAB"), "group member auto-opped"); + assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#room".into(), op: false }), "000AAAAAC"), "non-member not opped"); + + // Removing bob from the group revokes his inherited channel access. + gs(&mut e, "000AAAAAA", "DEL !team bob"); + assert_eq!(e.db.channel_join_mode("#room", "bob"), None, "access gone once out of the group"); + + // Managing the group needs the founder or the f flag. + assert!(has(&gs(&mut e, "000AAAAAC", "ADD !team carol"), "founder or the \x02f\x02 flag"), "outsider can't manage"); + gs(&mut e, "000AAAAAA", "FLAGS !team carol +f"); + assert!(has(&gs(&mut e, "000AAAAAC", "ADD !team bob"), "Added"), "carol with f can now manage"); + } + // 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. diff --git a/src/grpc.rs b/src/grpc.rs index 2edcdf1..b6068c6 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -176,6 +176,11 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ReportFiled { .. } | Event::ReportClosed { .. } | Event::ReportDeleted { .. } + | Event::GroupRegistered { .. } + | Event::GroupDropped { .. } + | Event::GroupFounderSet { .. } + | Event::GroupFlagsSet { .. } + | Event::GroupMemberDel { .. } | Event::OperGranted { .. } | Event::OperRevoked { .. } | Event::SessionExceptionAdded { .. } diff --git a/src/main.rs b/src/main.rs index 18fd4a2..32db4f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use fedserv_operserv::OperServ; use fedserv_diceserv::DiceServ; use fedserv_infoserv::InfoServ; use fedserv_reportserv::ReportServ; +use fedserv_groupserv::GroupServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -107,6 +108,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAK", cfg.server.sid), })); } + if enabled("groupserv") { + services.push(Box::new(GroupServ { + uid: format!("{}AAAAAL", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid),