move metadata out of core into its own module file

This commit is contained in:
Jean Chevronnet 2026-08-08 22:44:58 +00:00
parent ee98fade40
commit 50a95c6686
4 changed files with 213 additions and 164 deletions

204
src/modules/metadata.rs Normal file
View file

@ -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<uid>` or `#chan`) -> key -> value. Stored in `Server.ext`.
#[derive(Default)]
pub struct MetaStore(pub HashMap<String, HashMap<String, String>>);
/// Resolve a METADATA target (a nick or `#channel`) to its store key. User keys
/// are `u<uid>` (stable across nick changes); channels are the lowercased name.
fn meta_key(s: &Server, target: &str) -> Option<String> {
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::<MetaStore>() {
st.0.remove(&format!("u{uid}"));
}
}
}
pub fn commands() -> Vec<Box<dyn Command>> {
vec![Box::new(MetadataCmd)]
}
/// METADATA — `METADATA <target> <GET|LIST|SET|CLEAR> [args]`. `<target>` 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<String>)> = {
let store = s.ext.get::<MetaStore>();
let keys: Vec<String> = 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>(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<Uid> = 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::<MetaStore>() {
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
}
}

View file

@ -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<dyn Module>> {
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<Box<dyn Command>> {
filter::commands().into_iter().collect()
filter::commands()
.into_iter()
.chain(metadata::commands())
.collect()
}