diff --git a/src/modules/jsonlog.rs b/src/modules/jsonlog.rs new file mode 100644 index 0000000..d32b011 --- /dev/null +++ b/src/modules/jsonlog.rs @@ -0,0 +1,107 @@ +//! jsonlog — the `draft/json-log` capability. reverse's own module. When an oper +//! negotiates `CAP REQ draft/json-log`, every server notice they receive carries a +//! structured JSON object (timestamp, level, subsystem, msg, …) as an IRCv3 message +//! **tag** — the human-readable text stays in the NOTICE, the machine-readable copy +//! rides alongside. Companion to the RPC `log.*` methods, which expose the same data +//! over HTTP. Dispatched straight from `Server::snotice`; the tag build lives here. +//! +//! Behaviour reference: reverse's InspIRCd `m_jsonrpclog`. Original native Rust. + +use crate::modules::rpc::json::{obj, qstr}; +use crate::server::{iso_time, now, Server}; + +/// Strip mIRC/IRC formatting control codes so the `msg` field is clean plaintext: +/// bold/reset/mono/reverse/italic/strike/underline, colour (`\x03 fg[,bg]`) and hex +/// colour (`\x04 rrggbb[,rrggbb]`). The raw NOTICE keeps its formatting. +fn strip_formatting(input: &str) -> String { + let b: Vec = input.chars().collect(); + let mut out = String::with_capacity(input.len()); + let mut i = 0; + while i < b.len() { + let c = b[i]; + match c { + '\u{02}' | '\u{0F}' | '\u{11}' | '\u{16}' | '\u{1D}' | '\u{1E}' | '\u{1F}' => {} + '\u{03}' => i = skip_run(&b, i, 2, |c| c.is_ascii_digit()), + '\u{04}' => i = skip_run(&b, i, 6, |c| c.is_ascii_hexdigit()), + _ => out.push(c), + } + i += 1; + } + out +} + +/// From a colour/hex-colour introducer at `i`, skip up to `max` matching digits and +/// an optional `,` + up to `max` more. Returns the index of the last consumed char. +fn skip_run(b: &[char], mut i: usize, max: usize, ok: impl Fn(char) -> bool) -> usize { + let mut n = 0; + while i + 1 < b.len() && ok(b[i + 1]) && n < max { + i += 1; + n += 1; + } + if n > 0 && i + 2 < b.len() && b[i + 1] == ',' && ok(b[i + 2]) { + i += 1; // the comma + n = 0; + while i + 1 < b.len() && ok(b[i + 1]) && n < max { + i += 1; + n += 1; + } + } + i +} + +/// IRCv3 message-tag value escape (space→`\s`, `;`→`\:`, `\`→`\\`, CR/LF). Required +/// because the JSON is full of spaces and would otherwise split the wire line apart. +fn escape_tag(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + for c in v.chars() { + match c { + ';' => out.push_str("\\:"), + ' ' => out.push_str("\\s"), + '\\' => out.push_str("\\\\"), + '\r' => out.push_str("\\r"), + '\n' => out.push_str("\\n"), + c => out.push(c), + } + } + out +} + +/// The escaped `draft/json-log` tag *value* for a server notice `msg`. Place after +/// `draft/json-log=` in the tag block. echoIRCd snotices are untyped, so `subsystem` +/// / `event_id` are derived from the leading word and `snomask` is the generic `s`. +pub fn tag_value(s: &Server, msg: &str) -> String { + let head = msg + .split_whitespace() + .next() + .unwrap_or("general") + .trim_end_matches(':'); + let subsystem = head.to_ascii_lowercase(); + let event_id = head.to_ascii_uppercase(); + let json = obj(&[ + ("timestamp", qstr(&iso_time(now()))), + ("level", qstr("info")), + ("subsystem", qstr(&subsystem)), + ("event_id", qstr(&event_id)), + ("log_source", qstr(&s.name)), + ("msg", qstr(&strip_formatting(msg))), + ("snomask", qstr("s")), + ]); + escape_tag(&json) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_control_codes() { + assert_eq!(strip_formatting("\u{02}bold\u{0F} x"), "bold x"); + assert_eq!(strip_formatting("\u{03}04red\u{03} y"), "red y"); + assert_eq!(strip_formatting("\u{03}04,08two z"), "two z"); + } + + #[test] + fn escapes_tag_value() { + assert_eq!(escape_tag("a b;c\\d"), "a\\sb\\:c\\\\d"); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index db06f1f..b59ce0e 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -20,6 +20,7 @@ pub mod filter; pub mod flood; pub mod hashident; pub mod hidewhois; +pub mod jsonlog; pub mod jwt; pub mod markread; pub mod metadata; diff --git a/src/server.rs b/src/server.rs index 08bb2f3..a61150c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -619,13 +619,27 @@ impl Server { .filter(|(_, u)| u.flags.oper && u.flags.snomask) .map(|(&u, _)| u) .collect(); + // draft/json-log: the structured tag value is the same for every recipient + let jval = crate::modules::jsonlog::tag_value(self, msg); for o in opers { - let nick = self + let (nick, json_cap, time_cap) = self .users .get(&o) - .map(|u| u.nick.clone()) + .map(|u| (u.nick.clone(), u.caps.json_log, u.caps.server_time)) .unwrap_or_default(); - self.send(o, format!(":{} NOTICE {nick} :*** {msg}", self.name)); + let base = format!(":{} NOTICE {nick} :*** {msg}", self.name); + if json_cap { + // build one tag block (server-time too, if negotiated) and emit raw, + // so we don't collide with the auto server-time tagging in `send` + let mut tags = String::new(); + if time_cap { + tags.push_str(&format!("time={};", iso_time(now()))); + } + tags.push_str(&format!("draft/json-log={jval}")); + self.emit_to(o, format!("@{tags} {base}")); + } else { + self.send(o, base); + } } } diff --git a/src/users.rs b/src/users.rs index 00e27b9..80ef088 100644 --- a/src/users.rs +++ b/src/users.rs @@ -114,6 +114,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "draft/metadata-2", "draft/multiline", "draft/account-registration", + "draft/json-log", "cap-notify", ]; @@ -145,6 +146,7 @@ pub struct Caps { pub metadata: bool, // draft/metadata-2 — wants metadata + change notices pub multiline: bool, // draft/multiline — may send multiline message batches pub acct_registration: bool, // draft/account-registration — REGISTER/VERIFY understood + pub json_log: bool, // draft/json-log — structured JSON tag on server notices pub cap_notify: bool, } @@ -204,6 +206,7 @@ impl Caps { "draft/metadata-2" => self.metadata, "draft/multiline" => self.multiline, "draft/account-registration" => self.acct_registration, + "draft/json-log" => self.json_log, "cap-notify" => self.cap_notify, _ => false, } @@ -235,6 +238,7 @@ impl Caps { "draft/metadata-2" => &mut self.metadata, "draft/multiline" => &mut self.multiline, "draft/account-registration" => &mut self.acct_registration, + "draft/json-log" => &mut self.json_log, "cap-notify" => &mut self.cap_notify, _ => return false, };