From 5b023c22a42c1a8f707a431745f7fa708d4b5516 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 04:02:51 +0000 Subject: [PATCH] Add ReportServ, wired into the audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any user can REPORT ; filing is rate-limited per reporter. Reports are an event-sourced, federated queue operators review with LIST / VIEW / CLOSE / DEL. The interconnection: because a filed report is an event, the engine's audit feed already announces it to the staff channel and records it in the searchable action log — so OperServ LOGSEARCH finds a report by any word in it, tying reports into the same trail as bans, kicks, and everything else. Opt-in (add "reportserv"). --- Cargo.lock | 8 +++ Cargo.toml | 3 +- api/src/lib.rs | 17 ++++++ reportserv/Cargo.toml | 8 +++ reportserv/src/lib.rs | 130 ++++++++++++++++++++++++++++++++++++++++++ src/engine/db.rs | 126 ++++++++++++++++++++++++++++++++++++++-- src/engine/mod.rs | 54 +++++++++++++++++- src/grpc.rs | 3 + src/main.rs | 6 ++ 9 files changed, 346 insertions(+), 9 deletions(-) create mode 100644 reportserv/Cargo.toml create mode 100644 reportserv/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2eeeb39..5ed3c83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,6 +336,7 @@ dependencies = [ "fedserv-memoserv", "fedserv-nickserv", "fedserv-operserv", + "fedserv-reportserv", "fedserv-statserv", "hmac", "pbkdf2", @@ -431,6 +432,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-reportserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-statserv" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 527655f..08594a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv"] [package] name = "fedserv" @@ -20,6 +20,7 @@ fedserv-hostserv = { path = "hostserv" } fedserv-operserv = { path = "operserv" } fedserv-diceserv = { path = "diceserv" } fedserv-infoserv = { path = "infoserv" } +fedserv-reportserv = { path = "reportserv" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/api/src/lib.rs b/api/src/lib.rs index 60700c8..22d5ce8 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -510,6 +510,17 @@ pub struct NewsView { pub ts: u64, } +// An abuse report held by ReportServ. +#[derive(Debug, Clone)] +pub struct ReportView { + pub id: u64, + pub reporter: String, + pub target: String, + pub reason: String, + pub ts: u64, + pub open: bool, +} + #[derive(Debug, Clone)] pub struct BotView { pub nick: String, @@ -788,6 +799,12 @@ pub trait Store { fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64; fn news_del(&mut self, id: u64) -> bool; fn news(&self, kind: &str) -> Vec; + // Abuse reports (ReportServ). `report_file` is rate-limited (None = too soon). + fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option; + fn report_close(&mut self, id: u64) -> bool; + fn report_del(&mut self, id: u64) -> bool; + fn reports(&self, open_only: bool) -> Vec; + fn report(&self, id: u64) -> Option; // Runtime operator grants (OperServ OPER), merged with config opers. `expires` // is an absolute unix time (None = permanent); opers_list hides expired ones. fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option); diff --git a/reportserv/Cargo.toml b/reportserv/Cargo.toml new file mode 100644 index 0000000..f14e46c --- /dev/null +++ b/reportserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-reportserv" +version = "0.0.1" +edition = "2021" +description = "ReportServ: users file abuse reports; operators review them." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/reportserv/src/lib.rs b/reportserv/src/lib.rs new file mode 100644 index 0000000..14bb8ed --- /dev/null +++ b/reportserv/src/lib.rs @@ -0,0 +1,130 @@ +//! ReportServ lets any user file an abuse report against a nick or channel. +//! Filing is rate-limited; the report is stored and — because it's an event — +//! the engine's audit feed automatically announces it to the staff channel and +//! records it in the searchable action log (OperServ LOGSEARCH), so a report is +//! tied into the same trail as everything else. Operators review the queue with +//! LIST / VIEW / CLOSE / DEL. + +use fedserv_api::{human_time, NetView, Sender, Service, ServiceCtx, Store}; + +pub struct ReportServ { + pub uid: String, +} + +impl Service for ReportServ { + fn nick(&self) -> &str { + "ReportServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Report Service" + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { + let me = self.uid.as_str(); + match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("REPORT") => report(me, from, &args[1..], ctx, db), + Some("LIST") => list(me, from, args.get(1).copied(), ctx, db), + Some("VIEW") | Some("READ") => view(me, from, args.get(1).copied(), ctx, db), + Some("CLOSE") | Some("RESOLVE") => close(me, from, args.get(1).copied(), ctx, db), + Some("DEL") | Some("REMOVE") => del(me, from, args.get(1).copied(), ctx, db), + Some("HELP") | None => help(me, from, ctx), + Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02REPORT\x02 or \x02HELP\x02.")), + } + } +} + +fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, "ReportServ takes abuse reports. \x02REPORT\x02 tells the staff about a problem. Operators review with \x02LIST\x02 [ALL], \x02VIEW\x02 , \x02CLOSE\x02 , \x02DEL\x02 ."); +} + +fn report(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some((&target, reason_words)) = rest.split_first() else { + ctx.notice(me, from.uid, "Syntax: REPORT "); + return; + }; + if reason_words.is_empty() { + ctx.notice(me, from.uid, "Please say what the problem is: REPORT "); + return; + } + let reason = reason_words.join(" "); + let reporter = from.account.unwrap_or(from.nick); + match db.report_file(reporter, target, &reason) { + Some(id) => ctx.notice(me, from.uid, format!("Thanks — your report (\x02#{id}\x02) about \x02{target}\x02 has been sent to the staff.")), + None => ctx.notice(me, from.uid, "You just filed a report — please wait a moment before filing another."), + } +} + +// Reviewing the queue is for operators only. +fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool { + if from.privs.any() { + return true; + } + ctx.notice(me, from.uid, "Access denied — reviewing reports is for services operators."); + false +} + +fn list(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !require_oper(me, from, ctx) { + return; + } + let open_only = !arg.is_some_and(|a| a.eq_ignore_ascii_case("ALL")); + let reports = db.reports(open_only); + if reports.is_empty() { + ctx.notice(me, from.uid, if open_only { "No open reports." } else { "No reports." }); + return; + } + for r in &reports { + let flag = if r.open { "" } else { " (closed)" }; + let short: String = r.reason.chars().take(60).collect(); + ctx.notice(me, from.uid, format!("\x02#{}\x02 {} → \x02{}\x02: {}{}", r.id, r.reporter, r.target, short, flag)); + } + ctx.notice(me, from.uid, format!("{} report(s). \x02VIEW\x02 for detail.", reports.len())); +} + +fn view(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !require_oper(me, from, ctx) { + return; + } + let Some(r) = id.and_then(|n| n.parse::().ok()).and_then(|n| db.report(n)) else { + ctx.notice(me, from.uid, "No such report. Syntax: VIEW "); + return; + }; + ctx.notice(me, from.uid, format!("Report \x02#{}\x02 ({}):", r.id, if r.open { "open" } else { "closed" })); + ctx.notice(me, from.uid, format!(" By : \x02{}\x02", r.reporter)); + ctx.notice(me, from.uid, format!(" About : \x02{}\x02", r.target)); + ctx.notice(me, from.uid, format!(" Filed : {}", human_time(r.ts))); + ctx.notice(me, from.uid, format!(" Reason : {}", r.reason)); +} + +fn close(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !require_oper(me, from, ctx) { + return; + } + let Some(n) = id.and_then(|n| n.parse::().ok()) else { + ctx.notice(me, from.uid, "Syntax: CLOSE "); + return; + }; + if db.report_close(n) { + ctx.notice(me, from.uid, format!("Report \x02#{n}\x02 closed.")); + } else { + ctx.notice(me, from.uid, format!("Report \x02#{n}\x02 isn't open (or doesn't exist).")); + } +} + +fn del(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !require_oper(me, from, ctx) { + return; + } + let Some(n) = id.and_then(|n| n.parse::().ok()) else { + ctx.notice(me, from.uid, "Syntax: DEL "); + return; + }; + if db.report_del(n) { + ctx.notice(me, from.uid, format!("Report \x02#{n}\x02 deleted.")); + } else { + ctx.notice(me, from.uid, format!("There's no report \x02#{n}\x02.")); + } +} diff --git a/src/engine/db.rs b/src/engine/db.rs index 8855816..06d5114 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -30,7 +30,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, Privs, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, + AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -188,6 +188,10 @@ pub enum Event { // News items (OperServ NEWS). The stable `id` makes deletion order-independent. NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 }, NewsDeleted { id: u64 }, + // Abuse reports (ReportServ). Global so any node's operators see the queue. + ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 }, + ReportClosed { id: u64 }, + ReportDeleted { id: u64 }, // Runtime operator grants (OperServ OPER). OperGranted { account: String, @@ -260,6 +264,9 @@ impl Event { | Event::AkillRemoved { .. } | Event::NewsAdded { .. } | Event::NewsDeleted { .. } + | Event::ReportFiled { .. } + | Event::ReportClosed { .. } + | Event::ReportDeleted { .. } | Event::OperGranted { .. } | Event::OperRevoked { .. } | Event::SessionExceptionAdded { .. } @@ -362,10 +369,10 @@ pub struct Ignore { pub expires: Option, } -// A news item (OperServ NEWS): a `kind` ("logon" shown to everyone on connect, -// "oper" shown to operators on login), the text, who set it, and when. Carries a -// stable id (assigned at add time, embedded in the log) so deletion is order- -// independent under replay. +// A news item (InfoServ bulletin): a `kind` ("logon" shown to everyone on +// connect, "oper" shown to operators on login), the text, who set it, and when. +// Carries a stable id (assigned at add time, embedded in the log) so deletion is +// order-independent under replay. #[derive(Debug, Clone)] pub struct News { pub id: u64, @@ -375,6 +382,18 @@ pub struct News { pub ts: u64, } +// An abuse report (ReportServ): who filed it, the nick/channel it's about, why, +// when, and whether it's still open. Stable id like News. +#[derive(Debug, Clone)] +pub struct Report { + pub id: u64, + pub reporter: String, + pub target: String, + pub reason: String, + pub ts: u64, + pub open: bool, +} + // The network-wide lists that aren't accounts/channels/grouped/bots: the network // bans and the news items. Bundled so `apply` threads one parameter for them // (and stays within its argument budget as more are added). @@ -383,6 +402,9 @@ pub struct NetData { pub akills: Vec, pub news: Vec, pub news_seq: u64, + // Abuse reports (ReportServ), oldest first. + pub reports: Vec, + pub report_seq: u64, // Runtime services operators (OperServ OPER), casefolded account -> grant. // Merged with the declarative [[oper]] config at the engine. pub opers: HashMap, @@ -969,6 +991,8 @@ pub struct Db { auth_fails: HashMap, // Last vhost REQUEST time per account (in-memory, anti-spam), unix secs. vhost_req_times: HashMap, + // Last ReportServ REPORT time per reporter (in-memory, anti-spam), unix secs. + report_times: HashMap, // Registered service bots (BotServ), keyed by casefolded nick. bots: HashMap, // HostServ node config: the self-serve offer menu, the forbidden-pattern @@ -1052,7 +1076,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5, external_accounts: false } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5, external_accounts: false } } /// Fold an entry authored by another node into the store — the services-side @@ -1149,6 +1173,12 @@ impl Db { for n in &self.net.news { snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts }); } + for r in &self.net.reports { + snapshot.push(Event::ReportFiled { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts }); + if !r.open { + snapshot.push(Event::ReportClosed { id: r.id }); + } + } for (account, grant) in &self.net.opers { snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires }); } @@ -2151,6 +2181,61 @@ impl Db { self.jupes.iter().map(|j| (j.name.clone(), j.sid.clone(), j.reason.clone())).collect() } + /// File an abuse report, rate-limited per reporter. Returns the new report's + /// id, or None if the reporter filed one too recently. + pub fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option { + const COOLDOWN: u64 = 30; + let now = now(); + let key = reporter.to_ascii_lowercase(); + if self.report_times.get(&key).is_some_and(|&t| now.saturating_sub(t) < COOLDOWN) { + return None; + } + self.report_times.insert(key, now); + let id = self.net.report_seq; + let _ = self.log.append(Event::ReportFiled { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now }); + self.net.report_seq = id + 1; + self.net.reports.push(Report { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now, open: true }); + Some(id) + } + + /// Close (resolve) a report. Returns whether an open one was closed. + pub fn report_close(&mut self, id: u64) -> bool { + let closed = self.net.reports.iter().any(|r| r.id == id && r.open); + if closed { + let _ = self.log.append(Event::ReportClosed { id }); + if let Some(r) = self.net.reports.iter_mut().find(|r| r.id == id) { + r.open = false; + } + } + closed + } + + /// Delete a report entirely. Returns whether one existed. + pub fn report_del(&mut self, id: u64) -> bool { + let existed = self.net.reports.iter().any(|r| r.id == id); + if existed { + let _ = self.log.append(Event::ReportDeleted { id }); + self.net.reports.retain(|r| r.id != id); + } + existed + } + + /// The reports, newest first. `open_only` hides closed ones. + pub fn reports(&self, open_only: bool) -> Vec { + self.net + .reports + .iter() + .rev() + .filter(|r| !open_only || r.open) + .map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open }) + .collect() + } + + /// A single report by id, if present. + pub fn report(&self, id: u64) -> Option { + self.net.reports.iter().find(|r| r.id == id).map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open }) + } + /// Add a services ignore, replacing any existing entry for the same mask. pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option) { self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask)); @@ -3129,6 +3214,20 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { net.news.retain(|n| n.id != id); } + Event::ReportFiled { id, reporter, target, reason, ts } => { + net.report_seq = net.report_seq.max(id + 1); + if !net.reports.iter().any(|r| r.id == id) { + net.reports.push(Report { id, reporter, target, reason, ts, open: true }); + } + } + Event::ReportClosed { id } => { + if let Some(r) = net.reports.iter_mut().find(|r| r.id == id) { + r.open = false; + } + } + Event::ReportDeleted { id } => { + net.reports.retain(|r| r.id != id); + } Event::OperGranted { account, privs, expires } => { net.opers.insert(key(&account), OperGrant { privs, expires }); } @@ -3437,6 +3536,21 @@ impl Store for Db { fn news(&self, kind: &str) -> Vec { Db::news(self, kind) } + fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option { + Db::report_file(self, reporter, target, reason) + } + fn report_close(&mut self, id: u64) -> bool { + Db::report_close(self, id) + } + fn report_del(&mut self, id: u64) -> bool { + Db::report_del(self, id) + } + fn reports(&self, open_only: bool) -> Vec { + Db::reports(self, open_only) + } + fn report(&self, id: u64) -> Option { + Db::report(self, id) + } fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option) { Db::oper_grant(self, account, privs, expires) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 39115f1..8d66119 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1694,8 +1694,11 @@ fn audit_summary(event: &db::Event) -> Option { Some(_) => format!("set a staff note on \x02{channel}\x02"), None => format!("cleared the staff note on \x02{channel}\x02"), }, - NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 news item"), - NewsDeleted { .. } => "removed a news item".to_string(), + NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 bulletin"), + NewsDeleted { .. } => "removed a bulletin".to_string(), + ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"), + ReportClosed { id } => format!("closed report #{id}"), + ReportDeleted { id } => format!("deleted report #{id}"), OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")), OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"), SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"), @@ -4565,6 +4568,53 @@ mod tests { assert!(denied(&os(&mut e, "000AAAAAP", "STATS")), "expired grant confers nothing"); } + // ReportServ: any user files an abuse report; it lands in the queue AND flows + // through the audit trail so OperServ LOGSEARCH finds it. Reviewing is oper-only. + #[test] + fn reportserv_files_and_is_searchable() { + use fedserv_operserv::OperServ; + use fedserv_reportserv::ReportServ; + let path = std::env::temp_dir().join("fedserv-reportserv.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("staff", "password1", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(OperServ { uid: "42SAAAAAH".into() }), + Box::new(ReportServ { uid: "42SAAAAAK".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin).with(fedserv_api::Priv::Auspex)); + e.set_opers(opers); + let rs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAK".into(), text: t.into() }); + let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); + let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAU".into(), nick: "victim".into(), host: "h".into(), ip: "0.0.0.0".into() }); + + // An ordinary (unidentified) user files a report. + assert!(has(&rs(&mut e, "000AAAAAU", "REPORT spammer flooding the lobby"), "has been sent"), "report accepted"); + // The report is in the queue and — via the audit trail — searchable. + assert!(has(&rs(&mut e, "000AAAAAS", "LIST"), "spammer"), "oper sees the queue"); + assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH flooding"), "flooding"), "report searchable via LOGSEARCH"); + + // Detail, then close. + assert!(has(&rs(&mut e, "000AAAAAS", "VIEW 0"), "flooding the lobby"), "view detail"); + assert!(has(&rs(&mut e, "000AAAAAS", "CLOSE 0"), "closed"), "close"); + assert!(!has(&rs(&mut e, "000AAAAAS", "LIST"), "spammer"), "closed reports drop off the open list"); + + // Non-opers can't review; and filing is rate-limited. + assert!(has(&rs(&mut e, "000AAAAAU", "LIST"), "Access denied"), "review is oper-only"); + assert!(has(&rs(&mut e, "000AAAAAU", "REPORT someone again"), "wait a moment"), "rate-limited"); + } + // InfoServ bulletins over the shared news store: public bulletins greet // everyone on connect, oper bulletins greet an operator on login. #[test] diff --git a/src/grpc.rs b/src/grpc.rs index 1523d84..2edcdf1 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -173,6 +173,9 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelOperNoteSet { .. } | Event::NewsAdded { .. } | Event::NewsDeleted { .. } + | Event::ReportFiled { .. } + | Event::ReportClosed { .. } + | Event::ReportDeleted { .. } | Event::OperGranted { .. } | Event::OperRevoked { .. } | Event::SessionExceptionAdded { .. } diff --git a/src/main.rs b/src/main.rs index d5f197f..18fd4a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ use fedserv_hostserv::HostServ; use fedserv_operserv::OperServ; use fedserv_diceserv::DiceServ; use fedserv_infoserv::InfoServ; +use fedserv_reportserv::ReportServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -101,6 +102,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAJ", cfg.server.sid), })); } + if enabled("reportserv") { + services.push(Box::new(ReportServ { + uid: format!("{}AAAAAK", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid),