modules: port ircv3_FILEHOST (reverse.im/filehost cap + FILEHOST JWT cmd + metadata tag) and ircv3_irccloudtags

This commit is contained in:
Jean Chevronnet 2026-08-09 19:42:04 +00:00
parent 19157e0722
commit f48be3413d
4 changed files with 334 additions and 0 deletions

259
src/modules/filehost.rs Normal file
View file

@ -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=<website>` + the `reverse.im/filehost` cap
//! (so a client knows the service exists and can show an upload button).
//! * `FILEHOST [info]` — login-gated; replies with `<website>/upload?token=<jwt>`
//! and usage info.
//! * a `reverse.im/filehost` message tag carrying JSON metadata (url/filename/
//! type) attached to any message that contains a `<website>/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<String> {
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<String> {
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 "<website>/files/<name>" 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<Box<dyn Command>> {
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/<filename>"),
);
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\"}");
}
}

View file

@ -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
}
}

View file

@ -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<dyn Module>> {
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<Box<dyn Command>> {
.chain(recaptcha::commands())
.chain(cloudflare_challenge::commands())
.chain(extjwt::commands())
.chain(filehost::commands())
.collect()
}