modules: port ircv3_FILEHOST (reverse.im/filehost cap + FILEHOST JWT cmd + metadata tag) and ircv3_irccloudtags
This commit is contained in:
parent
19157e0722
commit
f48be3413d
4 changed files with 334 additions and 0 deletions
259
src/modules/filehost.rs
Normal file
259
src/modules/filehost.rs
Normal 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\"}");
|
||||
}
|
||||
}
|
||||
59
src/modules/irccloudtags.rs
Normal file
59
src/modules/irccloudtags.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
11
src/users.rs
11
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue