Add GroupServ, with groups that grant channel access
User groups (!name) — a founder plus member accounts, each with group- access flags (the shared flag primitive: F founder, f manage, i invite, c chan-access, s set, m memo). REGISTER/DROP/INFO/LIST/ADD/DEL/FLAGS; event-sourced and federated like accounts. The flagship interconnection: a channel access entry may name a !group, so every member inherits that channel access. Resolution is group-aware — a new db.channel_caps() unions a user's direct access with any group they belong to, and the engine's join handler auto-ops/voices through it. So "ChanServ FLAGS #chan !team +o" auto-ops every member of !team, and removing someone from the group revokes it. Opt-in.
This commit is contained in:
parent
c7a4d5be27
commit
09be99785e
10 changed files with 602 additions and 17 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -330,6 +330,7 @@ dependencies = [
|
||||||
"fedserv-chanserv",
|
"fedserv-chanserv",
|
||||||
"fedserv-diceserv",
|
"fedserv-diceserv",
|
||||||
"fedserv-example",
|
"fedserv-example",
|
||||||
|
"fedserv-groupserv",
|
||||||
"fedserv-hostserv",
|
"fedserv-hostserv",
|
||||||
"fedserv-infoserv",
|
"fedserv-infoserv",
|
||||||
"fedserv-inspircd",
|
"fedserv-inspircd",
|
||||||
|
|
@ -390,6 +391,13 @@ dependencies = [
|
||||||
"fedserv-api",
|
"fedserv-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fedserv-groupserv"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"fedserv-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fedserv-hostserv"
|
name = "fedserv-hostserv"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[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]
|
[package]
|
||||||
name = "fedserv"
|
name = "fedserv"
|
||||||
|
|
@ -21,6 +21,7 @@ fedserv-operserv = { path = "operserv" }
|
||||||
fedserv-diceserv = { path = "diceserv" }
|
fedserv-diceserv = { path = "diceserv" }
|
||||||
fedserv-infoserv = { path = "infoserv" }
|
fedserv-infoserv = { path = "infoserv" }
|
||||||
fedserv-reportserv = { path = "reportserv" }
|
fedserv-reportserv = { path = "reportserv" }
|
||||||
|
fedserv-groupserv = { path = "groupserv" }
|
||||||
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
|
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
|
|
@ -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
|
// The granular group-membership flags (GroupServ), same primitive, different
|
||||||
// flag string. A bare "ov" (no sign) grants. Returns Err with the first invalid
|
// letters: F founder, f modify the member/flags list, i invite members, s set
|
||||||
// flag letter.
|
// group options, c the group may be granted channel access, m group memos.
|
||||||
pub fn apply_flags(current: &str, delta: &str) -> Result<String, char> {
|
pub const GROUP_FLAGS: &str = "Fficsm";
|
||||||
let mut set: Vec<char> = current.chars().filter(|c| ACCESS_FLAGS.contains(*c)).collect();
|
|
||||||
|
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<GroupMemberView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<String, char> {
|
||||||
|
let mut set: Vec<char> = current.chars().filter(|c| valid.contains(*c)).collect();
|
||||||
let mut adding = true;
|
let mut adding = true;
|
||||||
for c in delta.chars() {
|
for c in delta.chars() {
|
||||||
match c {
|
match c {
|
||||||
'+' => adding = true,
|
'+' => adding = true,
|
||||||
'-' => adding = false,
|
'-' => adding = false,
|
||||||
f if ACCESS_FLAGS.contains(f) => {
|
f if valid.contains(f) => {
|
||||||
set.retain(|&x| x != f);
|
set.retain(|&x| x != f);
|
||||||
if adding {
|
if adding {
|
||||||
set.push(f);
|
set.push(f);
|
||||||
|
|
@ -534,8 +577,7 @@ pub fn apply_flags(current: &str, delta: &str) -> Result<String, char> {
|
||||||
other => return Err(other),
|
other => return Err(other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A stable, deduplicated order following ACCESS_FLAGS.
|
Ok(valid.chars().filter(|c| set.contains(c)).collect())
|
||||||
Ok(ACCESS_FLAGS.chars().filter(|c| set.contains(c)).collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One auto-kick entry (a hostmask and the reason shown on kick).
|
// 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 report_del(&mut self, id: u64) -> bool;
|
||||||
fn reports(&self, open_only: bool) -> Vec<ReportView>;
|
fn reports(&self, open_only: bool) -> Vec<ReportView>;
|
||||||
fn report(&self, id: u64) -> Option<ReportView>;
|
fn report(&self, id: u64) -> Option<ReportView>;
|
||||||
|
// 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<bool, ChanError>;
|
||||||
|
fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
|
||||||
|
fn group(&self, name: &str) -> Option<GroupView>;
|
||||||
|
fn groups(&self) -> Vec<String>;
|
||||||
|
fn groups_of(&self, account: &str) -> Vec<String>;
|
||||||
|
// 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`
|
// Runtime operator grants (OperServ OPER), merged with config opers. `expires`
|
||||||
// is an absolute unix time (None = permanent); opers_list hides expired ones.
|
// is an absolute unix time (None = permanent); opers_list hides expired ones.
|
||||||
fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>);
|
fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>);
|
||||||
|
|
@ -1081,11 +1134,11 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn access_flags_apply_and_resolve() {
|
fn access_flags_apply_and_resolve() {
|
||||||
// +/- deltas, dedup, stable order, invalid flag rejected.
|
// +/- deltas, dedup, stable order, invalid flag rejected.
|
||||||
assert_eq!(apply_flags("", "+ov").unwrap(), "ov");
|
assert_eq!(apply_flags("", "+ov", ACCESS_FLAGS).unwrap(), "ov");
|
||||||
assert_eq!(apply_flags("ov", "-o").unwrap(), "v");
|
assert_eq!(apply_flags("ov", "-o", ACCESS_FLAGS).unwrap(), "v");
|
||||||
assert_eq!(apply_flags("v", "+tv").unwrap(), "vt"); // ACCESS_FLAGS order, no dup
|
assert_eq!(apply_flags("v", "+tv", ACCESS_FLAGS).unwrap(), "vt"); // ACCESS_FLAGS order, no dup
|
||||||
assert_eq!(apply_flags("", "ai").unwrap(), "ia"); // bare letters grant
|
assert_eq!(apply_flags("", "ai", ACCESS_FLAGS).unwrap(), "ia"); // bare letters grant
|
||||||
assert_eq!(apply_flags("", "+ox"), Err('x'));
|
assert_eq!(apply_flags("", "+ox", ACCESS_FLAGS), Err('x'));
|
||||||
|
|
||||||
// Legacy presets and flag strings both resolve to capabilities.
|
// Legacy presets and flag strings both resolve to capabilities.
|
||||||
assert_eq!(level_caps("op").auto, Some("+o"));
|
assert_eq!(level_caps("op").auto, Some("+o"));
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut Serv
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let base = current.unwrap_or_default();
|
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,
|
Ok(f) => f,
|
||||||
Err(bad) => {
|
Err(bad) => {
|
||||||
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid flag. Valid flags: \x02{ACCESS_FLAGS}\x02."));
|
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid flag. Valid flags: \x02{ACCESS_FLAGS}\x02."));
|
||||||
|
|
|
||||||
8
groupserv/Cargo.toml
Normal file
8
groupserv/Cargo.toml
Normal file
|
|
@ -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" }
|
||||||
226
groupserv/src/lib.rs
Normal file
226
groupserv/src/lib.rs
Normal file
|
|
@ -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 <!group>, \x02DROP\x02 <!group>, \x02INFO\x02 <!group>, \x02LIST\x02, \x02ADD\x02/\x02DEL\x02 <!group> <account>, \x02FLAGS\x02 <!group> [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 <!group>");
|
||||||
|
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 <!group>");
|
||||||
|
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 <!group>");
|
||||||
|
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 <!group> <account>");
|
||||||
|
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 <!group> <account>");
|
||||||
|
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 <!group> [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."),
|
||||||
|
}
|
||||||
|
}
|
||||||
216
src/engine/db.rs
216
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
|
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
||||||
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
||||||
pub use fedserv_api::{
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -192,6 +192,14 @@ pub enum Event {
|
||||||
ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 },
|
ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 },
|
||||||
ReportClosed { id: u64 },
|
ReportClosed { id: u64 },
|
||||||
ReportDeleted { 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).
|
// Runtime operator grants (OperServ OPER).
|
||||||
OperGranted {
|
OperGranted {
|
||||||
account: String,
|
account: String,
|
||||||
|
|
@ -267,6 +275,11 @@ impl Event {
|
||||||
| Event::ReportFiled { .. }
|
| Event::ReportFiled { .. }
|
||||||
| Event::ReportClosed { .. }
|
| Event::ReportClosed { .. }
|
||||||
| Event::ReportDeleted { .. }
|
| Event::ReportDeleted { .. }
|
||||||
|
| Event::GroupRegistered { .. }
|
||||||
|
| Event::GroupDropped { .. }
|
||||||
|
| Event::GroupFounderSet { .. }
|
||||||
|
| Event::GroupFlagsSet { .. }
|
||||||
|
| Event::GroupMemberDel { .. }
|
||||||
| Event::OperGranted { .. }
|
| Event::OperGranted { .. }
|
||||||
| Event::OperRevoked { .. }
|
| Event::OperRevoked { .. }
|
||||||
| Event::SessionExceptionAdded { .. }
|
| Event::SessionExceptionAdded { .. }
|
||||||
|
|
@ -382,6 +395,22 @@ pub struct News {
|
||||||
pub ts: u64,
|
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<GroupMember>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
// 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.
|
// when, and whether it's still open. Stable id like News.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -405,6 +434,8 @@ pub struct NetData {
|
||||||
// Abuse reports (ReportServ), oldest first.
|
// Abuse reports (ReportServ), oldest first.
|
||||||
pub reports: Vec<Report>,
|
pub reports: Vec<Report>,
|
||||||
pub report_seq: u64,
|
pub report_seq: u64,
|
||||||
|
// User groups (GroupServ).
|
||||||
|
pub groups: Vec<Group>,
|
||||||
// Runtime services operators (OperServ OPER), casefolded account -> grant.
|
// Runtime services operators (OperServ OPER), casefolded account -> grant.
|
||||||
// Merged with the declarative [[oper]] config at the engine.
|
// Merged with the declarative [[oper]] config at the engine.
|
||||||
pub opers: HashMap<String, OperGrant>,
|
pub opers: HashMap<String, OperGrant>,
|
||||||
|
|
@ -1179,6 +1210,12 @@ impl Db {
|
||||||
snapshot.push(Event::ReportClosed { id: r.id });
|
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 {
|
for (account, grant) in &self.net.opers {
|
||||||
snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires });
|
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 })
|
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<bool, ChanError> {
|
||||||
|
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<GroupView> {
|
||||||
|
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<String> {
|
||||||
|
let mut names: Vec<String> = 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<String> {
|
||||||
|
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.
|
/// Add a services ignore, replacing any existing entry for the same mask.
|
||||||
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
|
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
|
||||||
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask));
|
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask));
|
||||||
|
|
@ -3228,6 +3386,35 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
Event::ReportDeleted { id } => {
|
Event::ReportDeleted { id } => {
|
||||||
net.reports.retain(|r| r.id != id);
|
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 } => {
|
Event::OperGranted { account, privs, expires } => {
|
||||||
net.opers.insert(key(&account), OperGrant { privs, expires });
|
net.opers.insert(key(&account), OperGrant { privs, expires });
|
||||||
}
|
}
|
||||||
|
|
@ -3551,6 +3738,33 @@ impl Store for Db {
|
||||||
fn report(&self, id: u64) -> Option<ReportView> {
|
fn report(&self, id: u64) -> Option<ReportView> {
|
||||||
Db::report(self, id)
|
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<bool, ChanError> {
|
||||||
|
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<GroupView> {
|
||||||
|
Db::group(self, name)
|
||||||
|
}
|
||||||
|
fn groups(&self) -> Vec<String> {
|
||||||
|
Db::groups(self)
|
||||||
|
}
|
||||||
|
fn groups_of(&self, account: &str) -> Vec<String> {
|
||||||
|
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<String>, expires: Option<u64>) {
|
fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>) {
|
||||||
Db::oper_grant(self, account, privs, expires)
|
Db::oper_grant(self, account, privs, expires)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -816,7 +816,9 @@ impl Engine {
|
||||||
}
|
}
|
||||||
let account = self.network.account_of(&uid).map(str::to_string);
|
let account = self.network.account_of(&uid).map(str::to_string);
|
||||||
let from = self.chan_service.clone().unwrap_or_default();
|
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());
|
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.
|
// Computed before the match below moves `channel` into its actions.
|
||||||
let greet = self.greet_on_join(&channel, account.as_deref());
|
let greet = self.greet_on_join(&channel, account.as_deref());
|
||||||
|
|
@ -1699,6 +1701,17 @@ fn audit_summary(event: &db::Event) -> Option<String> {
|
||||||
ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"),
|
ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"),
|
||||||
ReportClosed { id } => format!("closed report #{id}"),
|
ReportClosed { id } => format!("closed report #{id}"),
|
||||||
ReportDeleted { id } => format!("deleted 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(", ")),
|
OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")),
|
||||||
OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"),
|
OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"),
|
||||||
SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"),
|
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:?}");
|
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
|
// 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
|
// a flag-granted +o gets auto-op like a legacy op entry; changing needs the
|
||||||
// founder or the 'a' flag.
|
// founder or the 'a' flag.
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,11 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
||||||
| Event::ReportFiled { .. }
|
| Event::ReportFiled { .. }
|
||||||
| Event::ReportClosed { .. }
|
| Event::ReportClosed { .. }
|
||||||
| Event::ReportDeleted { .. }
|
| Event::ReportDeleted { .. }
|
||||||
|
| Event::GroupRegistered { .. }
|
||||||
|
| Event::GroupDropped { .. }
|
||||||
|
| Event::GroupFounderSet { .. }
|
||||||
|
| Event::GroupFlagsSet { .. }
|
||||||
|
| Event::GroupMemberDel { .. }
|
||||||
| Event::OperGranted { .. }
|
| Event::OperGranted { .. }
|
||||||
| Event::OperRevoked { .. }
|
| Event::OperRevoked { .. }
|
||||||
| Event::SessionExceptionAdded { .. }
|
| Event::SessionExceptionAdded { .. }
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ use fedserv_operserv::OperServ;
|
||||||
use fedserv_diceserv::DiceServ;
|
use fedserv_diceserv::DiceServ;
|
||||||
use fedserv_infoserv::InfoServ;
|
use fedserv_infoserv::InfoServ;
|
||||||
use fedserv_reportserv::ReportServ;
|
use fedserv_reportserv::ReportServ;
|
||||||
|
use fedserv_groupserv::GroupServ;
|
||||||
use fedserv_example::ExampleServ;
|
use fedserv_example::ExampleServ;
|
||||||
use fedserv_inspircd::InspIrcd;
|
use fedserv_inspircd::InspIrcd;
|
||||||
use fedserv_nickserv::NickServ;
|
use fedserv_nickserv::NickServ;
|
||||||
|
|
@ -107,6 +108,11 @@ async fn main() -> Result<()> {
|
||||||
uid: format!("{}AAAAAK", cfg.server.sid),
|
uid: format!("{}AAAAAK", cfg.server.sid),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if enabled("groupserv") {
|
||||||
|
services.push(Box::new(GroupServ {
|
||||||
|
uid: format!("{}AAAAAL", cfg.server.sid),
|
||||||
|
}));
|
||||||
|
}
|
||||||
if enabled("example") {
|
if enabled("example") {
|
||||||
services.push(Box::new(ExampleServ {
|
services.push(Box::new(ExampleServ {
|
||||||
uid: format!("{}AAAAAC", cfg.server.sid),
|
uid: format!("{}AAAAAC", cfg.server.sid),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue