exemptchanops +X: per-rank exemptions from flood/anticaps/repeat/censor/blockcolor/etc
This commit is contained in:
parent
789c2ca14f
commit
80cc61e7f8
6 changed files with 74 additions and 22 deletions
|
|
@ -275,10 +275,11 @@ pub struct Channel {
|
||||||
pub rmembers: HashMap<String, Member>, // remote members, by network uuid (S2S)
|
pub rmembers: HashMap<String, Member>, // remote members, by network uuid (S2S)
|
||||||
pub modes: ChanModes,
|
pub modes: ChanModes,
|
||||||
pub bans: Vec<Ban>,
|
pub bans: Vec<Ban>,
|
||||||
pub excepts: Vec<Ban>, // +e ban exceptions
|
pub excepts: Vec<Ban>, // +e ban exceptions
|
||||||
pub invex: Vec<Ban>, // +I invite exceptions
|
pub invex: Vec<Ban>, // +I invite exceptions
|
||||||
pub filters: Vec<Ban>, // +g word/glob message filters (mask = the glob)
|
pub filters: Vec<Ban>, // +g word/glob message filters (mask = the glob)
|
||||||
pub invites: HashSet<Uid>, // uids allowed past +i
|
pub exemptchanops: Vec<Ban>, // +X exemptions (mask = "restriction:rankchar")
|
||||||
|
pub invites: HashSet<Uid>, // uids allowed past +i
|
||||||
pub created: u64,
|
pub created: u64,
|
||||||
// --- ephemeral flood counters (not modes; never rendered or synced) -------
|
// --- ephemeral flood counters (not modes; never rendered or synced) -------
|
||||||
pub msgflood_hits: HashMap<Uid, Vec<u64>>, // +f per-user message times
|
pub msgflood_hits: HashMap<Uid, Vec<u64>>, // +f per-user message times
|
||||||
|
|
@ -306,6 +307,7 @@ impl Channel {
|
||||||
excepts: Vec::new(),
|
excepts: Vec::new(),
|
||||||
invex: Vec::new(),
|
invex: Vec::new(),
|
||||||
filters: Vec::new(),
|
filters: Vec::new(),
|
||||||
|
exemptchanops: Vec::new(),
|
||||||
invites: HashSet::new(),
|
invites: HashSet::new(),
|
||||||
created: now(),
|
created: now(),
|
||||||
msgflood_hits: HashMap::new(),
|
msgflood_hits: HashMap::new(),
|
||||||
|
|
@ -355,6 +357,32 @@ impl Server {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// +X exemptchanops — is `uid` exempt from `restriction` in this channel? True
|
||||||
|
/// when a `+X <restriction>:<rankchar>` entry names a rank they meet or exceed.
|
||||||
|
pub fn chanop_exempt(&self, uid: Uid, key: &str, restriction: &str) -> bool {
|
||||||
|
let Some(ch) = self.channels.get(key) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let rank = self.rank(uid, key);
|
||||||
|
ch.exemptchanops.iter().any(|e| {
|
||||||
|
let Some((r, prefix)) = e.mask.split_once(':') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !r.eq_ignore_ascii_case(restriction) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let need = match prefix.chars().next() {
|
||||||
|
Some('q') => RANK_OWNER,
|
||||||
|
Some('a') => RANK_ADMIN,
|
||||||
|
Some('o') => RANK_OP,
|
||||||
|
Some('h') => RANK_HALFOP,
|
||||||
|
Some('v') => RANK_VOICE,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
rank >= need
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Lift any expired TBAN timed bans, announcing `MODE -b` to each channel.
|
/// Lift any expired TBAN timed bans, announcing `MODE -b` to each channel.
|
||||||
/// Called from the background tick.
|
/// Called from the background tick.
|
||||||
pub fn purge_tbans(&mut self) {
|
pub fn purge_tbans(&mut self) {
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,11 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.map(|c| c.modes.moderated)
|
.map(|c| c.modes.moderated)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if moderated && s.rank(uid, &key) < RANK_VOICE && !op_only {
|
if moderated
|
||||||
|
&& s.rank(uid, &key) < RANK_VOICE
|
||||||
|
&& !op_only
|
||||||
|
&& !s.chanop_exempt(uid, &key, "moderated")
|
||||||
|
{
|
||||||
if !notice {
|
if !notice {
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -206,7 +210,11 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.map(|c| c.modes.reg_moderated)
|
.map(|c| c.modes.reg_moderated)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if reg_moderated && s.rank(uid, &key) < RANK_VOICE && !s.is_logged_in(uid) {
|
if reg_moderated
|
||||||
|
&& s.rank(uid, &key) < RANK_VOICE
|
||||||
|
&& !s.is_logged_in(uid)
|
||||||
|
&& !s.chanop_exempt(uid, &key, "regmoderated")
|
||||||
|
{
|
||||||
if !notice {
|
if !notice {
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -218,7 +226,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
}
|
}
|
||||||
// +d delaymsg — a just-joined unprivileged user must wait before speaking
|
// +d delaymsg — a just-joined unprivileged user must wait before speaking
|
||||||
if let Some(secs) = s.channels.get(&key).and_then(|c| c.modes.delaymsg) {
|
if let Some(secs) = s.channels.get(&key).and_then(|c| c.modes.delaymsg) {
|
||||||
if s.rank(uid, &key) < RANK_VOICE {
|
if s.rank(uid, &key) < RANK_VOICE && !s.chanop_exempt(uid, &key, "delaymsg") {
|
||||||
let joined = s
|
let joined = s
|
||||||
.channels
|
.channels
|
||||||
.get(&key)
|
.get(&key)
|
||||||
|
|
@ -251,7 +259,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
// +f message flood — ops/half-ops and opers are exempt; others get kicked
|
// +f message flood — ops/half-ops and opers are exempt; others get kicked
|
||||||
let flood_exempt = s.rank(uid, &key) >= RANK_HALFOP
|
let flood_exempt = s.rank(uid, &key) >= RANK_HALFOP
|
||||||
|| s.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false);
|
|| s.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false);
|
||||||
if !flood_exempt {
|
if !flood_exempt && !s.chanop_exempt(uid, &key, "flood") {
|
||||||
if let Some(ban) = s.messageflood_hit(uid, &key) {
|
if let Some(ban) = s.messageflood_hit(uid, &key) {
|
||||||
s.flood_kick(uid, &key, ban);
|
s.flood_kick(uid, &key, ban);
|
||||||
return CmdResult::Fail;
|
return CmdResult::Fail;
|
||||||
|
|
@ -270,10 +278,10 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if notice && no_notice {
|
if notice && no_notice && !s.chanop_exempt(uid, &key, "nonotice") {
|
||||||
return CmdResult::Fail; // +T — NOTICEs are silently dropped
|
return CmdResult::Fail; // +T — NOTICEs are silently dropped
|
||||||
}
|
}
|
||||||
if no_ctcp && is_ctcp(text) && !is_action(text) {
|
if no_ctcp && is_ctcp(text) && !is_action(text) && !s.chanop_exempt(uid, &key, "noctcp") {
|
||||||
if !notice {
|
if !notice {
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -283,7 +291,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
}
|
}
|
||||||
return CmdResult::Fail;
|
return CmdResult::Fail;
|
||||||
}
|
}
|
||||||
if no_color && has_formatting(text) {
|
if no_color && has_formatting(text) && !s.chanop_exempt(uid, &key, "blockcolor") {
|
||||||
if !notice {
|
if !notice {
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -310,7 +318,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.map(|c| c.filters.iter().any(|f| glob_match(&f.mask, text)))
|
.map(|c| c.filters.iter().any(|f| glob_match(&f.mask, text)))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if filtered {
|
if filtered && !s.chanop_exempt(uid, &key, "filter") {
|
||||||
if !notice {
|
if !notice {
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -320,7 +328,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
}
|
}
|
||||||
return CmdResult::Fail;
|
return CmdResult::Fail;
|
||||||
}
|
}
|
||||||
let mut body = if strip {
|
let mut body = if strip && !s.chanop_exempt(uid, &key, "stripcolor") {
|
||||||
strip_formatting(text)
|
strip_formatting(text)
|
||||||
} else {
|
} else {
|
||||||
text.clone()
|
text.clone()
|
||||||
|
|
@ -331,7 +339,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.map(|c| c.modes.censor)
|
.map(|c| c.modes.censor)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if censor_on && !s.censor.is_empty() {
|
if censor_on && !s.censor.is_empty() && !s.chanop_exempt(uid, &key, "censor") {
|
||||||
match apply_censor(&body, &s.censor) {
|
match apply_censor(&body, &s.censor) {
|
||||||
Some(b) => body = b,
|
Some(b) => body = b,
|
||||||
None => {
|
None => {
|
||||||
|
|
@ -347,7 +355,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// +B anticaps — reject a mostly-uppercase message (ops/opers exempt)
|
// +B anticaps — reject a mostly-uppercase message (ops/opers exempt)
|
||||||
if !flood_exempt {
|
if !flood_exempt && !s.chanop_exempt(uid, &key, "anticaps") {
|
||||||
if let Some(pct) = s.channels.get(&key).and_then(|c| c.modes.anticaps) {
|
if let Some(pct) = s.channels.get(&key).and_then(|c| c.modes.anticaps) {
|
||||||
if caps_percent(&body).map(|p| p >= pct).unwrap_or(false) {
|
if caps_percent(&body).map(|p| p >= pct).unwrap_or(false) {
|
||||||
if !notice {
|
if !notice {
|
||||||
|
|
@ -363,7 +371,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
}
|
}
|
||||||
// +K repeat — reject a line the sender just repeated; else remember it (ops exempt)
|
// +K repeat — reject a line the sender just repeated; else remember it (ops exempt)
|
||||||
if let Some(n) = s.channels.get(&key).and_then(|c| c.modes.repeat) {
|
if let Some(n) = s.channels.get(&key).and_then(|c| c.modes.repeat) {
|
||||||
if s.rank(uid, &key) < RANK_HALFOP {
|
if s.rank(uid, &key) < RANK_HALFOP && !s.chanop_exempt(uid, &key, "repeat") {
|
||||||
let repeated = s
|
let repeated = s
|
||||||
.channels
|
.channels
|
||||||
.get(&key)
|
.get(&key)
|
||||||
|
|
|
||||||
21
src/mode.rs
21
src/mode.rs
|
|
@ -92,6 +92,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[
|
||||||
&OPMODERATED,
|
&OPMODERATED,
|
||||||
&DELAYMSG,
|
&DELAYMSG,
|
||||||
&REPEAT,
|
&REPEAT,
|
||||||
|
&EXEMPTCHANOPS,
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ----------
|
// --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ----------
|
||||||
|
|
@ -477,7 +478,8 @@ enum ListKind {
|
||||||
Ban,
|
Ban,
|
||||||
Except,
|
Except,
|
||||||
Invex,
|
Invex,
|
||||||
Filter, // +g — message word/glob filters (not host masks)
|
Filter, // +g — message word/glob filters (not host masks)
|
||||||
|
ExemptChanOps, // +X — "restriction:rankchar" exemptions
|
||||||
}
|
}
|
||||||
impl ListKind {
|
impl ListKind {
|
||||||
fn list<'a>(&self, c: &'a Channel) -> &'a Vec<Ban> {
|
fn list<'a>(&self, c: &'a Channel) -> &'a Vec<Ban> {
|
||||||
|
|
@ -486,6 +488,7 @@ impl ListKind {
|
||||||
ListKind::Except => &c.excepts,
|
ListKind::Except => &c.excepts,
|
||||||
ListKind::Invex => &c.invex,
|
ListKind::Invex => &c.invex,
|
||||||
ListKind::Filter => &c.filters,
|
ListKind::Filter => &c.filters,
|
||||||
|
ListKind::ExemptChanOps => &c.exemptchanops,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn list_mut<'a>(&self, c: &'a mut Channel) -> &'a mut Vec<Ban> {
|
fn list_mut<'a>(&self, c: &'a mut Channel) -> &'a mut Vec<Ban> {
|
||||||
|
|
@ -494,6 +497,7 @@ impl ListKind {
|
||||||
ListKind::Except => &mut c.excepts,
|
ListKind::Except => &mut c.excepts,
|
||||||
ListKind::Invex => &mut c.invex,
|
ListKind::Invex => &mut c.invex,
|
||||||
ListKind::Filter => &mut c.filters,
|
ListKind::Filter => &mut c.filters,
|
||||||
|
ListKind::ExemptChanOps => &mut c.exemptchanops,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// (per-entry numeric, end-of-list numeric, name for the "End of …" line)
|
/// (per-entry numeric, end-of-list numeric, name for the "End of …" line)
|
||||||
|
|
@ -503,12 +507,17 @@ impl ListKind {
|
||||||
ListKind::Except => (RPL_EXCEPTLIST, RPL_ENDOFEXCEPTLIST, "exception list"),
|
ListKind::Except => (RPL_EXCEPTLIST, RPL_ENDOFEXCEPTLIST, "exception list"),
|
||||||
ListKind::Invex => (RPL_INVEXLIST, RPL_ENDOFINVEXLIST, "invite list"),
|
ListKind::Invex => (RPL_INVEXLIST, RPL_ENDOFINVEXLIST, "invite list"),
|
||||||
ListKind::Filter => (RPL_SPAMFILTER, RPL_ENDOFSPAMFILTER, "spamfilter list"),
|
ListKind::Filter => (RPL_SPAMFILTER, RPL_ENDOFSPAMFILTER, "spamfilter list"),
|
||||||
|
ListKind::ExemptChanOps => (
|
||||||
|
RPL_EXEMPTIONLIST,
|
||||||
|
RPL_ENDOFEXEMPTIONLIST,
|
||||||
|
"exemptchanops list",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Ban-style lists hold host masks and get filled out to `nick!user@host`;
|
/// Ban-style lists hold host masks and get filled out to `nick!user@host`;
|
||||||
/// the +g filter list holds literal word/globs and is stored verbatim.
|
/// the +g filter and +X lists hold literal strings and are stored verbatim.
|
||||||
fn normalizes(&self) -> bool {
|
fn normalizes(&self) -> bool {
|
||||||
!matches!(self, ListKind::Filter)
|
matches!(self, ListKind::Ban | ListKind::Except | ListKind::Invex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,6 +541,10 @@ static FILTER: ListMode = ListMode {
|
||||||
ch: 'g',
|
ch: 'g',
|
||||||
kind: ListKind::Filter,
|
kind: ListKind::Filter,
|
||||||
};
|
};
|
||||||
|
static EXEMPTCHANOPS: ListMode = ListMode {
|
||||||
|
ch: 'X',
|
||||||
|
kind: ListKind::ExemptChanOps,
|
||||||
|
};
|
||||||
|
|
||||||
impl ChanMode for ListMode {
|
impl ChanMode for ListMode {
|
||||||
fn letter(&self) -> char {
|
fn letter(&self) -> char {
|
||||||
|
|
@ -1234,7 +1247,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_covers_all_channel_modes() {
|
fn registry_covers_all_channel_modes() {
|
||||||
for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdK".chars() {
|
for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdKX".chars() {
|
||||||
assert!(chan_mode(c).is_some(), "missing handler for +{c}");
|
assert!(chan_mode(c).is_some(), "missing handler for +{c}");
|
||||||
}
|
}
|
||||||
assert!(chan_mode('y').is_none());
|
assert!(chan_mode('y').is_none());
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ impl Module for ReputationMod {
|
||||||
*e = (*e + 1).min(REP_CAP);
|
*e = (*e + 1).min(REP_CAP);
|
||||||
}
|
}
|
||||||
self.ticks += 1;
|
self.ticks += 1;
|
||||||
|
#[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable on our MSRV
|
||||||
if self.ticks % SAVE_EVERY == 0 {
|
if self.ticks % SAVE_EVERY == 0 {
|
||||||
save(s);
|
save(s);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ pub const ERR_DELAYREJOIN: u16 = 495; // +J — must wait before rejoining after
|
||||||
pub const ERR_CANTSENDTOUSER: u16 = 531; // +c — no shared channel with the target
|
pub const ERR_CANTSENDTOUSER: u16 = 531; // +c — no shared channel with the target
|
||||||
pub const ERR_BADCHANNEL: u16 = 926; // CBAN — this channel name is forbidden
|
pub const ERR_BADCHANNEL: u16 = 926; // CBAN — this channel name is forbidden
|
||||||
pub const RPL_ENDOFSPAMFILTER: u16 = 940; // end of the +g word-filter list
|
pub const RPL_ENDOFSPAMFILTER: u16 = 940; // end of the +g word-filter list
|
||||||
|
pub const RPL_EXEMPTIONLIST: u16 = 954; // +X exemptchanops entry
|
||||||
|
pub const RPL_ENDOFEXEMPTIONLIST: u16 = 953; // end of the +X list
|
||||||
pub const RPL_SPAMFILTER: u16 = 941; // one +g word-filter entry
|
pub const RPL_SPAMFILTER: u16 = 941; // one +g word-filter entry
|
||||||
pub const RPL_KNOCK: u16 = 710; // channel gets the knock
|
pub const RPL_KNOCK: u16 = 710; // channel gets the knock
|
||||||
pub const RPL_KNOCKDLVR: u16 = 711; // knocker's ack
|
pub const RPL_KNOCKDLVR: u16 = 711; // knocker's ack
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,7 @@ impl Server {
|
||||||
uid,
|
uid,
|
||||||
RPL_MYINFO,
|
RPL_MYINFO,
|
||||||
&format!(
|
&format!(
|
||||||
"{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdK",
|
"{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdKX",
|
||||||
self.name
|
self.name
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -426,7 +426,7 @@ impl Server {
|
||||||
uid,
|
uid,
|
||||||
RPL_ISUPPORT,
|
RPL_ISUPPORT,
|
||||||
&format!(
|
&format!(
|
||||||
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLHBJdK,ACGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||||
self.network
|
self.network
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue