From 0f280838552bee6f3948bdddde369faa971b7216 Mon Sep 17 00:00:00 2001 From: reverse Date: Mon, 17 Aug 2026 02:12:17 +0000 Subject: [PATCH] markread: persist account-keyed read markers to disk and restore them at startup --- echoircd.conf.example | 4 ++ src/ircd.rs | 1 + src/modules/markread.rs | 92 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/echoircd.conf.example b/echoircd.conf.example index 1c0b6b1..2539d35 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -239,6 +239,10 @@ amu_target = both # member list AND a restart (its modes, topic, TS and ban lists are saved). --- # permchannels_database = permchannels.db # default: .permchannels +# --- read markers (IRCv3 draft/read-marker): account-keyed "last read" positions +# are persisted so they survive a restart, not just reconnects. --- +# markread_database = markread.db # default: .markread + # --- whoisport: opers see the target's listener port in WHOIS (always on) --- # --- ircv3_network_icon: advertise a network icon via draft/ICON ISUPPORT --- # network_icon = https://example.org/icon.png diff --git a/src/ircd.rs b/src/ircd.rs index 37fc455..6c034c5 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -132,6 +132,7 @@ impl Ircd { crate::modules::metadata::load(&mut server); // restore channel metadata crate::modules::reputation::load(&mut server); // restore per-IP reputation crate::modules::permchannels::load(&mut server); // recreate +P channels (pre-link) + crate::modules::markread::load(&mut server); // restore account-keyed read markers crate::modules::geoip::init(&mut server); // load the GeoIP database crate::modules::customprefix::init(&server); // load prefix config crate::mode::init_custom_prefixes(); // register any config-defined prefix modes diff --git a/src/modules/markread.rs b/src/modules/markread.rs index 437c6fd..91f99ce 100644 --- a/src/modules/markread.rs +++ b/src/modules/markread.rs @@ -25,6 +25,70 @@ pub fn marker_id(s: &Server, uid: Uid) -> String { .unwrap_or_else(|| format!("~{uid}")) } +/// The read-marker database path: the `markread_database` conf key, or `.markread`. +fn db_path(s: &Server) -> String { + match s.conf("markread_database") { + Some(p) if !p.is_empty() => p.to_string(), + _ => format!("{}.markread", s.conf_path), + } +} + +/// Serialise the durable (account-keyed) markers as `identity target ts` lines. +/// Session `~uid` keys are dropped — a uid doesn't outlive the connection, let +/// alone a restart. Output is sorted so it's stable across coalesced writes. +fn dump_markers(m: &ReadMarkers) -> String { + let mut out = String::from("# echoircd read markers — auto-generated; account-keyed only\n"); + let mut ids: Vec<&String> = m.0.keys().filter(|k| !k.starts_with('~')).collect(); + ids.sort(); + for id in ids { + if let Some(targets) = m.0.get(id) { + let mut tk: Vec<&String> = targets.keys().collect(); + tk.sort(); + for t in tk { + out.push_str(&format!("{id} {t} {}\n", targets[t])); + } + } + } + out +} + +/// Parse the on-disk format back into a store (identity/target names carry no +/// spaces, so a 3-way split is unambiguous). +fn load_str(store: &mut ReadMarkers, text: &str) { + for line in text.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut it = line.splitn(3, ' '); + if let (Some(id), Some(target), Some(ts)) = (it.next(), it.next(), it.next()) { + if let Ok(ts) = ts.parse::() { + store + .0 + .entry(id.to_string()) + .or_default() + .insert(target.to_string(), ts); + } + } + } +} + +/// Persist the account-keyed markers. Off-core via `disk_write`, which coalesces +/// repeated writes to the same path, so frequent MARKREADs stay cheap. +pub fn save(s: &Server) { + if let Some(m) = s.ext.get::() { + s.disk_write(db_path(s), dump_markers(m)); + } +} + +/// Restore account-keyed markers at startup so read positions survive a restart. +pub fn load(s: &mut Server) { + let Ok(text) = std::fs::read_to_string(db_path(s)) else { + return; + }; + let store = s.ext.get_or_insert_with::(ReadMarkers::default); + load_str(store, &text); +} + /// Cleanup hook: drop a user's session markers on disconnect (account-keyed /// markers are intentionally kept so they persist across reconnects). pub struct MarkRead; @@ -85,6 +149,9 @@ impl Command for MarkReadCmd { for p in recips { s.send(p, line.clone()); } + if !id.starts_with('~') { + save(s); // account markers are durable — persist across restarts + } } None => { let val = s @@ -101,3 +168,28 @@ impl Command for MarkReadCmd { CmdResult::Ok } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markers_round_trip_and_drop_session_keys() { + let mut m = ReadMarkers::default(); + m.0.entry("alice".into()).or_default().insert("#chan".into(), 1700); + m.0.entry("alice".into()).or_default().insert("bob".into(), 42); + m.0.entry("~7".into()).or_default().insert("#chan".into(), 9999); // session: ephemeral + + let text = dump_markers(&m); + assert!(text.contains("alice #chan 1700")); + assert!(text.contains("alice bob 42")); + assert!(!text.contains("~7"), "session keys must not be persisted"); + + // reload into a fresh store — the account markers come back, the session one doesn't + let mut restored = ReadMarkers::default(); + load_str(&mut restored, &text); + assert_eq!(restored.0.get("alice").and_then(|t| t.get("#chan")), Some(&1700)); + assert_eq!(restored.0.get("alice").and_then(|t| t.get("bob")), Some(&42)); + assert!(!restored.0.contains_key("~7")); + } +}