move metadata out of core into its own module file
This commit is contained in:
parent
ee98fade40
commit
50a95c6686
4 changed files with 213 additions and 164 deletions
|
|
@ -126,7 +126,6 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
|||
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 <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 as `:<setter> METADATA <target> <key> * [: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<String> = 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<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;
|
||||
}
|
||||
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 <target> [timestamp=<iso>]`. 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
|
||||
|
|
|
|||
204
src/modules/metadata.rs
Normal file
204
src/modules/metadata.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,10 +165,9 @@ pub struct Server {
|
|||
pub label_capture: RefCell<Option<(Uid, Vec<String>)>>,
|
||||
pub history: HashMap<String, VecDeque<HistMsg>>, // channel key -> recent messages (CHATHISTORY)
|
||||
pub read_markers: HashMap<String, HashMap<String, u64>>, // identity -> target -> read ts (MARKREAD)
|
||||
pub metadata: HashMap<String, HashMap<String, String>>, // target key -> key -> value (draft/metadata-2)
|
||||
pub mline: HashMap<Uid, MlineBatch>, // in-progress inbound multiline batches
|
||||
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
||||
pub conn_counter: Arc<AtomicU64>, // mints connection uids (for CONNECT dials)
|
||||
pub mline: HashMap<Uid, MlineBatch>, // in-progress inbound multiline batches
|
||||
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
||||
pub conn_counter: Arc<AtomicU64>, // 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<uid>` (stable across
|
||||
/// nick changes); channel keys are the lowercased name.
|
||||
pub fn meta_key(&self, target: &str) -> Option<String> {
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue