From 50a95c66861dfbc9c8435040a160c0038bded570 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 22:44:58 +0000 Subject: [PATCH] move metadata out of core into its own module file --- src/coremods/core_message.rs | 145 ------------------------- src/modules/metadata.rs | 204 +++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 7 +- src/server.rs | 21 +--- 4 files changed, 213 insertions(+), 164 deletions(-) create mode 100644 src/modules/metadata.rs diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index bfb3a6c..56ed918 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -126,7 +126,6 @@ pub fn commands() -> Vec> { Box::new(ChatHistory), Box::new(Redact), Box::new(MarkRead), - Box::new(Metadata), Box::new(Batch), ] } @@ -161,150 +160,6 @@ impl Command for Batch { } } -/// METADATA — draft/metadata-2. `METADATA [args]`. -/// `` is `*` (self), a nick, or a `#channel`. Anyone may GET/LIST; only the -/// user themselves or a channel op/oper may SET/CLEAR. Values are public (`*` -/// visibility); a change is pushed as `: METADATA * [:val]` -/// to metadata-capable viewers (self for a user, members for a channel). -struct Metadata; -impl Command for Metadata { - fn name(&self) -> &'static str { - "METADATA" - } - fn min_params(&self) -> usize { - 2 - } - fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { - let target = params[0].clone(); - let sub = params[1].to_ascii_uppercase(); - let me_key = format!("u{uid}"); - let key = if target == "*" { - me_key.clone() - } else { - match s.meta_key(&target) { - Some(k) => k, - None => { - s.fail( - uid, - "METADATA", - "INVALID_TARGET", - &format!("{target} invalid target"), - ); - return CmdResult::Fail; - } - } - }; - let reqnick = s - .users - .get(&uid) - .map(|u| u.nick.clone()) - .unwrap_or_else(|| "*".to_string()); - let disp = if target == "*" { - reqnick.clone() - } else { - target.clone() - }; - let can_set = key == me_key - || (key.starts_with('#') && s.rank(uid, &key) >= RANK_HALFOP) - || s.is_oper(uid); - - match sub.as_str() { - "GET" | "LIST" => { - // GET lists the named keys; LIST lists every set key - let keys: Vec = if sub == "LIST" { - s.metadata - .get(&key) - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default() - } else { - params[2..].to_vec() - }; - let bref = s.next_msgid().replace('-', ""); - s.send(uid, format!(":{} BATCH +{bref} metadata", s.name)); - for k in keys { - let line = match s.metadata.get(&key).and_then(|m| m.get(&k)) { - Some(v) => format!( - "@batch={bref} :{} {RPL_KEYVALUE} {reqnick} {disp} {k} * :{v}", - s.name - ), - None => format!( - "@batch={bref} :{} {RPL_KEYNOTSET} {reqnick} {disp} {k} :key not set", - s.name - ), - }; - s.send(uid, line); - } - s.send(uid, format!(":{} BATCH -{bref}", s.name)); - } - "SET" => { - if !can_set { - s.fail( - uid, - "METADATA", - "KEY_NO_PERMISSION", - &format!("{disp} permission denied"), - ); - return CmdResult::Fail; - } - let Some(mkey) = params.get(2).cloned() else { - s.fail(uid, "METADATA", "KEY_INVALID", "missing key"); - return CmdResult::Fail; - }; - let value = params.get(3).cloned(); // no value => delete the key - match &value { - Some(v) => { - s.metadata - .entry(key.clone()) - .or_default() - .insert(mkey.clone(), v.clone()); - } - None => { - if let Some(m) = s.metadata.get_mut(&key) { - m.remove(&mkey); - } - } - } - let setter = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); - let note = match &value { - Some(v) => format!(":{setter} METADATA {disp} {mkey} * :{v}"), - None => format!(":{setter} METADATA {disp} {mkey} *"), - }; - let recips: Vec = if key.starts_with('#') { - s.channels - .get(&key) - .map(|c| c.members.keys().copied().collect()) - .unwrap_or_default() - } else { - vec![uid] - }; - for r in recips { - if s.users.get(&r).map(|u| u.caps.metadata).unwrap_or(false) { - s.send(r, note.clone()); - } - } - } - "CLEAR" => { - if !can_set { - s.fail( - uid, - "METADATA", - "KEY_NO_PERMISSION", - &format!("{disp} permission denied"), - ); - return CmdResult::Fail; - } - s.metadata.remove(&key); - } - "SUB" | "UNSUB" => {} // all metadata is public here; subscriptions are a no-op - _ => { - s.fail(uid, "METADATA", "INVALID_SUBCOMMAND", &sub); - return CmdResult::Fail; - } - } - CmdResult::Ok - } -} - /// MARKREAD — draft/read-marker. `MARKREAD [timestamp=]`. With a /// timestamp it sets the read marker (only ever advancing) and echoes it to every /// connection sharing the user's identity (multi-device); without one it returns diff --git a/src/modules/metadata.rs b/src/modules/metadata.rs new file mode 100644 index 0000000..7fe304a --- /dev/null +++ b/src/modules/metadata.rs @@ -0,0 +1,204 @@ +//! metadata — InspIRCd's `m_ircv3_metadata` (draft/metadata-2). Client METADATA +//! GET/LIST/SET/CLEAR on users and channels, op-gated, with change notices in a +//! `metadata` batch. Self-contained: the store lives in `Server.ext`, cleaned up +//! by the on_user_quit hook; the command and its logic are all here. + +use std::collections::HashMap; + +use crate::channels::RANK_HALFOP; +use crate::command::{CmdResult, Command}; +use crate::module::Module; +use crate::numeric::{RPL_KEYNOTSET, RPL_KEYVALUE}; +use crate::server::Server; +use crate::Uid; + +/// target key (`u` or `#chan`) -> key -> value. Stored in `Server.ext`. +#[derive(Default)] +pub struct MetaStore(pub HashMap>); + +/// Resolve a METADATA target (a nick or `#channel`) to its store key. User keys +/// are `u` (stable across nick changes); channels are the lowercased name. +fn meta_key(s: &Server, target: &str) -> Option { + if let Some(chan) = target.strip_prefix('#') { + let k = format!("#{}", chan.to_ascii_lowercase()); + s.channels.contains_key(&k).then_some(k) + } else { + s.find_nick(target).map(|u| format!("u{u}")) + } +} + +/// Cleanup hook: drop a user's metadata when they disconnect. +pub struct Metadata; +impl Module for Metadata { + fn name(&self) -> &'static str { + "metadata" + } + fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) { + if let Some(st) = s.ext.get_mut::() { + st.0.remove(&format!("u{uid}")); + } + } +} + +pub fn commands() -> Vec> { + vec![Box::new(MetadataCmd)] +} + +/// METADATA — `METADATA [args]`. `` is `*` +/// (self), a nick, or a `#channel`. Anyone may GET/LIST; only the user themselves +/// or a channel op/oper may SET/CLEAR. Values are public (`*` visibility); a change +/// is pushed to metadata-capable viewers (self for a user, members for a channel). +struct MetadataCmd; +impl Command for MetadataCmd { + fn name(&self) -> &'static str { + "METADATA" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = params[0].clone(); + let sub = params[1].to_ascii_uppercase(); + let me_key = format!("u{uid}"); + let key = if target == "*" { + me_key.clone() + } else { + match meta_key(s, &target) { + Some(k) => k, + None => { + s.fail( + uid, + "METADATA", + "INVALID_TARGET", + &format!("{target} invalid target"), + ); + return CmdResult::Fail; + } + } + }; + let reqnick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_else(|| "*".to_string()); + let disp = if target == "*" { + reqnick.clone() + } else { + target.clone() + }; + let can_set = key == me_key + || (key.starts_with('#') && s.rank(uid, &key) >= RANK_HALFOP) + || s.is_oper(uid); + + match sub.as_str() { + "GET" | "LIST" => { + // collect (key, value) pairs, then send — no store borrow held across send + let pairs: Vec<(String, Option)> = { + let store = s.ext.get::(); + let keys: Vec = if sub == "LIST" { + store + .and_then(|st| st.0.get(&key)) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default() + } else { + params[2..].to_vec() + }; + keys.into_iter() + .map(|k| { + let v = store + .and_then(|st| st.0.get(&key)) + .and_then(|m| m.get(&k)) + .cloned(); + (k, v) + }) + .collect() + }; + let bref = s.next_msgid().replace('-', ""); + s.send(uid, format!(":{} BATCH +{bref} metadata", s.name)); + for (k, v) in pairs { + let line = match v { + Some(v) => format!( + "@batch={bref} :{} {RPL_KEYVALUE} {reqnick} {disp} {k} * :{v}", + s.name + ), + None => format!( + "@batch={bref} :{} {RPL_KEYNOTSET} {reqnick} {disp} {k} :key not set", + s.name + ), + }; + s.send(uid, line); + } + s.send(uid, format!(":{} BATCH -{bref}", s.name)); + } + "SET" => { + if !can_set { + s.fail( + uid, + "METADATA", + "KEY_NO_PERMISSION", + &format!("{disp} permission denied"), + ); + return CmdResult::Fail; + } + let Some(mkey) = params.get(2).cloned() else { + s.fail(uid, "METADATA", "KEY_INVALID", "missing key"); + return CmdResult::Fail; + }; + let value = params.get(3).cloned(); // no value => delete the key + { + let st = s.ext.get_or_insert_with::(MetaStore::default); + match &value { + Some(v) => { + st.0.entry(key.clone()) + .or_default() + .insert(mkey.clone(), v.clone()); + } + None => { + if let Some(m) = st.0.get_mut(&key) { + m.remove(&mkey); + } + } + } + } + let setter = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + let note = match &value { + Some(v) => format!(":{setter} METADATA {disp} {mkey} * :{v}"), + None => format!(":{setter} METADATA {disp} {mkey} *"), + }; + let recips: Vec = if key.starts_with('#') { + s.channels + .get(&key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default() + } else { + vec![uid] + }; + for r in recips { + if s.users.get(&r).map(|u| u.caps.metadata).unwrap_or(false) { + s.send(r, note.clone()); + } + } + } + "CLEAR" => { + if !can_set { + s.fail( + uid, + "METADATA", + "KEY_NO_PERMISSION", + &format!("{disp} permission denied"), + ); + return CmdResult::Fail; + } + if let Some(st) = s.ext.get_mut::() { + st.0.remove(&key); + } + } + "SUB" | "UNSUB" => {} // all metadata is public here; subscriptions are a no-op + _ => { + s.fail(uid, "METADATA", "INVALID_SUBCOMMAND", &sub); + return CmdResult::Fail; + } + } + CmdResult::Ok + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 1f87bec..5fbe4b2 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -8,6 +8,7 @@ pub mod cloak; pub mod dnsbl; pub mod filter; pub mod flood; +pub mod metadata; pub mod snoop; use crate::command::Command; @@ -21,11 +22,15 @@ pub fn default_modules() -> Vec> { Box::new(cloak::Cloak), Box::new(antimixedutf8::AntiMixedUtf8), Box::new(filter::Filter), + Box::new(metadata::Metadata), ] } /// Commands contributed by modules (chained into the core command table), so a /// module that adds a command keeps it in its own file, InspIRCd-style. pub fn module_commands() -> Vec> { - filter::commands().into_iter().collect() + filter::commands() + .into_iter() + .chain(metadata::commands()) + .collect() } diff --git a/src/server.rs b/src/server.rs index 19fd635..02e3de3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -165,10 +165,9 @@ pub struct Server { pub label_capture: RefCell)>>, pub history: HashMap>, // channel key -> recent messages (CHATHISTORY) pub read_markers: HashMap>, // identity -> target -> read ts (MARKREAD) - pub metadata: HashMap>, // target key -> key -> value (draft/metadata-2) - pub mline: HashMap, // in-progress inbound multiline batches - pub event_tx: Sender, // self-inject events (DNS results) - pub conn_counter: Arc, // mints connection uids (for CONNECT dials) + pub mline: HashMap, // in-progress inbound multiline batches + pub event_tx: Sender, // self-inject events (DNS results) + pub conn_counter: Arc, // mints connection uids (for CONNECT dials) /// Module-owned server state, keyed by type — the InspIRCd `ExtensionItem` /// equivalent. Each `modules/*.rs` stores its own struct here so features live /// in their own file instead of bloating this one. @@ -216,7 +215,6 @@ impl Server { label_capture: RefCell::new(None), history: HashMap::new(), read_markers: HashMap::new(), - metadata: HashMap::new(), mline: HashMap::new(), event_tx, conn_counter, @@ -331,18 +329,6 @@ impl Server { Some((mb.target, mb.notice, lines)) } - /// Resolve a METADATA target (a nick or `#channel`) to its metadata storage - /// key, or `None` if it doesn't exist. User keys are `u` (stable across - /// nick changes); channel keys are the lowercased name. - pub fn meta_key(&self, target: &str) -> Option { - if let Some(chan) = target.strip_prefix('#') { - let k = format!("#{}", chan.to_ascii_lowercase()); - self.channels.contains_key(&k).then_some(k) - } else { - self.find_nick(target).map(|u| format!("u{u}")) - } - } - /// The read-marker identity for `uid`: their account when logged in (so markers /// are shared across their devices and survive reconnects), else a per-session /// key. `remove_user` prunes the session key on disconnect. @@ -531,7 +517,6 @@ impl Server { }; self.uuid_local.remove(&user.uuid); self.read_markers.remove(&format!("~{uid}")); // session read-markers (kept if account-keyed) - self.metadata.remove(&format!("u{uid}")); // per-user metadata self.mline.remove(&uid); // any half-open multiline batch if user.registered { self.push_whowas(