From 0c3e09ea00b2cec40a32b477ea2b366510c6f109 Mon Sep 17 00:00:00 2001 From: Jean Date: Sat, 18 Jul 2026 00:04:24 +0000 Subject: [PATCH] =?UTF-8?q?operserv:=20SPAMFILTER=20=E2=80=94=20persisted?= =?UTF-8?q?=20content=20filters=20pushed=20to=20the=20ircd=20m=5Ffilter=20?= =?UTF-8?q?via=20metadata,=20re-asserted=20at=20burst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/lib.rs | 51 ++++++++++++ modules/operserv/src/lib.rs | 3 + modules/operserv/src/spamfilter.rs | 125 +++++++++++++++++++++++++++++ src/engine/db/event.rs | 15 ++++ src/engine/db/mod.rs | 18 +++++ src/engine/db/network.rs | 36 +++++++++ src/engine/db/store.rs | 9 +++ src/engine/mod.rs | 8 ++ src/grpc.rs | 2 + 9 files changed, 267 insertions(+) create mode 100644 modules/operserv/src/spamfilter.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index c952f47..5382685 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -581,6 +581,13 @@ impl ServiceCtx { self.actions.push(NetAction::DelLine { kind: kind.wire().to_string(), mask: mask.to_string() }); } + // Push a spam filter to the ircd's m_filter via a broadcast `METADATA * filter`. + // `value` is the encoded filter (see `encode_filter`). m_filter accepts adds + // this way; there is no matching metadata delete. + pub fn filter_push(&mut self, value: String) { + self.actions.push(NetAction::Metadata { target: "*".to_string(), key: "filter".to_string(), value }); + } + // Disconnect a user, sourced from pseudoclient `from`. pub fn kill(&mut self, from: &str, uid: &str, reason: &str) { self.actions.push(NetAction::KillUser { from: from.to_string(), uid: uid.to_string(), reason: reason.to_string() }); @@ -1208,6 +1215,36 @@ pub struct AkillView { pub expires: Option, } +// A spam filter OperServ manages and pushes to the ircd's m_filter (SPAMFILTER). +#[derive(Debug, Clone)] +pub struct FilterView { + pub pattern: String, + pub action: String, + pub flags: String, + pub reason: String, + pub setter: String, + pub ts: u64, + pub expires: Option, +} + +// The filter actions InspIRCd's m_filter accepts (what happens on a match). +pub const FILTER_ACTIONS: &[&str] = &["gline", "zline", "block", "silent", "kill", "shun", "warn", "none"]; + +// Whether `action` is a filter action m_filter understands (case-insensitive). +pub fn is_filter_action(action: &str) -> bool { + FILTER_ACTIONS.iter().any(|a| a.eq_ignore_ascii_case(action)) +} + +/// Encode a filter for InspIRCd m_filter's `METADATA * filter` value: +/// ` :`, spaces in the pattern +/// escaped to `\x07` exactly as m_filter's own EncodeFilter does. `flags` empty +/// becomes `-`; `duration` is seconds remaining (0 = permanent). +pub fn encode_filter(pattern: &str, action: &str, flags: &str, duration: u64, reason: &str) -> String { + let freeform: String = pattern.chars().map(|c| if c == ' ' { '\x07' } else { c }).collect(); + let flags = if flags.is_empty() { "-" } else { flags }; + format!("{freeform} {} {flags} {duration} :{reason}", action.to_ascii_lowercase()) +} + // A network-ban type OperServ manages, identified by the ircd's X-line token. // The wire (`NetAction::AddLine`), storage, and gossip stay the string token // (a transparent passthrough — the ircd knows more line types than we model); @@ -1669,6 +1706,11 @@ pub trait Store { fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option) -> Result; fn akill_del(&mut self, kind: XlineKind, mask: &str) -> Result; fn akills(&self) -> Vec; + // Spam filters (OperServ SPAMFILTER, oper-only), persisted and re-asserted to + // the ircd at each burst. `filter_add` returns whether it was newly added. + fn filter_add(&mut self, pattern: &str, action: &str, flags: &str, setter: &str, reason: &str, expires: Option) -> Result; + fn filter_del(&mut self, pattern: &str) -> Result; + fn filters(&self) -> Vec; // Registration bans (OperServ FORBID). fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result; fn forbid_del(&mut self, kind: ForbidKind, mask: &str) -> Result; @@ -2066,6 +2108,15 @@ mod tests { assert!(ircd_enforced("mute:*!*@x"), "extban echo's table lacks: ircd enforces"); } + #[test] + fn encode_filter_matches_m_filter_wire_form() { + // " :", spaces in the pattern + // escaped to \x07, action lowercased, empty flags -> "-". + assert_eq!(encode_filter("*viagra*", "GLINE", "*", 0, "spam"), "*viagra* gline * 0 :spam"); + assert_eq!(encode_filter("buy now", "block", "", 3600, "ads"), "buy\x07now block - 3600 :ads"); + assert!(is_filter_action("gline") && is_filter_action("Kill") && !is_filter_action("nonsense")); + } + #[test] fn flags_bitset_parses_applies_and_matches_presets() { // ACCESS_FLAGS is exactly the flag letters in order — one source of truth. diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index 6bf7fcb..a66528f 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -7,6 +7,7 @@ use echo_api::{HelpEntry, NetView, Sender, Service, ServiceCtx, Store}; #[path = "xline.rs"] mod xline; +mod spamfilter; #[path = "forbid.rs"] mod forbid; #[path = "global.rs"] @@ -76,6 +77,7 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("CBAN") => xline::CBAN.handle(me, from, args, ctx, db), + Some(cmd) if cmd.eq_ignore_ascii_case("SPAMFILTER") => spamfilter::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net), @@ -109,6 +111,7 @@ const BLURB: &str = "OperServ holds the network operator tools. Every command is const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "AKILL", summary: "network-ban a user@host", detail: "Syntax: \x02AKILL ADD [+expiry] | AKILL DEL | AKILL LIST [pattern]\x02\nA network-wide user@host ban." }, + HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD [+expiry] | SPAMFILTER DEL | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." }, HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD | FORBID DEL | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." }, HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] | SQLINE DEL | SQLINE LIST [pattern]\x02\nBans matching nicknames." }, HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] | SNLINE DEL | SNLINE LIST [pattern]\x02\nBans matching real names." }, diff --git a/modules/operserv/src/spamfilter.rs b/modules/operserv/src/spamfilter.rs new file mode 100644 index 0000000..64e2996 --- /dev/null +++ b/modules/operserv/src/spamfilter.rs @@ -0,0 +1,125 @@ +use echo_api::{parse_duration, Priv, Sender, ServiceCtx, Store}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// Default filter flags: apply to privmsg/notice/part/quit, exempt opers, match on +// colour-stripped text. (m_filter's `*`.) +const DEFAULT_FLAGS: &str = "*"; + +// SPAMFILTER ADD [+expiry] | DEL | LIST [pattern] +// Content spam filters echo persists and pushes to the ircd's m_filter (re-asserted +// at each burst, so they outlive an ircd restart). The pattern is a regular +// expression (the ircd's filter engine), e.g. `.*free.*bitcoin.*`. `action` is what +// happens on a match: gline / zline / block / silent / kill / shun / warn / none. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — SPAMFILTER needs the \x02admin\x02 privilege."); + return; + } + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => add(me, from, &args[2..], ctx, db), + Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db), + Some("LIST") | Some("VIEW") => list(me, from, args.get(2).copied(), ctx, db), + _ => ctx.notice(me, from.uid, "Syntax: SPAMFILTER ADD [+expiry] | DEL | LIST [pattern]"), + } +} + +fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some((&action, rest)) = rest.split_first() else { + ctx.notice(me, from.uid, "Syntax: SPAMFILTER ADD [+expiry] "); + return; + }; + if !echo_api::is_filter_action(action) { + ctx.notice(me, from.uid, format!("\x02{action}\x02 isn't a filter action. Use one of: {}.", echo_api::FILTER_ACTIONS.join(", "))); + return; + } + // An optional leading +duration, then the pattern (one glob token), then reason. + let mut rest = rest; + let duration = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration); + if duration.is_some() { + rest = &rest[1..]; + } + let Some((&pattern, reason_words)) = rest.split_first() else { + ctx.notice(me, from.uid, "Syntax: SPAMFILTER ADD [+expiry] "); + return; + }; + if reason_words.is_empty() { + ctx.notice(me, from.uid, "Please give a reason."); + return; + } + // A regex of only wildcards/quantifiers (e.g. `.*`) would match everything. + if pattern.chars().all(|c| ".*+?".contains(c)) { + ctx.notice(me, from.uid, "That pattern is too wide — it would match almost everything."); + return; + } + let reason = reason_words.join(" "); + let setter = from.account.unwrap_or(from.nick); + let expires = duration.map(|secs| now() + secs); + match db.filter_add(pattern, action, DEFAULT_FLAGS, setter, &reason, expires) { + Ok(fresh) => { + ctx.filter_push(echo_api::encode_filter(pattern, action, DEFAULT_FLAGS, duration.unwrap_or(0), &reason)); + let word = if fresh { "added" } else { "updated" }; + let expiry = if expires.is_some() { " (temporary)" } else { "" }; + ctx.notice(me, from.uid, format!("Spam filter {word}: \x02{pattern}\x02 ({}){expiry}.", action.to_ascii_lowercase())); + } + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(arg) = arg else { + ctx.notice(me, from.uid, "Syntax: SPAMFILTER DEL "); + return; + }; + // A number targets the nth filter in the list; otherwise it's the pattern. + let pattern = match arg.parse::() { + Ok(n) if n >= 1 => match db.filters().get(n - 1) { + Some(f) => f.pattern.clone(), + None => { + ctx.notice(me, from.uid, format!("There's no spam filter number \x02{n}\x02.")); + return; + } + }, + _ => arg.to_string(), + }; + match db.filter_del(&pattern) { + Ok(true) => ctx.notice(me, from.uid, format!("Spam filter \x02{pattern}\x02 removed. It stops being re-applied, but stays live on the ircd until the next services relink or a manual \x02/FILTER\x02 removal.")), + Ok(false) => ctx.notice(me, from.uid, format!("No spam filter matches \x02{pattern}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn list(me: &str, from: &Sender, pattern: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let pat = pattern.map(|p| p.to_ascii_lowercase()); + let mut shown = 0; + for (i, f) in db.filters().iter().enumerate() { + if let Some(p) = &pat { + if !f.pattern.to_ascii_lowercase().contains(p.as_str()) { + continue; + } + } + let expiry = match f.expires { + Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))), + None => String::new(), + }; + ctx.notice(me, from.uid, format!("{}. \x02{}\x02 [{}] by {} — {}{}", i + 1, f.pattern, f.action, f.setter, f.reason, expiry)); + shown += 1; + } + if shown == 0 { + ctx.notice(me, from.uid, "No matching spam filters."); + } else { + ctx.notice(me, from.uid, format!("End of spam-filter list ({shown} shown).")); + } +} + +fn human_secs(secs: u64) -> String { + match secs { + 0 => "moments".to_string(), + s if s < 3600 => format!("{}m", s / 60), + s if s < 86_400 => format!("{}h", s / 3600), + s => format!("{}d", s / 86_400), + } +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index d742ad1..38a611f 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -128,6 +128,11 @@ pub enum Event { kind: String, mask: String, }, + // Spam filters (OperServ SPAMFILTER) pushed to the ircd's m_filter via + // broadcast METADATA. Global network policy; re-asserted at burst so they + // survive an ircd restart. Keyed by `pattern` (the filter's freeform match). + FilterAdded { pattern: String, action: String, flags: String, reason: String, setter: String, ts: u64, expires: Option }, + FilterRemoved { pattern: String }, // Registration bans (OperServ FORBID). Global network policy. `kind` is // "NICK", "CHAN", or "EMAIL". ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 }, @@ -193,6 +198,8 @@ impl Event { | Event::AccountOperNoteSet { .. } | Event::AkillAdded { .. } | Event::AkillRemoved { .. } + | Event::FilterAdded { .. } + | Event::FilterRemoved { .. } | Event::ForbidAdded { .. } | Event::ForbidRemoved { .. } | Event::NewsAdded { .. } @@ -615,6 +622,14 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash Event::AkillRemoved { kind, mask } => { net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask))); } + Event::FilterAdded { pattern, action, flags, reason, setter, ts, expires } => { + // Keyed by pattern (case-insensitive): a re-add refreshes in place. + net.filters.retain(|f| !f.pattern.eq_ignore_ascii_case(&pattern)); + net.filters.push(Filter { pattern, action, flags, reason, setter, ts, expires }); + } + Event::FilterRemoved { pattern } => { + net.filters.retain(|f| !f.pattern.eq_ignore_ascii_case(&pattern)); + } Event::ForbidAdded { kind, mask, setter, reason, ts } => { net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask))); net.forbids.push(Forbid { kind, mask, setter, reason, ts }); diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 3cf865d..3a17140 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -190,6 +190,20 @@ pub struct Akill { pub expires: Option, } +// A spam filter (OperServ SPAMFILTER), pushed to the ircd's m_filter. `expires` +// is absolute unix seconds (None = permanent), like an Akill. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Filter { + pub pattern: String, + pub action: String, + pub flags: String, + pub reason: String, + pub setter: String, + pub ts: u64, + #[serde(default)] + pub expires: Option, +} + // A registration ban: an account name, channel, or email pattern that may not be // registered. `kind` is "NICK", "CHAN", or "EMAIL". Network-wide policy like an // AKILL, so it gossips and every node enforces it. @@ -288,6 +302,7 @@ pub type ChanStats = Vec<(String, u64, Vec<(String, u64)>)>; #[derive(Debug, Clone, Default)] pub struct NetData { pub akills: Vec, + pub filters: Vec, pub forbids: Vec, pub news: Vec, pub news_seq: u64, @@ -1213,6 +1228,9 @@ impl Db { for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) { snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }); } + for f in self.net.filters.iter().filter(|f| f.expires.is_none_or(|e| e > now)) { + snapshot.push(Event::FilterAdded { pattern: f.pattern.clone(), action: f.action.clone(), flags: f.flags.clone(), reason: f.reason.clone(), setter: f.setter.clone(), ts: f.ts, expires: f.expires }); + } for n in &self.net.news { snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts }); } diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index fca90ad..7188a79 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -45,6 +45,42 @@ impl Db { .collect() } + /// Add (or refresh) a spam filter. Returns whether it was newly added. + pub fn filter_add(&mut self, pattern: &str, action: &str, flags: &str, setter: &str, reason: &str, expires: Option) -> Result { + // One timestamp for the event and the in-memory record (see akill_add). + let ts = now(); + let same = |f: &Filter| f.pattern.eq_ignore_ascii_case(pattern); + let fresh = !self.net.filters.iter().any(|f| same(f) && f.expires.is_none_or(|e| e > ts)); + self.log + .append(Event::FilterAdded { pattern: pattern.to_string(), action: action.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires }) + .map_err(|_| RegError::Internal)?; + self.net.filters.retain(|f| !same(f)); + self.net.filters.push(Filter { pattern: pattern.to_string(), action: action.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires }); + Ok(fresh) + } + + /// Remove a spam filter by pattern. Returns whether a live one existed. + pub fn filter_del(&mut self, pattern: &str) -> Result { + let same = |f: &Filter| f.pattern.eq_ignore_ascii_case(pattern); + let existed = self.net.filters.iter().any(|f| same(f) && f.expires.is_none_or(|e| e > now())); + if !existed { + return Ok(false); + } + self.log.append(Event::FilterRemoved { pattern: pattern.to_string() }).map_err(|_| RegError::Internal)?; + self.net.filters.retain(|f| !same(f)); + Ok(true) + } + + /// The live spam filters (expired ones hidden lazily), oldest first. + pub fn filters(&self) -> Vec { + let now = now(); + self.net.filters + .iter() + .filter(|f| f.expires.is_none_or(|e| e > now)) + .map(|f| echo_api::FilterView { pattern: f.pattern.clone(), action: f.action.clone(), flags: f.flags.clone(), reason: f.reason.clone(), setter: f.setter.clone(), ts: f.ts, expires: f.expires }) + .collect() + } + /// Add a registration ban of `kind` ("NICK"/"CHAN"/"EMAIL") for `mask`. /// Returns whether it was new. pub fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result { diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index c848976..37b1f77 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -230,6 +230,15 @@ impl Store for Db { fn akills(&self) -> Vec { Db::akills(self) } + fn filter_add(&mut self, pattern: &str, action: &str, flags: &str, setter: &str, reason: &str, expires: Option) -> Result { + Db::filter_add(self, pattern, action, flags, setter, reason, expires) + } + fn filter_del(&mut self, pattern: &str) -> Result { + Db::filter_del(self, pattern) + } + fn filters(&self) -> Vec { + Db::filters(self) + } fn forbid_add(&mut self, kind: ForbidKind, mask: &str, setter: &str, reason: &str) -> Result { Db::forbid_add(self, kind, mask, setter, reason) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b7e2d6c..402f7ef 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1057,6 +1057,12 @@ impl Engine { let duration = a.expires.map(|e| e.saturating_sub(now)).unwrap_or(0); out.push(NetAction::AddLine { kind: a.kind.wire().to_string(), mask: a.mask, setter: a.setter, duration, reason: a.reason }); } + // Re-assert the spam filters so they survive an ircd restart (m_filter has + // no s2s delete, so echo is the durable source of truth for them). + for f in self.db.filters() { + let duration = f.expires.map(|e| e.saturating_sub(now)).unwrap_or(0); + out.push(NetAction::Metadata { target: "*".to_string(), key: "filter".to_string(), value: echo_api::encode_filter(&f.pattern, &f.action, &f.flags, duration, &f.reason) }); + } // Re-introduce our juped servers over the fresh link. for (name, sid, reason) in self.db.jupes() { out.push(NetAction::JupeServer { name, sid, reason }); @@ -1657,6 +1663,8 @@ fn audit_summary(event: &db::Event) -> Option { format!("set a {} on \x02{mask}\x02{temp} ({reason})", ban_kind_label(kind)) } AkillRemoved { kind, mask } => format!("lifted the {} on \x02{mask}\x02", ban_kind_label(kind)), + FilterAdded { pattern, action, reason, .. } => format!("added a \x02{action}\x02 spam filter on \x02{pattern}\x02 ({reason})"), + FilterRemoved { pattern } => format!("removed the spam filter on \x02{pattern}\x02"), ForbidAdded { kind, mask, reason, .. } => format!("forbade {} \x02{mask}\x02 ({reason})", kind.to_ascii_lowercase()), ForbidRemoved { kind, mask } => format!("un-forbade {} \x02{mask}\x02", kind.to_ascii_lowercase()), JupeAdded { name, reason, .. } => format!("juped server \x02{name}\x02 ({reason})"), diff --git a/src/grpc.rs b/src/grpc.rs index 084c2ba..fa776ef 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -177,6 +177,8 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelNoExpire { .. } | Event::AkillAdded { .. } | Event::AkillRemoved { .. } + | Event::FilterAdded { .. } + | Event::FilterRemoved { .. } | Event::ForbidAdded { .. } | Event::ForbidRemoved { .. } | Event::JupeAdded { .. }