From f48be3413dd99f4973823ce9b9da28fa158ab836 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 19:42:04 +0000 Subject: [PATCH] modules: port ircv3_FILEHOST (reverse.im/filehost cap + FILEHOST JWT cmd + metadata tag) and ircv3_irccloudtags --- src/modules/filehost.rs | 259 ++++++++++++++++++++++++++++++++++++ src/modules/irccloudtags.rs | 59 ++++++++ src/modules/mod.rs | 5 + src/users.rs | 11 ++ 4 files changed, 334 insertions(+) create mode 100644 src/modules/filehost.rs create mode 100644 src/modules/irccloudtags.rs diff --git a/src/modules/filehost.rs b/src/modules/filehost.rs new file mode 100644 index 0000000..5bb13c0 --- /dev/null +++ b/src/modules/filehost.rs @@ -0,0 +1,259 @@ +//! filehost — the DRAFT `reverse.im/filehost` IRCv3 extension. reverse's own +//! module: it advertises an external file-hosting service to clients and hands a +//! logged-in user a short-lived, server-signed JWT upload link (so the web uploader +//! trusts them without a second login — pairs with reverse's rubot upload service). +//! +//! Surfaces: +//! * ISUPPORT `reverse.im/FILEHOST=` + the `reverse.im/filehost` cap +//! (so a client knows the service exists and can show an upload button). +//! * `FILEHOST [info]` — login-gated; replies with `/upload?token=` +//! and usage info. +//! * a `reverse.im/filehost` message tag carrying JSON metadata (url/filename/ +//! type) attached to any message that contains a `/files/…` link, so +//! clients render the file inline. Scoped to the message's recipients (not the +//! whole network — cleaner than the reference's broadcast). +//! * `filehost_requiressl`: refuse to relay a filehost link from a plaintext user. +//! +//! Config: `filehost_website` (enables it) `filehost_jwt_secret` `filehost_jwt_issuer` +//! (default FILEHOST) `filehost_token_expiry` (secs, default 3600) `filehost_requiressl` +//! (default yes) `filehost_auth_message`. +//! +//! Behaviour reference: reverse's InspIRCd `m_ircv3_FILEHOST`. Original native Rust. + +use crate::command::{CmdResult, Command}; +use crate::module::{ModResult, Module}; +use crate::modules::jwt; +use crate::server::{now, Server}; +use crate::Uid; + +/// The configured website, trailing slash trimmed; `None` when unconfigured. +fn website(s: &Server) -> Option { + s.conf("filehost_website") + .map(|w| w.trim_end_matches('/').to_string()) + .filter(|w| !w.is_empty()) +} + +/// ISUPPORT token advertising the file host (called from the welcome burst). +pub fn isupport(s: &Server) -> Option { + website(s).map(|w| format!("reverse.im/FILEHOST={w}")) +} + +/// File category from a filename extension (mirrors the reference's set). +fn file_type(filename: &str) -> &'static str { + let ext = filename + .rsplit_once('.') + .map(|(_, e)| e) + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "png" | "jpg" | "jpeg" | "gif" | "svg" => "image", + "txt" | "md" | "html" | "htm" | "css" | "js" => "text", + "pdf" | "doc" | "docx" => "document", + "zip" | "tar" | "gz" | "rar" => "archive", + "" => "unknown", + _ => "binary", + } +} + +/// IRCv3 message-tag value escape (JSON is full of spaces, which would split the line). +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 +} + +pub struct FileHost; + +impl Module for FileHost { + fn name(&self) -> &'static str { + "filehost" + } + + fn on_pre_message( + &mut self, + srv: &mut Server, + uid: Uid, + _target: &str, + text: &str, + ) -> ModResult { + let Some(web) = website(srv) else { + return ModResult::Passthru; + }; + let files_prefix = format!("{web}/files/"); + + // require_ssl: don't let a plaintext user spread filehost links + if srv.conf_bool("filehost_requiressl", true) + && text.contains(&web) + && !srv.users.get(&uid).map(|u| u.secure).unwrap_or(false) + { + let nick = srv + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + srv.send( + uid, + format!( + ":{} NOTICE {nick} :You cannot share FILEHOST links over a non-TLS connection.", + srv.name + ), + ); + return ModResult::Deny; + } + + // detect a "/files/" link and attach file metadata as a tag, + // so the recipients' clients can render it inline + if let Some(pos) = text.find(&files_prefix) { + let rest = &text[pos..]; + let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); + let url = rest[..end].trim_end_matches([',', '.', ';', ':', '!', '?', ')', ']', '}']); + let filename = &url[files_prefix.len().min(url.len())..]; + let meta = format!( + "{{\"url\":\"{}\",\"filename\":\"{}\",\"type\":\"{}\"}}", + url, + filename, + file_type(filename) + ); + // fold the metadata onto this message's relayed tag block (message-tags + // recipients get it; clients that don't know the tag ignore it) + let tag = format!("reverse.im/filehost={}", escape_tag(&meta)); + if srv.line_ctags.is_empty() { + srv.line_ctags = tag; + } else { + srv.line_ctags.push(';'); + srv.line_ctags.push_str(&tag); + } + } + ModResult::Passthru + } +} + +pub fn commands() -> Vec> { + vec![Box::new(FileHostCmd)] +} + +/// FILEHOST `[info]` — a logged-in user gets a signed upload link. +struct FileHostCmd; +impl Command for FileHostCmd { + fn name(&self) -> &'static str { + "FILEHOST" + } + fn min_params(&self) -> usize { + 0 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let Some(web) = website(s) else { + note(s, uid, "FILEHOST is not configured on this server."); + return CmdResult::Fail; + }; + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + + if params + .first() + .map(|p| p.eq_ignore_ascii_case("info")) + .unwrap_or(false) + { + note(s, uid, &format!("FILEHOST: service provided by {web}")); + note(s, uid, "FILEHOST: allowed types: txt, md, pdf, png, jpg, jpeg, gif, html, htm, css, js, svg, zip"); + return CmdResult::Ok; + } + + // must be logged into an account + let account = match s.users.get(&uid).and_then(|u| u.account.clone()) { + Some(a) if !a.is_empty() => a, + _ => { + let msg = s + .conf("filehost_auth_message") + .unwrap_or("Log in to your account to use file hosting.") + .to_string(); + note( + s, + uid, + &format!("You must be logged in to use file hosting. {msg}"), + ); + return CmdResult::Fail; + } + }; + + let secret = s + .conf("filehost_jwt_secret") + .unwrap_or("changeme") + .to_string(); + let issuer = s + .conf("filehost_jwt_issuer") + .unwrap_or("FILEHOST") + .to_string(); + let expiry = s + .conf_num("filehost_token_expiry", 3600u64) + .clamp(60, 86400); + let n = now(); + let claims = format!( + r#"{{"iss":"{issuer}","sub":"{nick}","iat":{n},"exp":{}}}"#, + n + expiry + ); + let Some(token) = jwt::sign_hs256(&claims, &secret) else { + note(s, uid, "FILEHOST: could not create an upload token."); + return CmdResult::Fail; + }; + note( + s, + uid, + &format!("FILEHOST: upload files at {web}/upload?token={token}"), + ); + note( + s, + uid, + &format!("FILEHOST: share them via {web}/files/"), + ); + note( + s, + uid, + &format!( + "FILEHOST: authenticated as {account}; link valid {} minutes", + expiry / 60 + ), + ); + CmdResult::Ok + } +} + +fn note(s: &Server, uid: Uid, msg: &str) { + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.send(uid, format!(":{} NOTICE {nick} :*** {msg}", s.name)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_type_by_extension() { + assert_eq!(file_type("cat.png"), "image"); + assert_eq!(file_type("notes.txt"), "text"); + assert_eq!(file_type("paper.pdf"), "document"); + assert_eq!(file_type("blob.bin"), "binary"); + assert_eq!(file_type("noext"), "unknown"); + } + + #[test] + fn tag_escape() { + assert_eq!(escape_tag(r#"{"a":"b c"}"#), "{\"a\":\"b\\sc\"}"); + } +} diff --git a/src/modules/irccloudtags.rs b/src/modules/irccloudtags.rs new file mode 100644 index 0000000..0893b07 --- /dev/null +++ b/src/modules/irccloudtags.rs @@ -0,0 +1,59 @@ +//! irccloudtags — support for IRCCloud's client-only message tags +//! (`+draft/unreact`, `+draft/edit`, `+draft/edit-text`, `+draft/attachments`, +//! `+draft/attachment-fallback`). echoIRCd already relays *all* `+` client tags to +//! `message-tags` clients, so these flow for free; what this module adds is the +//! spec validation InspIRCd's `m_ircv3_irccloudtags` does — each of these tags MUST +//! carry a value, and an empty one is rejected with `FAIL … MESSAGE_TAG_TOO_SHORT`. +//! +//! Behaviour reference: InspIRCd's `m_ircv3_irccloudtags`. Original native Rust. + +use crate::module::{ModResult, Module}; +use crate::server::Server; +use crate::Uid; + +/// The IRCCloud client tags that require a value. +const TAGS: &[&str] = &[ + "+draft/unreact", + "+draft/edit", + "+draft/edit-text", + "+draft/attachments", + "+draft/attachment-fallback", +]; + +pub struct IrcCloudTags; + +impl Module for IrcCloudTags { + fn name(&self) -> &'static str { + "irccloudtags" + } + + fn on_pre_command( + &mut self, + srv: &mut Server, + uid: Uid, + cmd: &str, + _params: &[String], + ) -> ModResult { + // only messages carry client tags + if !(cmd.eq_ignore_ascii_case("PRIVMSG") + || cmd.eq_ignore_ascii_case("NOTICE") + || cmd.eq_ignore_ascii_case("TAGMSG")) + { + return ModResult::Passthru; + } + // the line's client-only tags are stashed on the server before dispatch + for raw in srv.line_ctags.split(';').filter(|t| !t.is_empty()) { + let (name, val) = raw.split_once('=').unwrap_or((raw, "")); + if TAGS.contains(&name) && val.is_empty() { + srv.fail( + uid, + name, + "MESSAGE_TAG_TOO_SHORT", + "That message tag must contain a value.", + ); + return ModResult::Deny; + } + } + ModResult::Passthru + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index b59ce0e..00216c8 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -16,10 +16,12 @@ pub mod connflood; pub mod denychans; pub mod dnsbl; pub mod extjwt; +pub mod filehost; pub mod filter; pub mod flood; pub mod hashident; pub mod hidewhois; +pub mod irccloudtags; pub mod jsonlog; pub mod jwt; pub mod markread; @@ -65,6 +67,8 @@ pub fn default_modules() -> Vec> { Box::new(hashident::HashIdent), Box::new(recaptcha::ReCaptcha), Box::new(cloudflare_challenge::CloudflareChallenge), + Box::new(filehost::FileHost), + Box::new(irccloudtags::IrcCloudTags), ] } @@ -92,5 +96,6 @@ pub fn module_commands() -> Vec> { .chain(recaptcha::commands()) .chain(cloudflare_challenge::commands()) .chain(extjwt::commands()) + .chain(filehost::commands()) .collect() } diff --git a/src/users.rs b/src/users.rs index 5e0294c..b67bb59 100644 --- a/src/users.rs +++ b/src/users.rs @@ -116,6 +116,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "draft/multiline", "draft/account-registration", "draft/json-log", + "reverse.im/filehost", "cap-notify", ]; @@ -148,6 +149,7 @@ pub struct Caps { 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 filehost: bool, // reverse.im/filehost — knows the file-host extension pub cap_notify: bool, } @@ -208,6 +210,7 @@ impl Caps { "draft/multiline" => self.multiline, "draft/account-registration" => self.acct_registration, "draft/json-log" => self.json_log, + "reverse.im/filehost" => self.filehost, "cap-notify" => self.cap_notify, _ => false, } @@ -240,6 +243,7 @@ impl Caps { "draft/multiline" => &mut self.multiline, "draft/account-registration" => &mut self.acct_registration, "draft/json-log" => &mut self.json_log, + "reverse.im/filehost" => &mut self.filehost, "cap-notify" => &mut self.cap_notify, _ => return false, }; @@ -472,6 +476,13 @@ impl Server { &format!("{tok} :are supported by this server"), ); } + if let Some(tok) = crate::modules::filehost::isupport(self) { + self.numeric( + uid, + RPL_ISUPPORT, + &format!("{tok} :are supported by this server"), + ); + } self.numeric( uid, RPL_LUSERCLIENT,