Add ChanServ mode lock (MLOCK)
Founders lock channel modes with MLOCK <#channel> [modes]. The lock is a ChannelMlock event, so it replicates and compacts like everything else; it is applied on channel creation alongside +r and enforced by reverting any FMODE that breaks it. Our own changes are filtered so enforcement can't loop. Simple (paramless) modes for now.
This commit is contained in:
parent
727713ee3f
commit
353aee1b54
5 changed files with 248 additions and 14 deletions
108
src/engine/db.rs
108
src/engine/db.rs
|
|
@ -40,6 +40,7 @@ pub enum Event {
|
||||||
CertRemoved { account: String, fp: String },
|
CertRemoved { account: String, fp: String },
|
||||||
ChannelRegistered { name: String, founder: String, ts: u64 },
|
ChannelRegistered { name: String, founder: String, ts: u64 },
|
||||||
ChannelDropped { name: String },
|
ChannelDropped { name: String },
|
||||||
|
ChannelMlock { name: String, on: String, off: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
// A registered channel and who owns it.
|
// A registered channel and who owns it.
|
||||||
|
|
@ -48,6 +49,57 @@ pub struct ChannelInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub founder: String,
|
pub founder: String,
|
||||||
pub ts: u64,
|
pub ts: u64,
|
||||||
|
// Mode-lock: chars services keep set / unset (besides the implicit +r).
|
||||||
|
#[serde(default)]
|
||||||
|
pub lock_on: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lock_off: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelInfo {
|
||||||
|
/// The mode string services keep applied: +r plus the lock.
|
||||||
|
pub fn lock_modes(&self) -> String {
|
||||||
|
let mut s = format!("+r{}", self.lock_on);
|
||||||
|
if !self.lock_off.is_empty() {
|
||||||
|
s.push('-');
|
||||||
|
s.push_str(&self.lock_off);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given a mode change, the modes to send back to restore the lock, if it was
|
||||||
|
/// violated. +r is always locked on. Only simple (paramless) modes are checked.
|
||||||
|
pub fn enforce(&self, change: &str) -> Option<String> {
|
||||||
|
let (mut readd, mut reremove) = (String::new(), String::new());
|
||||||
|
let mut adding = true;
|
||||||
|
for ch in change.chars() {
|
||||||
|
match ch {
|
||||||
|
'+' => adding = true,
|
||||||
|
'-' => adding = false,
|
||||||
|
m if m.is_ascii_alphabetic() => {
|
||||||
|
if (m == 'r' || self.lock_on.contains(m)) && !adding && !readd.contains(m) {
|
||||||
|
readd.push(m);
|
||||||
|
} else if self.lock_off.contains(m) && adding && !reremove.contains(m) {
|
||||||
|
reremove.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readd.is_empty() && reremove.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut s = String::new();
|
||||||
|
if !readd.is_empty() {
|
||||||
|
s.push('+');
|
||||||
|
s.push_str(&readd);
|
||||||
|
}
|
||||||
|
if !reremove.is_empty() {
|
||||||
|
s.push('-');
|
||||||
|
s.push_str(&reremove);
|
||||||
|
}
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A durable record: the event plus the metadata a gossip layer needs to address
|
// A durable record: the event plus the metadata a gossip layer needs to address
|
||||||
|
|
@ -288,11 +340,12 @@ impl Db {
|
||||||
pub fn compact(&mut self) -> std::io::Result<()> {
|
pub fn compact(&mut self) -> std::io::Result<()> {
|
||||||
let before = self.log.len();
|
let before = self.log.len();
|
||||||
let mut snapshot: Vec<Event> = self.accounts.values().cloned().map(Event::AccountRegistered).collect();
|
let mut snapshot: Vec<Event> = self.accounts.values().cloned().map(Event::AccountRegistered).collect();
|
||||||
snapshot.extend(self.channels.values().map(|c| Event::ChannelRegistered {
|
for c in self.channels.values() {
|
||||||
name: c.name.clone(),
|
snapshot.push(Event::ChannelRegistered { name: c.name.clone(), founder: c.founder.clone(), ts: c.ts });
|
||||||
founder: c.founder.clone(),
|
if !c.lock_on.is_empty() || !c.lock_off.is_empty() {
|
||||||
ts: c.ts,
|
snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() });
|
||||||
}));
|
}
|
||||||
|
}
|
||||||
self.log.compact(snapshot)?;
|
self.log.compact(snapshot)?;
|
||||||
tracing::info!(before, after = self.log.len(), "compacted event log");
|
tracing::info!(before, after = self.log.len(), "compacted event log");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -432,7 +485,7 @@ impl Db {
|
||||||
self.log
|
self.log
|
||||||
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
||||||
.map_err(|_| ChanError::Internal)?;
|
.map_err(|_| ChanError::Internal)?;
|
||||||
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts });
|
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new() });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -441,6 +494,21 @@ impl Db {
|
||||||
self.channels.get(&key(name))
|
self.channels.get(&key(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the mode-lock (chars to keep set / unset) for a registered channel.
|
||||||
|
pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> {
|
||||||
|
let k = key(name);
|
||||||
|
if !self.channels.contains_key(&k) {
|
||||||
|
return Err(ChanError::NoChannel);
|
||||||
|
}
|
||||||
|
self.log
|
||||||
|
.append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string() })
|
||||||
|
.map_err(|_| ChanError::Internal)?;
|
||||||
|
let c = self.channels.get_mut(&k).unwrap();
|
||||||
|
c.lock_on = on.to_string();
|
||||||
|
c.lock_off = off.to_string();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Unregister `name`.
|
/// Unregister `name`.
|
||||||
pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
|
pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
|
||||||
let k = key(name);
|
let k = key(name);
|
||||||
|
|
@ -473,11 +541,17 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ChannelRegistered { name, founder, ts } => {
|
Event::ChannelRegistered { name, founder, ts } => {
|
||||||
channels.insert(key(&name), ChannelInfo { name, founder, ts });
|
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new() });
|
||||||
}
|
}
|
||||||
Event::ChannelDropped { name } => {
|
Event::ChannelDropped { name } => {
|
||||||
channels.remove(&key(&name));
|
channels.remove(&key(&name));
|
||||||
}
|
}
|
||||||
|
Event::ChannelMlock { name, on, off } => {
|
||||||
|
if let Some(c) = channels.get_mut(&key(&name)) {
|
||||||
|
c.lock_on = on;
|
||||||
|
c.lock_off = off;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -625,4 +699,24 @@ mod tests {
|
||||||
assert!(db.channel("#chat").is_none());
|
assert!(db.channel("#chat").is_none());
|
||||||
assert!(matches!(db.drop_channel("#chat"), Err(ChanError::NoChannel)));
|
assert!(matches!(db.drop_channel("#chat"), Err(ChanError::NoChannel)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mode lock persists, renders the applied string, and detects violations.
|
||||||
|
#[test]
|
||||||
|
fn mode_lock_persists_and_enforces() {
|
||||||
|
let p = tmp("mlock");
|
||||||
|
let mut db = Db::open(&p, "local");
|
||||||
|
db.register_channel("#chan", "alice").unwrap();
|
||||||
|
db.set_mlock("#chan", "nt", "s").unwrap();
|
||||||
|
|
||||||
|
let info = db.channel("#chan").unwrap();
|
||||||
|
assert_eq!(info.lock_modes(), "+rnt-s");
|
||||||
|
assert_eq!(info.enforce("-nt"), Some("+nt".to_string())); // locked-on removed
|
||||||
|
assert_eq!(info.enforce("+s"), Some("-s".to_string())); // locked-off added
|
||||||
|
assert_eq!(info.enforce("-r"), Some("+r".to_string())); // +r always locked
|
||||||
|
assert_eq!(info.enforce("+m"), None); // unrelated mode ignored
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
let db = Db::open(&p, "local");
|
||||||
|
assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,10 +174,16 @@ impl Engine {
|
||||||
// creation and on the uplink burst, so registered channels regain the
|
// creation and on the uplink burst, so registered channels regain the
|
||||||
// mode after emptying, and after a services relink.
|
// mode after emptying, and after a services relink.
|
||||||
NetEvent::ChannelCreate { channel } => {
|
NetEvent::ChannelCreate { channel } => {
|
||||||
if self.db.channel(&channel).is_some() {
|
match self.db.channel(&channel) {
|
||||||
vec![NetAction::ChannelMode { channel, modes: "+r".to_string() }]
|
Some(info) => vec![NetAction::ChannelMode { channel, modes: info.lock_modes() }],
|
||||||
} else {
|
None => Vec::new(),
|
||||||
Vec::new()
|
}
|
||||||
|
}
|
||||||
|
// Enforce the mode lock: revert any change that broke it.
|
||||||
|
NetEvent::ChannelModeChange { channel, modes } => {
|
||||||
|
match self.db.channel(&channel).and_then(|info| info.enforce(&modes)) {
|
||||||
|
Some(revert) => vec![NetAction::ChannelMode { channel, modes: revert }],
|
||||||
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NetEvent::Quit { uid } => {
|
NetEvent::Quit { uid } => {
|
||||||
|
|
@ -939,8 +945,15 @@ mod tests {
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#room" && modes == "+r")), "register sets +r: {out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#room" && modes == "+r")), "register sets +r: {out:?}");
|
||||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #room"), "alice"));
|
assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #room"), "alice"));
|
||||||
|
|
||||||
// A different, unidentified user cannot drop it.
|
// Founder sets a mode lock: it applies immediately and shows on view.
|
||||||
|
let out = to_cs(&mut e, "000AAAAAB", "MLOCK #room +nt-s");
|
||||||
|
assert!(notice(&out, "updated"));
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#room" && modes == "+rnt-s")), "mlock applied: {out:?}");
|
||||||
|
assert!(notice(&to_cs(&mut e, "000AAAAAB", "MLOCK #room"), "+nt-s"));
|
||||||
|
|
||||||
|
// A different, unidentified user cannot set the lock or drop it.
|
||||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() });
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() });
|
||||||
|
assert!(notice(&to_cs(&mut e, "000AAAAAC", "MLOCK #room +i"), "founder"));
|
||||||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder"));
|
assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder"));
|
||||||
|
|
||||||
// The founder can, which also clears +r.
|
// The founder can, which also clears +r.
|
||||||
|
|
@ -965,4 +978,27 @@ mod tests {
|
||||||
let out = e.handle(NetEvent::ChannelCreate { channel: "#other".into() });
|
let out = e.handle(NetEvent::ChannelCreate { channel: "#other".into() });
|
||||||
assert!(out.is_empty(), "unregistered channel is left alone: {out:?}");
|
assert!(out.is_empty(), "unregistered channel is left alone: {out:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A channel's mode lock is applied on creation and enforced on violation.
|
||||||
|
#[test]
|
||||||
|
fn mode_lock_is_applied_and_enforced() {
|
||||||
|
let path = std::env::temp_dir().join("fedserv-mlock-engine.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "42S");
|
||||||
|
db.register_channel("#lk", "alice").unwrap();
|
||||||
|
db.set_mlock("#lk", "nt", "s").unwrap();
|
||||||
|
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||||||
|
let mut e = Engine::new(vec![Box::new(ns)], db);
|
||||||
|
|
||||||
|
// Creation applies the full lock.
|
||||||
|
let out = e.handle(NetEvent::ChannelCreate { channel: "#lk".into() });
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#lk" && modes == "+rnt-s")), "{out:?}");
|
||||||
|
|
||||||
|
// Setting a locked-off mode is reverted.
|
||||||
|
let out = e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+s".into() });
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#lk" && modes == "-s")), "{out:?}");
|
||||||
|
|
||||||
|
// An unrelated mode change is left alone.
|
||||||
|
assert!(e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+m".into() }).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,17 @@ impl Protocol for InspIrcd {
|
||||||
Some(chan) if !chan.is_empty() => vec![NetEvent::ChannelCreate { channel: chan.to_string() }],
|
Some(chan) if !chan.is_empty() => vec![NetEvent::ChannelCreate { channel: chan.to_string() }],
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
},
|
},
|
||||||
|
// :<src> FMODE <chan> <ts> <modes> [params] — a channel mode change.
|
||||||
|
// Skip changes we made ourselves so enforcement can't loop.
|
||||||
|
"FMODE" => {
|
||||||
|
let a: Vec<&str> = tokens.collect();
|
||||||
|
match (source.as_deref(), a.first(), a.get(2)) {
|
||||||
|
(Some(src), Some(chan), Some(modes)) if src != self.sid && chan.starts_with('#') => {
|
||||||
|
vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }]
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
|
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
|
||||||
// account-registration relay from an ircd:
|
// account-registration relay from an ircd:
|
||||||
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
|
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
|
||||||
|
|
@ -247,4 +258,16 @@ mod tests {
|
||||||
"{ev:?}"
|
"{ev:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A peer FMODE surfaces as a mode change; our own (sid 42S) is filtered out.
|
||||||
|
#[test]
|
||||||
|
fn parses_fmode_and_filters_own() {
|
||||||
|
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m");
|
||||||
|
assert!(
|
||||||
|
matches!(ev.as_slice(), [NetEvent::ChannelModeChange { channel, modes }] if channel == "#chan" && modes == "+m"),
|
||||||
|
"{ev:?}"
|
||||||
|
);
|
||||||
|
let ev = proto().parse(":42S FMODE #chan 1783845132 -m");
|
||||||
|
assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ pub enum NetEvent {
|
||||||
// A channel was created or bursted (InspIRCd FJOIN). Subsequent single joins
|
// A channel was created or bursted (InspIRCd FJOIN). Subsequent single joins
|
||||||
// arrive as IJOIN and are not surfaced.
|
// arrive as IJOIN and are not surfaced.
|
||||||
ChannelCreate { channel: String },
|
ChannelCreate { channel: String },
|
||||||
|
// A channel's modes changed (FMODE), for enforcing mode locks. Our own
|
||||||
|
// changes are filtered out by the protocol layer.
|
||||||
|
ChannelModeChange { channel: String, modes: String },
|
||||||
Quit { uid: String },
|
Quit { uid: String },
|
||||||
// An ircd relaying an IRCv3 account-registration request to us as the authority.
|
// An ircd relaying an IRCv3 account-registration request to us as the authority.
|
||||||
AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String },
|
AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::engine::db::{ChanError, Db};
|
use crate::engine::db::{ChanError, ChannelInfo, Db};
|
||||||
use crate::engine::service::{Sender, Service, ServiceCtx};
|
use crate::engine::service::{Sender, Service, ServiceCtx};
|
||||||
|
|
||||||
pub struct ChanServ {
|
pub struct ChanServ {
|
||||||
|
|
@ -81,13 +81,91 @@ impl Service for ChanServ {
|
||||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02DROP\x02 <#channel>."),
|
Some("MLOCK") => {
|
||||||
|
let Some(&chan) = args.get(1) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: MLOCK <#channel> [modes], e.g. MLOCK #chan +nt-s");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// No modes given: show the current lock.
|
||||||
|
if args.len() <= 2 {
|
||||||
|
match db.channel(chan) {
|
||||||
|
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
|
||||||
|
Some(info) if info.lock_on.is_empty() && info.lock_off.is_empty() => {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{}\x02 has no mode lock set.", info.name));
|
||||||
|
}
|
||||||
|
Some(info) => ctx.notice(me, from.uid, format!("Mode lock for \x02{}\x02: \x02{}\x02", info.name, show_mlock(info))),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Setting the lock: founder only.
|
||||||
|
let founder = match db.channel(chan) {
|
||||||
|
Some(info) => info.founder.clone(),
|
||||||
|
None => {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if from.account != Some(founder.as_str()) {
|
||||||
|
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can set its mode lock."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (on, off) = parse_mlock(&args[2..].concat());
|
||||||
|
match db.set_mlock(chan, &on, &off) {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Some(info) = db.channel(chan) {
|
||||||
|
ctx.channel_mode(chan, &info.lock_modes()); // apply it now
|
||||||
|
}
|
||||||
|
ctx.notice(me, from.uid, format!("Mode lock for \x02{chan}\x02 updated."));
|
||||||
|
}
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02MLOCK\x02 <#channel> [modes], \x02DROP\x02 <#channel>."),
|
||||||
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is
|
||||||
|
// implicit for a registered channel and is ignored here.
|
||||||
|
fn parse_mlock(spec: &str) -> (String, String) {
|
||||||
|
let (mut on, mut off) = (String::new(), String::new());
|
||||||
|
let mut adding = true;
|
||||||
|
for ch in spec.chars() {
|
||||||
|
match ch {
|
||||||
|
'+' => adding = true,
|
||||||
|
'-' => adding = false,
|
||||||
|
'r' => {}
|
||||||
|
m if m.is_ascii_alphabetic() => {
|
||||||
|
on.retain(|c| c != m);
|
||||||
|
off.retain(|c| c != m);
|
||||||
|
if adding {
|
||||||
|
on.push(m);
|
||||||
|
} else {
|
||||||
|
off.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(on, off)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a channel's lock as "+on-off" for display.
|
||||||
|
fn show_mlock(info: &ChannelInfo) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
if !info.lock_on.is_empty() {
|
||||||
|
s.push('+');
|
||||||
|
s.push_str(&info.lock_on);
|
||||||
|
}
|
||||||
|
if !info.lock_off.is_empty() {
|
||||||
|
s.push('-');
|
||||||
|
s.push_str(&info.lock_off);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
// Format a Unix timestamp (seconds) as "YYYY-MM-DD HH:MM:SS UTC", using Howard
|
// Format a Unix timestamp (seconds) as "YYYY-MM-DD HH:MM:SS UTC", using Howard
|
||||||
// Hinnant's civil-from-days algorithm so no date crate is needed.
|
// Hinnant's civil-from-days algorithm so no date crate is needed.
|
||||||
fn human_time(ts: u64) -> String {
|
fn human_time(ts: u64) -> String {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue