Persist the channel topic setter as a nick!ident@host mask for KEEPTOPIC and INFO
All checks were successful
CI / check (push) Successful in 5m34s
All checks were successful
CI / check (push) Successful in 5m34s
This commit is contained in:
parent
9ccc681a62
commit
948a59707e
17 changed files with 102 additions and 35 deletions
|
|
@ -62,7 +62,9 @@ pub enum NetEvent {
|
||||||
ChannelKey { channel: String, key: Option<String> },
|
ChannelKey { channel: String, key: Option<String> },
|
||||||
// A channel's topic changed (FTOPIC), for KEEPTOPIC / TOPICLOCK. `setter` is
|
// A channel's topic changed (FTOPIC), for KEEPTOPIC / TOPICLOCK. `setter` is
|
||||||
// the source uid; our own changes are filtered out by the protocol layer.
|
// the source uid; our own changes are filtered out by the protocol layer.
|
||||||
TopicChange { channel: String, setter: String, topic: String },
|
// `setter` is the ephemeral source uid (for auth/notify only — never persisted).
|
||||||
|
// `setby` is the ircd's nick!ident@host author mask, the stable value to store.
|
||||||
|
TopicChange { channel: String, setter: String, topic: String, setby: String },
|
||||||
// A user disconnected (QUIT). `reason` is the quit message (may be empty).
|
// A user disconnected (QUIT). `reason` is the quit message (may be empty).
|
||||||
Quit { uid: String, reason: String },
|
Quit { uid: String, reason: String },
|
||||||
// A user was forcibly removed (KILL). Handled like a quit, except a killed
|
// A user was forcibly removed (KILL). Handled like a quit, except a killed
|
||||||
|
|
@ -175,7 +177,10 @@ pub enum NetAction {
|
||||||
// Kick a user from a channel, sourced from pseudoclient `from`.
|
// Kick a user from a channel, sourced from pseudoclient `from`.
|
||||||
Kick { from: String, channel: String, uid: String, reason: String },
|
Kick { from: String, channel: String, uid: String, reason: String },
|
||||||
// Set a channel's topic, sourced from pseudoclient `from`.
|
// Set a channel's topic, sourced from pseudoclient `from`.
|
||||||
Topic { from: String, channel: String, topic: String },
|
// `setter` is the name attributed as having set the topic (the FTOPIC "setby"
|
||||||
|
// field); empty falls back to `from`. Lets a KEEPTOPIC restore credit the
|
||||||
|
// original author rather than the service.
|
||||||
|
Topic { from: String, channel: String, topic: String, setter: String },
|
||||||
// Invite a user to a channel, sourced from pseudoclient `from`.
|
// Invite a user to a channel, sourced from pseudoclient `from`.
|
||||||
Invite { from: String, uid: String, channel: String },
|
Invite { from: String, uid: String, channel: String },
|
||||||
// Add a network ban (X-line — `kind` is the ircd's line type, e.g. "G" for a
|
// Add a network ban (X-line — `kind` is the ircd's line type, e.g. "G" for a
|
||||||
|
|
@ -909,12 +914,14 @@ impl ServiceCtx {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set a channel's topic, sourced from pseudoclient `from`.
|
// Set a channel's topic, sourced from pseudoclient `from`, credited to `setter`
|
||||||
pub fn topic(&mut self, from: &str, channel: &str, topic: &str) {
|
// in the FTOPIC "setby" field (empty = credit `from`).
|
||||||
|
pub fn topic(&mut self, from: &str, channel: &str, topic: &str, setter: &str) {
|
||||||
self.actions.push(NetAction::Topic {
|
self.actions.push(NetAction::Topic {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
channel: channel.to_string(),
|
channel: channel.to_string(),
|
||||||
topic: topic.to_string(),
|
topic: topic.to_string(),
|
||||||
|
setter: setter.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1906,6 +1913,8 @@ pub struct ChannelView {
|
||||||
pub suspended: bool,
|
pub suspended: bool,
|
||||||
// Last known topic (for KEEPTOPIC / TOPICLOCK).
|
// Last known topic (for KEEPTOPIC / TOPICLOCK).
|
||||||
pub topic: String,
|
pub topic: String,
|
||||||
|
// Who last set the topic (a nick), shown in INFO; empty if unknown.
|
||||||
|
pub topic_setter: String,
|
||||||
// BotServ bot assigned to this channel, if any.
|
// BotServ bot assigned to this channel, if any.
|
||||||
pub assigned_bot: Option<String>,
|
pub assigned_bot: Option<String>,
|
||||||
// BotServ: whether members' personal greets are shown on join.
|
// BotServ: whether members' personal greets are shown on join.
|
||||||
|
|
@ -2316,7 +2325,7 @@ pub trait Store {
|
||||||
fn trigger_del(&mut self, channel: &str, index: usize) -> Result<bool, ChanError>;
|
fn trigger_del(&mut self, channel: &str, index: usize) -> Result<bool, ChanError>;
|
||||||
fn trigger_clear(&mut self, channel: &str) -> Result<usize, ChanError>;
|
fn trigger_clear(&mut self, channel: &str) -> Result<usize, ChanError>;
|
||||||
fn triggers(&self, channel: &str) -> Vec<TriggerView>;
|
fn triggers(&self, channel: &str) -> Vec<TriggerView>;
|
||||||
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>;
|
fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError>;
|
||||||
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError>;
|
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError>;
|
||||||
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;
|
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;
|
||||||
fn is_channel_suspended(&self, channel: &str) -> bool;
|
fn is_channel_suspended(&self, channel: &str) -> bool;
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@
|
||||||
" Email : {email} (unconfirmed)": " E-Mail : {email} (unbestätigt)",
|
" Email : {email} (unconfirmed)": " E-Mail : {email} (unbestätigt)",
|
||||||
" Filed : {when}": " Eingereicht: {when}",
|
" Filed : {when}": " Eingereicht: {when}",
|
||||||
" Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002",
|
||||||
|
" Topic by : \u0002{who}\u0002": " Gesetzt von: \u0002{who}\u0002",
|
||||||
" Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002",
|
||||||
" From : \u0002{requester}\u0002": " Von : \u0002{requester}\u0002",
|
" From : \u0002{requester}\u0002": " Von : \u0002{requester}\u0002",
|
||||||
" Greet : {greet}": " Greet : {greet}",
|
" Greet : {greet}": " Greet : {greet}",
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@
|
||||||
" Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)",
|
" Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)",
|
||||||
" Filed : {when}": " Enviado : {when}",
|
" Filed : {when}": " Enviado : {when}",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
||||||
|
" Topic by : \u0002{who}\u0002": " Puesto por : \u0002{who}\u0002",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador: \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador: \u0002{founder}\u0002",
|
||||||
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
||||||
" Greet : {greet}": " Greet : {greet}",
|
" Greet : {greet}": " Greet : {greet}",
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@
|
||||||
" Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)",
|
" Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)",
|
||||||
" Filed : {when}": " Presentado: {when}",
|
" Filed : {when}": " Presentado: {when}",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
||||||
|
" Topic by : \u0002{who}\u0002": " Puesto por : \u0002{who}\u0002",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
||||||
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
||||||
" Greet : {greet}": " Saludo : {greet}",
|
" Greet : {greet}": " Saludo : {greet}",
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@
|
||||||
" Email : {email} (unconfirmed)": " Email : {email} (non confirmé)",
|
" Email : {email} (unconfirmed)": " Email : {email} (non confirmé)",
|
||||||
" Filed : {when}": " Déposé : {when}",
|
" Filed : {when}": " Déposé : {when}",
|
||||||
" Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002",
|
||||||
|
" Topic by : \u0002{who}\u0002": " Défini par : \u0002{who}\u0002",
|
||||||
" Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002",
|
||||||
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
||||||
" Greet : {greet}": " Message d'accueil : {greet}",
|
" Greet : {greet}": " Message d'accueil : {greet}",
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@
|
||||||
" Email : {email} (unconfirmed)": " Email : {email} (não confirmado)",
|
" Email : {email} (unconfirmed)": " Email : {email} (não confirmado)",
|
||||||
" Filed : {when}": " Registrado: {when}",
|
" Filed : {when}": " Registrado: {when}",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
||||||
|
" Topic by : \u0002{who}\u0002": " Definido por: \u0002{who}\u0002",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
||||||
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
||||||
" Greet : {greet}": " Saudação : {greet}",
|
" Greet : {greet}": " Saudação : {greet}",
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@
|
||||||
" Email : {email} (unconfirmed)": " Email : {email} (não confirmado)",
|
" Email : {email} (unconfirmed)": " Email : {email} (não confirmado)",
|
||||||
" Filed : {when}": " Submetido: {when}",
|
" Filed : {when}": " Submetido: {when}",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
||||||
|
" Topic by : \u0002{who}\u0002": " Definido por: \u0002{who}\u0002",
|
||||||
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
|
||||||
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
|
||||||
" Greet : {greet}": " Saudação : {greet}",
|
" Greet : {greet}": " Saudação : {greet}",
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,9 @@ impl Service for ChanServ {
|
||||||
ctx.notice(me, from.uid, t!(ctx, " Email : {email}", email = info.email));
|
ctx.notice(me, from.uid, t!(ctx, " Email : {email}", email = info.email));
|
||||||
}
|
}
|
||||||
ctx.notice(me, from.uid, t!(ctx, " Registered : {when}", when = echo_api::human_time(info.ts)));
|
ctx.notice(me, from.uid, t!(ctx, " Registered : {when}", when = echo_api::human_time(info.ts)));
|
||||||
|
if !info.topic_setter.is_empty() {
|
||||||
|
ctx.notice(me, from.uid, t!(ctx, " Topic by : \x02{who}\x02", who = info.topic_setter));
|
||||||
|
}
|
||||||
if let Some(s) = db.channel_suspension(chan) {
|
if let Some(s) = db.channel_suspension(chan) {
|
||||||
ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason));
|
ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let text = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
|
let text = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
|
||||||
ctx.topic(me, chan, &text);
|
ctx.topic(me, chan, &text, from.nick);
|
||||||
// Persist it too: the ircd filters our own FTOPIC back out, so without this the
|
// Persist it too: the ircd filters our own FTOPIC back out, so without this the
|
||||||
// stored topic stays stale and KEEPTOPIC/TOPICLOCK restore the old one on
|
// stored topic stays stale and KEEPTOPIC/TOPICLOCK restore the old one on
|
||||||
// recreation/restart. Ignore NoChannel (require_op already proved it exists).
|
// recreation/restart. Ignore NoChannel (require_op already proved it exists).
|
||||||
let _ = db.set_channel_topic(chan, &text);
|
let _ = db.set_channel_topic(chan, &text, from.nick);
|
||||||
ctx.notice(me, from.uid, t!(ctx, "Topic for \x02{chan}\x02 updated.", chan = chan));
|
ctx.notice(me, from.uid, t!(ctx, "Topic for \x02{chan}\x02 updated.", chan = chan));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,11 @@ impl Protocol for InspIrcd {
|
||||||
let a: Vec<&str> = tokens.collect();
|
let a: Vec<&str> = tokens.collect();
|
||||||
match (source.as_deref(), a.first()) {
|
match (source.as_deref(), a.first()) {
|
||||||
(Some(src), Some(chan)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => {
|
(Some(src), Some(chan)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => {
|
||||||
vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest) }]
|
// FTOPIC <chan> <chanTS> <topicTS> [setby] :<topic>. The optional
|
||||||
|
// setby (index 3) is the author's nick!ident@host; a ':'-prefixed
|
||||||
|
// token there is the trailing topic, meaning no setby was sent.
|
||||||
|
let setby = a.get(3).filter(|s| !s.starts_with(':')).map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest), setby }]
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
|
|
@ -541,9 +545,16 @@ impl Protocol for InspIrcd {
|
||||||
}
|
}
|
||||||
// FTOPIC <chan> <chanTS> <topicTS> <setter> :<topic>. TS 1 so the ircd
|
// FTOPIC <chan> <chanTS> <topicTS> <setter> :<topic>. TS 1 so the ircd
|
||||||
// always accepts it; sourced from the given pseudoclient.
|
// always accepts it; sourced from the given pseudoclient.
|
||||||
NetAction::Topic { from, channel, topic } => {
|
NetAction::Topic { from, channel, topic, setter } => {
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts);
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts);
|
||||||
vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, from, topic)]
|
// The setby field must be a nick / nick!ident@host — never a uid. When
|
||||||
|
// we don't know the author, omit it so the ircd credits the source's
|
||||||
|
// nick instead of printing our raw source uid.
|
||||||
|
if setter.is_empty() {
|
||||||
|
vec![format!(":{} FTOPIC {} 1 {} :{}", from, channel, now, topic)]
|
||||||
|
} else {
|
||||||
|
vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, setter, topic)]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// INVITE <uid> <chan> <chanTS> <expiry>. Expiry 0 = no expiry.
|
// INVITE <uid> <chan> <chanTS> <expiry>. Expiry 0 = no expiry.
|
||||||
NetAction::Invite { from, uid, channel } => {
|
NetAction::Invite { from, uid, channel } => {
|
||||||
|
|
@ -1076,6 +1087,20 @@ mod tests {
|
||||||
assert!(matches!(ev.as_slice(), [NetEvent::Privmsg { msgid: None, .. }]));
|
assert!(matches!(ev.as_slice(), [NetEvent::Privmsg { msgid: None, .. }]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The FTOPIC setby field is a nick / nick!ident@host when known, and omitted
|
||||||
|
// otherwise — never our raw source uid (which is meaningless after a reconnect).
|
||||||
|
#[test]
|
||||||
|
fn topic_setby_is_a_mask_or_omitted_never_a_uid() {
|
||||||
|
let with = proto().serialize(&NetAction::Topic { from: "42SAAAAAA".into(), channel: "#c".into(), topic: "hi".into(), setter: "bob!u@host".into() });
|
||||||
|
let head = with[0].split(" :").next().unwrap();
|
||||||
|
assert!(head.ends_with("bob!u@host"), "known author -> mask in setby: {with:?}");
|
||||||
|
|
||||||
|
let without = proto().serialize(&NetAction::Topic { from: "42SAAAAAA".into(), channel: "#c".into(), topic: "hi".into(), setter: String::new() });
|
||||||
|
let head = without[0].split(" :").next().unwrap();
|
||||||
|
assert!(head.split(' ').next_back().unwrap().chars().all(|c| c.is_ascii_digit()),
|
||||||
|
"unknown author -> no setby token (ends with topicTS), no uid: {without:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serializes_svspart() {
|
fn serializes_svspart() {
|
||||||
let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: "bye".into() });
|
let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: "bye".into() });
|
||||||
|
|
@ -1197,12 +1222,12 @@ mod tests {
|
||||||
fn parses_ftopic_and_filters_own() {
|
fn parses_ftopic_and_filters_own() {
|
||||||
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all");
|
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all"),
|
matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic, setby }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all" && setby.is_empty()),
|
||||||
"{ev:?}"
|
"{ev:?}"
|
||||||
);
|
);
|
||||||
// The burst/RESYNC form carries a setby field before the topic.
|
// The burst/RESYNC form carries a setby field before the topic.
|
||||||
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 someone!u@h :Hi there");
|
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 someone!u@h :Hi there");
|
||||||
assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, .. }] if topic == "Hi there"), "{ev:?}");
|
assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, setby, .. }] if topic == "Hi there" && setby == "someone!u@h"), "{ev:?}");
|
||||||
// Our own topic changes are filtered so enforcement can't loop.
|
// Our own topic changes are filtered so enforcement can't loop.
|
||||||
let ev = proto().parse(":42S FTOPIC #chan 1 1 :nope");
|
let ev = proto().parse(":42S FTOPIC #chan 1 1 :nope");
|
||||||
assert!(!ev.iter().any(|e| matches!(e, NetEvent::TopicChange { .. })), "own change filtered: {ev:?}");
|
assert!(!ev.iter().any(|e| matches!(e, NetEvent::TopicChange { .. })), "own change filtered: {ev:?}");
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ impl Db {
|
||||||
self.log
|
self.log
|
||||||
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
||||||
.map_err(|_| ChanError::Internal)?;
|
.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(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: 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(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), topic_setter: 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,16 +505,18 @@ impl Db {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remember a channel's topic (KEEPTOPIC / TOPICLOCK).
|
/// Remember a channel's topic and who set it (KEEPTOPIC / TOPICLOCK / INFO).
|
||||||
pub fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
|
pub fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError> {
|
||||||
let k = key(channel);
|
let k = key(channel);
|
||||||
if !self.channels.contains_key(&k) {
|
if !self.channels.contains_key(&k) {
|
||||||
return Err(ChanError::NoChannel);
|
return Err(ChanError::NoChannel);
|
||||||
}
|
}
|
||||||
self.log
|
self.log
|
||||||
.append(Event::ChannelTopicSet { channel: channel.to_string(), topic: topic.to_string() })
|
.append(Event::ChannelTopicSet { channel: channel.to_string(), topic: topic.to_string(), setter: setter.to_string() })
|
||||||
.map_err(|_| ChanError::Internal)?;
|
.map_err(|_| ChanError::Internal)?;
|
||||||
self.channels.get_mut(&k).unwrap().topic = topic.to_string();
|
let c = self.channels.get_mut(&k).unwrap();
|
||||||
|
c.topic = topic.to_string();
|
||||||
|
c.topic_setter = setter.to_string();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ pub enum Event {
|
||||||
ChannelKickerSet { channel: String, kickers: KickerSettings },
|
ChannelKickerSet { channel: String, kickers: KickerSettings },
|
||||||
ChannelBadwordsSet { channel: String, badwords: Vec<String> },
|
ChannelBadwordsSet { channel: String, badwords: Vec<String> },
|
||||||
ChannelTriggersSet { channel: String, triggers: Vec<Trigger> },
|
ChannelTriggersSet { channel: String, triggers: Vec<Trigger> },
|
||||||
ChannelTopicSet { channel: String, topic: String },
|
ChannelTopicSet { channel: String, topic: String, #[serde(default)] setter: String },
|
||||||
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
|
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
|
||||||
ChannelUnsuspended { channel: String },
|
ChannelUnsuspended { channel: String },
|
||||||
ChannelBotAssigned { channel: String, bot: String },
|
ChannelBotAssigned { channel: String, bot: String },
|
||||||
|
|
@ -493,7 +493,7 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
||||||
grouped.remove(&key(&nick));
|
grouped.remove(&key(&nick));
|
||||||
}
|
}
|
||||||
Event::ChannelRegistered { name, founder, ts } => {
|
Event::ChannelRegistered { name, founder, ts } => {
|
||||||
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(), levels: 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(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), topic_setter: 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 } => {
|
Event::ChannelDropped { name } => {
|
||||||
channels.remove(&key(&name));
|
channels.remove(&key(&name));
|
||||||
|
|
@ -585,9 +585,10 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
||||||
c.triggers_rev = c.triggers_rev.wrapping_add(1);
|
c.triggers_rev = c.triggers_rev.wrapping_add(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ChannelTopicSet { channel, topic } => {
|
Event::ChannelTopicSet { channel, topic, setter } => {
|
||||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||||
c.topic = topic;
|
c.topic = topic;
|
||||||
|
c.topic_setter = setter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ChannelSuspended { channel, by, reason, ts, expires } => {
|
Event::ChannelSuspended { channel, by, reason, ts, expires } => {
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,10 @@ pub struct ChannelInfo {
|
||||||
// Last known topic, kept for KEEPTOPIC / TOPICLOCK.
|
// Last known topic, kept for KEEPTOPIC / TOPICLOCK.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub topic: String,
|
pub topic: String,
|
||||||
|
// Nick credited with setting `topic`, restored as the FTOPIC "setby" on
|
||||||
|
// KEEPTOPIC and shown in INFO. Empty if unknown (e.g. pre-upgrade state).
|
||||||
|
#[serde(default)]
|
||||||
|
pub topic_setter: String,
|
||||||
// Services suspension, if any (channel frozen while set and unexpired).
|
// Services suspension, if any (channel frozen while set and unexpired).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub suspension: Option<Suspension>,
|
pub suspension: Option<Suspension>,
|
||||||
|
|
@ -1384,7 +1388,7 @@ impl Db {
|
||||||
snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings });
|
snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings });
|
||||||
}
|
}
|
||||||
if !c.topic.is_empty() {
|
if !c.topic.is_empty() {
|
||||||
snapshot.push(Event::ChannelTopicSet { channel: c.name.clone(), topic: c.topic.clone() });
|
snapshot.push(Event::ChannelTopicSet { channel: c.name.clone(), topic: c.topic.clone(), setter: c.topic_setter.clone() });
|
||||||
}
|
}
|
||||||
if let Some(s) = &c.suspension {
|
if let Some(s) = &c.suspension {
|
||||||
snapshot.push(Event::ChannelSuspended { channel: c.name.clone(), by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires });
|
snapshot.push(Event::ChannelSuspended { channel: c.name.clone(), by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires });
|
||||||
|
|
|
||||||
|
|
@ -515,8 +515,8 @@ impl Store for Db {
|
||||||
fn triggers(&self, channel: &str) -> Vec<TriggerView> {
|
fn triggers(&self, channel: &str) -> Vec<TriggerView> {
|
||||||
Db::triggers(self, channel).iter().map(|t| TriggerView { pattern: t.pattern.clone(), response: t.response.clone(), cooldown: t.cooldown }).collect()
|
Db::triggers(self, channel).iter().map(|t| TriggerView { pattern: t.pattern.clone(), response: t.response.clone(), cooldown: t.cooldown }).collect()
|
||||||
}
|
}
|
||||||
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
|
fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError> {
|
||||||
Db::set_channel_topic(self, channel, topic)
|
Db::set_channel_topic(self, channel, topic, setter)
|
||||||
}
|
}
|
||||||
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError> {
|
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError> {
|
||||||
Db::suspend_channel(self, channel, by, reason, expires)
|
Db::suspend_channel(self, channel, by, reason, expires)
|
||||||
|
|
@ -661,6 +661,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
||||||
keeptopic: c.settings.keeptopic,
|
keeptopic: c.settings.keeptopic,
|
||||||
topiclock: c.settings.topiclock,
|
topiclock: c.settings.topiclock,
|
||||||
topic: c.topic.clone(),
|
topic: c.topic.clone(),
|
||||||
|
topic_setter: c.topic_setter.clone(),
|
||||||
suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())),
|
suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())),
|
||||||
assigned_bot: c.assigned_bot.clone(),
|
assigned_bot: c.assigned_bot.clone(),
|
||||||
bot_greet: c.settings.bot_greet,
|
bot_greet: c.settings.bot_greet,
|
||||||
|
|
|
||||||
|
|
@ -1412,9 +1412,10 @@ impl Engine {
|
||||||
match self.db.channel(&channel) {
|
match self.db.channel(&channel) {
|
||||||
Some(info) => {
|
Some(info) => {
|
||||||
let mut out = vec![NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: info.lock_modes() }];
|
let mut out = vec![NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: info.lock_modes() }];
|
||||||
// KEEPTOPIC: restore the remembered topic on a recreated channel.
|
// KEEPTOPIC: restore the remembered topic on a recreated channel,
|
||||||
|
// credited to whoever originally set it.
|
||||||
if info.settings.keeptopic && !info.topic.is_empty() {
|
if info.settings.keeptopic && !info.topic.is_empty() {
|
||||||
out.push(NetAction::Topic { from, channel, topic: info.topic.clone() });
|
out.push(NetAction::Topic { from, channel, topic: info.topic.clone(), setter: info.topic_setter.clone() });
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
@ -1563,20 +1564,34 @@ impl Engine {
|
||||||
self.network.set_channel_key(&channel, key);
|
self.network.set_channel_key(&channel, key);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
NetEvent::TopicChange { channel, setter, topic } => {
|
NetEvent::TopicChange { channel, setter, topic, setby } => {
|
||||||
let watch = self.notify_line('t', &setter, Some(&channel), &format!("changed the topic of {channel}"));
|
let watch = self.notify_line('t', &setter, Some(&channel), &format!("changed the topic of {channel}"));
|
||||||
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone()));
|
// Who set it, as a stable nick!ident@host — NEVER the source uid, which
|
||||||
|
// is recycled to another user the instant this connection drops. Prefer
|
||||||
|
// the ircd's own setby mask; else snapshot the still-connected source.
|
||||||
|
let author = if !setby.is_empty() {
|
||||||
|
setby
|
||||||
|
} else {
|
||||||
|
match self.network.nick_of(&setter) {
|
||||||
|
Some(nick) => match (self.network.ident_of(&setter), self.network.host_of(&setter)) {
|
||||||
|
(Some(ident), Some(host)) => format!("{nick}!{ident}@{host}"),
|
||||||
|
_ => nick.to_string(),
|
||||||
|
},
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone(), c.topic_setter.clone()));
|
||||||
// The setter may change a locked topic only with op-level access.
|
// The setter may change a locked topic only with op-level access.
|
||||||
let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.is_op(a))).unwrap_or(false);
|
let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.is_op(a))).unwrap_or(false);
|
||||||
let mut out: Vec<NetAction> = match info {
|
let mut out: Vec<NetAction> = match info {
|
||||||
// TOPICLOCK + unauthorised: put the kept topic back.
|
// TOPICLOCK + unauthorised: put the kept topic back, keeping its author.
|
||||||
Some((_, true, stored)) if !authorized => {
|
Some((_, true, stored, stored_by)) if !authorized => {
|
||||||
let from = self.chan_service.clone().unwrap_or_default();
|
let from = self.chan_service.clone().unwrap_or_default();
|
||||||
vec![NetAction::Topic { from, channel, topic: stored }]
|
vec![NetAction::Topic { from, channel, topic: stored, setter: stored_by }]
|
||||||
}
|
}
|
||||||
// Otherwise remember the new topic if the channel keeps it.
|
// Otherwise remember the new topic if the channel keeps it.
|
||||||
Some((keep, lock, _)) if keep || lock => {
|
Some((keep, lock, _, _)) if keep || lock => {
|
||||||
let _ = self.db.set_channel_topic(&channel, &topic);
|
let _ = self.db.set_channel_topic(&channel, &topic, &author);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -3810,11 +3810,11 @@
|
||||||
db.register_channel("#c", "boss").unwrap();
|
db.register_channel("#c", "boss").unwrap();
|
||||||
db.set_channel_setting("#c", db::ChanSetting::KeepTopic, true).unwrap();
|
db.set_channel_setting("#c", db::ChanSetting::KeepTopic, true).unwrap();
|
||||||
db.set_channel_setting("#c", db::ChanSetting::TopicLock, true).unwrap();
|
db.set_channel_setting("#c", db::ChanSetting::TopicLock, true).unwrap();
|
||||||
db.set_channel_topic("#c", "Official topic").unwrap();
|
db.set_channel_topic("#c", "Official topic", "alice").unwrap();
|
||||||
let mut e = Engine::new(vec![Box::new(ChanServ { uid: "42SAAAAAB".into() })], db);
|
let mut e = Engine::new(vec![Box::new(ChanServ { uid: "42SAAAAAB".into() })], db);
|
||||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||||||
// A user without access changes the topic -> reverted to the stored one.
|
// A user without access changes the topic -> reverted to the stored one.
|
||||||
let out = e.handle(NetEvent::TopicChange { channel: "#c".into(), setter: "000AAAAAB".into(), topic: "hacked".into() });
|
let out = e.handle(NetEvent::TopicChange { channel: "#c".into(), setter: "000AAAAAB".into(), topic: "hacked".into(), setby: String::new() });
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::Topic { topic, .. } if topic == "Official topic")), "topiclock reverts: {out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::Topic { topic, .. } if topic == "Official topic")), "topiclock reverts: {out:?}");
|
||||||
// KEEPTOPIC restores the remembered topic on channel (re)creation.
|
// KEEPTOPIC restores the remembered topic on channel (re)creation.
|
||||||
let out = e.handle(NetEvent::ChannelCreate { channel: "#c".into() });
|
let out = e.handle(NetEvent::ChannelCreate { channel: "#c".into() });
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,8 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re
|
||||||
db.migrate_append(Event::ChannelDescSet { channel: name.to_string(), desc: desc.to_string() })?;
|
db.migrate_append(Event::ChannelDescSet { channel: name.to_string(), desc: desc.to_string() })?;
|
||||||
}
|
}
|
||||||
if let Some(topic) = field(ci, "last_topic") {
|
if let Some(topic) = field(ci, "last_topic") {
|
||||||
db.migrate_append(Event::ChannelTopicSet { channel: name.to_string(), topic: topic.to_string() })?;
|
let setter = field(ci, "last_topic_setter").unwrap_or("").to_string();
|
||||||
|
db.migrate_append(Event::ChannelTopicSet { channel: name.to_string(), topic: topic.to_string(), setter })?;
|
||||||
}
|
}
|
||||||
if let Some(bot) = field(ci, "bi") {
|
if let Some(bot) = field(ci, "bi") {
|
||||||
if !SERVICE_NICKS.contains(&bot.to_ascii_lowercase().as_str()) {
|
if !SERVICE_NICKS.contains(&bot.to_ascii_lowercase().as_str()) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue