chanserv: support param mode locks (+f flood, +j joinflood)
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Mode locks were char-only, so Anope FLOOD/JOINFLOOD locks were dropped on migration. Carry a value per locked param-mode through the event log, MLOCK parsing/display, drift enforcement, compaction, and the Anope importer, so a migrated network keeps its flood protection verbatim.
This commit is contained in:
parent
3f97a66dbb
commit
9d94dd5eb0
9 changed files with 168 additions and 26 deletions
|
|
@ -752,6 +752,9 @@ pub struct ChannelView {
|
|||
// Mode-lock: chars services keep set / unset (besides the implicit +r).
|
||||
pub lock_on: String,
|
||||
pub lock_off: String,
|
||||
// Param values for locked param-modes (+f flood, +j joinflood), in the order
|
||||
// their mode chars appear in `lock_on`.
|
||||
pub lock_params: Vec<(char, String)>,
|
||||
pub access: Vec<ChanAccessView>,
|
||||
pub akick: Vec<ChanAkickView>,
|
||||
pub desc: String,
|
||||
|
|
@ -856,6 +859,13 @@ impl ChannelView {
|
|||
s.push('-');
|
||||
s.push_str(&self.lock_off);
|
||||
}
|
||||
// Param modes take their argument as a trailing token, in mode-char order.
|
||||
for ch in self.lock_on.chars() {
|
||||
if let Some((_, param)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
|
||||
s.push(' ');
|
||||
s.push_str(param);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
|
@ -1059,7 +1069,7 @@ pub trait Store {
|
|||
fn session_exceptions(&self) -> Vec<(String, u32, String)>;
|
||||
fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
|
||||
fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>;
|
||||
fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>;
|
||||
fn set_mlock(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError>;
|
||||
fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError>;
|
||||
fn set_url(&mut self, channel: &str, url: &str) -> Result<(), ChanError>;
|
||||
fn set_channel_email(&mut self, channel: &str, email: &str) -> Result<(), ChanError>;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
|||
ctx.notice(me, from.uid, "You must be the founder of both channels.");
|
||||
return;
|
||||
}
|
||||
let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off);
|
||||
let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off, sinfo.lock_params.clone());
|
||||
for a in &sinfo.access {
|
||||
let _ = db.access_add(dest, &a.account, &a.level);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ impl Service for ChanServ {
|
|||
if suspended_block(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let (on, off) = parse_mlock(&args[2..].concat());
|
||||
match db.set_mlock(chan, &on, &off) {
|
||||
let (on, off, params) = parse_mlock(&args[2..]);
|
||||
match db.set_mlock(chan, &on, &off, params) {
|
||||
Ok(()) => {
|
||||
if let Some(info) = db.channel(chan) {
|
||||
ctx.channel_mode(me, chan, &info.lock_modes()); // apply it now
|
||||
|
|
@ -369,10 +369,21 @@ fn peace_blocks(me: &str, from: &Sender, chan: &str, target_uid: &str, ctx: &mut
|
|||
|
||||
// Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is
|
||||
// implicit for a registered channel and is ignored here.
|
||||
fn parse_mlock(spec: &str) -> (String, String) {
|
||||
// Locked add-modes that carry a param (+f flood, +j joinflood): a param is
|
||||
// consumed from the trailing tokens, IRC MODE order.
|
||||
fn is_param_mode(c: char) -> bool {
|
||||
matches!(c, 'f' | 'j')
|
||||
}
|
||||
|
||||
// Parse `+ntf-s <param...>` into (on, off, params). Tokens after the mode string
|
||||
// supply, in order, a value for each added param mode.
|
||||
fn parse_mlock(tokens: &[&str]) -> (String, String, Vec<(char, String)>) {
|
||||
let (mut on, mut off) = (String::new(), String::new());
|
||||
let mut params: Vec<(char, String)> = Vec::new();
|
||||
let mut adding = true;
|
||||
for ch in spec.chars() {
|
||||
let modes = tokens.first().copied().unwrap_or("");
|
||||
let mut rest = tokens.iter().skip(1);
|
||||
for ch in modes.chars() {
|
||||
match ch {
|
||||
'+' => adding = true,
|
||||
'-' => adding = false,
|
||||
|
|
@ -380,8 +391,17 @@ fn parse_mlock(spec: &str) -> (String, String) {
|
|||
m if m.is_ascii_alphabetic() => {
|
||||
on.retain(|c| c != m);
|
||||
off.retain(|c| c != m);
|
||||
params.retain(|(c, _)| *c != m);
|
||||
if adding {
|
||||
on.push(m);
|
||||
if is_param_mode(m) {
|
||||
if let Some(&p) = rest.next() {
|
||||
params.push((m, p.to_string()));
|
||||
} else {
|
||||
// No value supplied: drop the mode rather than lock a paramless +f.
|
||||
on.pop();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
off.push(m);
|
||||
}
|
||||
|
|
@ -389,10 +409,10 @@ fn parse_mlock(spec: &str) -> (String, String) {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
(on, off)
|
||||
(on, off, params)
|
||||
}
|
||||
|
||||
// Render a channel's lock as "+on-off" for display.
|
||||
// Render a channel's lock as "+on-off <params>" for display.
|
||||
fn show_mlock(info: &ChannelView) -> String {
|
||||
let mut s = String::new();
|
||||
if !info.lock_on.is_empty() {
|
||||
|
|
@ -403,6 +423,45 @@ fn show_mlock(info: &ChannelView) -> String {
|
|||
s.push('-');
|
||||
s.push_str(&info.lock_off);
|
||||
}
|
||||
for (_, param) in &info.lock_params {
|
||||
s.push(' ');
|
||||
s.push_str(param);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_mlock;
|
||||
|
||||
#[test]
|
||||
fn parses_plain_modes() {
|
||||
let (on, off, params) = parse_mlock(&["+nt-s"]);
|
||||
assert_eq!(on, "nt");
|
||||
assert_eq!(off, "s");
|
||||
assert!(params.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_param_mode_value() {
|
||||
let (on, off, params) = parse_mlock(&["+ntf", "mute:7:8s"]);
|
||||
assert_eq!(on, "ntf");
|
||||
assert_eq!(off, "");
|
||||
assert_eq!(params, vec![('f', "mute:7:8s".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_param_modes_consume_tokens_in_order() {
|
||||
let (on, _off, params) = parse_mlock(&["+fj", "mute:7:8s", "10:6"]);
|
||||
assert_eq!(on, "fj");
|
||||
assert_eq!(params, vec![('f', "mute:7:8s".to_string()), ('j', "10:6".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_mode_without_value_is_dropped() {
|
||||
let (on, _off, params) = parse_mlock(&["+ntf"]);
|
||||
assert_eq!(on, "nt", "paramless +f is not locked");
|
||||
assert!(params.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ impl Db {
|
|||
self.log
|
||||
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
||||
.map_err(|_| ChanError::Internal)?;
|
||||
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -45,15 +45,21 @@ impl Db {
|
|||
}
|
||||
|
||||
/// Set the mode-lock (chars to keep set / unset) for a registered channel.
|
||||
#[cfg(test)]
|
||||
pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> {
|
||||
self.set_mlock_params(name, on, off, Vec::new())
|
||||
}
|
||||
|
||||
pub fn set_mlock_params(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError> {
|
||||
let k = key(name);
|
||||
if !self.channels.contains_key(&k) {
|
||||
return Err(ChanError::NoChannel);
|
||||
}
|
||||
self.log
|
||||
.append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string() })
|
||||
.append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string(), params: params.clone() })
|
||||
.map_err(|_| ChanError::Internal)?;
|
||||
let c = self.channels.get_mut(&k).unwrap();
|
||||
c.lock_params = params;
|
||||
c.lock_on = on.to_string();
|
||||
c.lock_off = off.to_string();
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ pub enum Event {
|
|||
NickUngrouped { nick: String },
|
||||
ChannelRegistered { name: String, founder: String, ts: u64 },
|
||||
ChannelDropped { name: String },
|
||||
ChannelMlock { name: String, on: String, off: String },
|
||||
ChannelMlock { name: String, on: String, off: String, #[serde(default)] params: Vec<(char, String)> },
|
||||
ChannelAccessAdd { channel: String, account: String, level: String },
|
||||
ChannelAccessDel { channel: String, account: String },
|
||||
ChannelAkickAdd { channel: String, mask: String, reason: String },
|
||||
|
|
@ -429,15 +429,16 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
|||
grouped.remove(&key(&nick));
|
||||
}
|
||||
Event::ChannelRegistered { name, founder, ts } => {
|
||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
}
|
||||
Event::ChannelDropped { name } => {
|
||||
channels.remove(&key(&name));
|
||||
}
|
||||
Event::ChannelMlock { name, on, off } => {
|
||||
Event::ChannelMlock { name, on, off, params } => {
|
||||
if let Some(c) = channels.get_mut(&key(&name)) {
|
||||
c.lock_on = on;
|
||||
c.lock_off = off;
|
||||
c.lock_params = params;
|
||||
}
|
||||
}
|
||||
Event::ChannelAccessAdd { channel, account, level } => {
|
||||
|
|
|
|||
|
|
@ -408,6 +408,10 @@ pub struct ChannelInfo {
|
|||
pub lock_on: String,
|
||||
#[serde(default)]
|
||||
pub lock_off: String,
|
||||
// Params for locked +modes that take one (e.g. ('f', "mute:7:8s"), ('j', "10:6")).
|
||||
// Each char here is also in `lock_on`; applied and re-asserted with its param.
|
||||
#[serde(default)]
|
||||
pub lock_params: Vec<(char, String)>,
|
||||
#[serde(default)]
|
||||
pub access: Vec<ChanAccess>,
|
||||
#[serde(default)]
|
||||
|
|
@ -613,6 +617,13 @@ impl ChannelInfo {
|
|||
s.push('-');
|
||||
s.push_str(&self.lock_off);
|
||||
}
|
||||
// Params for locked +modes that take one, in the order they appear in lock_on.
|
||||
for ch in self.lock_on.chars() {
|
||||
if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
|
||||
s.push(' ');
|
||||
s.push_str(p);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
|
@ -647,6 +658,13 @@ impl ChannelInfo {
|
|||
s.push('-');
|
||||
s.push_str(&reremove);
|
||||
}
|
||||
// A re-asserted param mode (e.g. +f) must carry its locked param.
|
||||
for ch in readd.chars() {
|
||||
if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
|
||||
s.push(' ');
|
||||
s.push_str(p);
|
||||
}
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
|
@ -1087,7 +1105,7 @@ impl Db {
|
|||
for c in self.channels.values() {
|
||||
snapshot.push(Event::ChannelRegistered { name: c.name.clone(), founder: c.founder.clone(), ts: c.ts });
|
||||
if !c.lock_on.is_empty() || !c.lock_off.is_empty() {
|
||||
snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() });
|
||||
snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone(), params: c.lock_params.clone() });
|
||||
}
|
||||
for a in &c.access {
|
||||
snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() });
|
||||
|
|
|
|||
|
|
@ -371,8 +371,8 @@ impl Store for Db {
|
|||
fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
|
||||
Db::drop_channel(self, name)
|
||||
}
|
||||
fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> {
|
||||
Db::set_mlock(self, name, on, off)
|
||||
fn set_mlock(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError> {
|
||||
Db::set_mlock_params(self, name, on, off, params)
|
||||
}
|
||||
fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> {
|
||||
Db::set_desc(self, channel, desc)
|
||||
|
|
@ -560,6 +560,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
|||
ts: c.ts,
|
||||
lock_on: c.lock_on.clone(),
|
||||
lock_off: c.lock_off.clone(),
|
||||
lock_params: c.lock_params.clone(),
|
||||
access: c.access.iter().map(|a| ChanAccessView { account: a.account.clone(), level: a.level.clone() }).collect(),
|
||||
akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(),
|
||||
desc: c.desc.clone(),
|
||||
|
|
|
|||
|
|
@ -438,6 +438,27 @@
|
|||
assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen");
|
||||
}
|
||||
|
||||
// A param mode lock (+f flood) carries its value through render, enforcement,
|
||||
// and a reopen — the Anope FLOOD/JOINFLOOD migration must not lose it.
|
||||
#[test]
|
||||
fn param_mode_lock_carries_its_value() {
|
||||
let p = tmp("mlock_param");
|
||||
let mut db = Db::open(&p, "local");
|
||||
db.register_channel("#chan", "alice").unwrap();
|
||||
db.set_mlock_params("#chan", "ntf", "", vec![('f', "mute:7:8s".to_string())]).unwrap();
|
||||
|
||||
let info = db.channel("#chan").unwrap();
|
||||
assert_eq!(info.lock_modes(), "+rntf mute:7:8s");
|
||||
// Removing the locked param mode re-asserts it with its value.
|
||||
assert_eq!(info.enforce("-f"), Some("+f mute:7:8s".to_string()));
|
||||
|
||||
drop(db);
|
||||
let db = Db::open(&p, "local");
|
||||
let info = db.channel("#chan").unwrap();
|
||||
assert_eq!(info.lock_params, vec![('f', "mute:7:8s".to_string())], "param survives reopen");
|
||||
assert_eq!(info.lock_modes(), "+rntf mute:7:8s");
|
||||
}
|
||||
|
||||
// Access list drives join modes, is case-insensitive, and persists.
|
||||
#[test]
|
||||
fn access_list_grants_join_modes() {
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ const SERVICE_NICKS: &[&str] = &[
|
|||
"groupserv", "statserv", "infoserv", "global", "chanfix", "diceserv", "reportserv",
|
||||
];
|
||||
|
||||
// Anope ModeLock name -> InspIRCd mode char, for the param-less locks we can carry.
|
||||
// Param modes (FLOOD/JOINFLOOD) are skipped: Echo's mlock is char-only.
|
||||
// Anope ModeLock name -> InspIRCd mode char, for the param-less locks.
|
||||
// Param modes (FLOOD/JOINFLOOD) are handled separately by param_mode_char.
|
||||
fn mode_char(name: &str) -> Option<char> {
|
||||
Some(match name {
|
||||
"NOEXTERNAL" => 'n',
|
||||
|
|
@ -56,6 +56,16 @@ fn mode_char(name: &str) -> Option<char> {
|
|||
})
|
||||
}
|
||||
|
||||
// Param-carrying mode locks: the ircd mode takes an argument, so the lock keeps
|
||||
// the stored value and re-asserts `+<char> <value>`.
|
||||
fn param_mode_char(name: &str) -> Option<char> {
|
||||
Some(match name {
|
||||
"FLOOD" => 'f',
|
||||
"JOINFLOOD" => 'j',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// A non-empty, non-"None" string field (Anope stores genuine text as strings).
|
||||
fn field<'a>(v: &'a Value, k: &str) -> Option<&'a str> {
|
||||
v.get(k).and_then(Value::as_str).filter(|s| !s.is_empty() && *s != "None")
|
||||
|
|
@ -249,20 +259,35 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re
|
|||
sum.access += 1;
|
||||
}
|
||||
|
||||
// Mode locks: aggregate the param-less +modes per channel.
|
||||
let mut locks: HashMap<String, String> = HashMap::new();
|
||||
// Mode locks: aggregate the +modes per channel. Param modes (+f flood,
|
||||
// +j joinflood) carry their stored value so the lock re-enforces verbatim.
|
||||
let mut locks: HashMap<String, (String, Vec<(char, String)>)> = HashMap::new();
|
||||
for ml in records(&data, "ModeLock") {
|
||||
let (Some(chan), Some(name)) = (field(ml, "ci"), field(ml, "name")) else { continue };
|
||||
if !flag(ml, "set") {
|
||||
continue;
|
||||
}
|
||||
match mode_char(name) {
|
||||
Some(ch) => locks.entry(chan.to_string()).or_default().push(ch),
|
||||
None => sum.skipped.push(format!("modelock {chan} {name}: unmapped/param mode")),
|
||||
if let Some(ch) = mode_char(name) {
|
||||
locks.entry(chan.to_string()).or_default().0.push(ch);
|
||||
} else if let Some(ch) = param_mode_char(name) {
|
||||
let Some(param) = field(ml, "param") else {
|
||||
sum.skipped.push(format!("modelock {chan} {name}: param mode with no value"));
|
||||
continue;
|
||||
};
|
||||
let entry = locks.entry(chan.to_string()).or_default();
|
||||
entry.0.push(ch);
|
||||
entry.1.push((ch, param.to_string()));
|
||||
} else {
|
||||
sum.skipped.push(format!("modelock {chan} {name}: unmapped mode"));
|
||||
}
|
||||
}
|
||||
for (chan, on) in &locks {
|
||||
db.migrate_append(Event::ChannelMlock { name: chan.clone(), on: on.clone(), off: String::new() })?;
|
||||
for (chan, (on, params)) in &locks {
|
||||
db.migrate_append(Event::ChannelMlock {
|
||||
name: chan.clone(),
|
||||
on: on.clone(),
|
||||
off: String::new(),
|
||||
params: params.clone(),
|
||||
})?;
|
||||
sum.mlocked += 1;
|
||||
}
|
||||
|
||||
|
|
@ -349,7 +374,8 @@ mod tests {
|
|||
let chan = db.channel("#chan").expect("channel migrated");
|
||||
assert_eq!(chan.founder, "alice");
|
||||
assert_eq!(chan.desc, "a place");
|
||||
assert_eq!(chan.lock_on, "nt", "param-less mlocks kept, FLOOD skipped");
|
||||
assert_eq!(chan.lock_on, "ntf", "param-less and param mlocks both kept");
|
||||
assert_eq!(chan.lock_params, vec![('f', "5:10".to_string())], "FLOOD param carried");
|
||||
assert_eq!(chan.assigned_bot.as_deref(), Some("Botty"));
|
||||
assert!(chan.settings.keeptopic && chan.settings.peace, "channel SET flags migrated");
|
||||
assert!(!chan.settings.signkick, "unset flags stay default (PERSIST has no equivalent, ignored)");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue