diff --git a/.gitignore b/.gitignore index b7bbd1a..3fdf355 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ rustbot.service rustbot.conf weather.*.json karma.*.json -*.json.tmp \ No newline at end of file +*.json.tmp +karma-web.service +karma-web.timer +karma.devtronic.pro.conf \ No newline at end of file diff --git a/README.md b/README.md index 7c9b4b1..6a695c5 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,22 @@ lists them at runtime and `help ` describes one. | `tinyurl ` | shorten a URL | | `down ` | is a site reachable? | +## Karma web page + +The live karma board is published at ****. A second +binary, `karma-web`, reads the per-network `karma.*.json` files and renders one +self-contained HTML page (no JS/CSS/font dependencies, all IRC-supplied text +HTML-escaped); a systemd timer regenerates it every minute and nginx serves it +over TLS. + +```sh +cargo build --release --bin karma-web +./target/release/karma-web # omit out.html to print to stdout +``` + +On mars: `karma-web.service` + `karma-web.timer` write `/var/www/karma/index.html`, +served by the `karma.devtronic.pro.conf` vhost. + ## Build & run ```sh diff --git a/src/bin/karma-web.rs b/src/bin/karma-web.rs new file mode 100644 index 0000000..abeb453 --- /dev/null +++ b/src/bin/karma-web.rs @@ -0,0 +1,48 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rustbot::karma_web::{parse_board, render, Board}; + +fn main() { + let mut args = std::env::args().skip(1); + let dir = args + .next() + .or_else(|| std::env::var("RUSTBOT_DATA_DIR").ok()) + .unwrap_or_else(|| ".".to_string()); + let out = args.next(); + + let mut boards: Vec = Vec::new(); + if let Ok(rd) = fs::read_dir(&dir) { + let mut paths: Vec = rd.flatten().map(|e| e.path()).collect(); + paths.sort(); + for p in paths { + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if let Some(net) = name + .strip_prefix("karma.") + .and_then(|s| s.strip_suffix(".json")) + { + if let Ok(body) = fs::read_to_string(&p) { + boards.push(parse_board(net, &body)); + } + } + } + } + + let epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let html = render(&boards, epoch); + + match out { + Some(path) => { + let tmp = format!("{path}.tmp"); + if let Err(e) = fs::write(&tmp, &html).and_then(|_| fs::rename(&tmp, &path)) { + eprintln!("karma-web: could not write {path}: {e}"); + std::process::exit(1); + } + } + None => print!("{html}"), + } +} diff --git a/src/karma_web.rs b/src/karma_web.rs new file mode 100644 index 0000000..30c9d6a --- /dev/null +++ b/src/karma_web.rs @@ -0,0 +1,210 @@ +use crate::json::Json; + +pub struct Row { + pub thing: String, + pub count: i64, + pub reason: Option, + pub by: Option, +} + +pub struct Board { + pub network: String, + pub rows: Vec, +} + +pub fn parse_board(network: &str, body: &str) -> Board { + let mut rows = Vec::new(); + if let Some(Json::Obj(entries)) = Json::parse(body) { + for (k, v) in entries { + let (count, reason, by) = fields(&v); + rows.push(Row { + thing: k, + count, + reason, + by, + }); + } + } + rows.sort_by(|a, b| { + b.count + .cmp(&a.count) + .then_with(|| a.thing.to_lowercase().cmp(&b.thing.to_lowercase())) + }); + Board { + network: network.to_string(), + rows, + } +} + +fn fields(v: &Json) -> (i64, Option, Option) { + if let Some(n) = v.as_f64() { + return (n as i64, None, None); + } + let count = v.get("n").and_then(Json::as_f64).unwrap_or(0.0) as i64; + let reason = v + .get("why") + .and_then(Json::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let by = v + .get("by") + .and_then(Json::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + (count, reason, by) +} + +fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\'' => o.push_str("'"), + _ => o.push(c), + } + } + o +} + +pub fn render(boards: &[Board], updated_epoch: u64) -> String { + let total: usize = boards.iter().map(|b| b.rows.len()).sum(); + let mut s = String::from(HEAD); + if total == 0 { + s.push_str(EMPTY); + } else { + s.push_str(SEARCH); + for b in boards { + if b.rows.is_empty() { + continue; + } + s.push_str(&format!( + "

{}

\ + {} {}
    ", + esc(&b.network), + b.rows.len(), + if b.rows.len() == 1 { + "entry" + } else { + "entries" + } + )); + for (i, r) in b.rows.iter().enumerate() { + s.push_str(&row(i + 1, r)); + } + s.push_str("
"); + } + } + s.push_str(&SCRIPT.replace("__EPOCH__", &updated_epoch.to_string())); + s +} + +fn row(rank: usize, r: &Row) -> String { + let medal = match rank { + 1 => "🥇".to_string(), + 2 => "🥈".to_string(), + 3 => "🥉".to_string(), + n => n.to_string(), + }; + let top = if rank <= 3 { " top" } else { "" }; + let (klass, sign) = match r.count { + n if n > 0 => ("pos", "+"), + n if n < 0 => ("neg", ""), + _ => ("zero", ""), + }; + let mut who = format!("
{}
", esc(&r.thing)); + if let Some(reason) = &r.reason { + match &r.by { + Some(by) => who.push_str(&format!( + "
\u{201C}{}\u{201D} \u{2014} {}
", + esc(reason), + esc(by) + )), + None => who.push_str(&format!( + "
\u{201C}{}\u{201D}
", + esc(reason) + )), + } + } + format!( + "
  • {medal}\ +
    {who}
    \ + {sign}{}
  • ", + r.count + ) +} + +const HEAD: &str = r##" + + + + +karma — devtronic + + + + +
    +
    +

    ✨ karma

    +

    community karma, tracked by rubot

    +
    +"##; + +const SEARCH: &str = r##""##; + +const EMPTY: &str = r##"
    no karma yet — say nick++ in the channel to give some.
    "##; + +const SCRIPT: &str = r##"
    say nick++ in the channel to give karma · rubot · updated
    +
    + + + +"##; diff --git a/src/lib.rs b/src/lib.rs index f70e772..c36544c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod http; pub mod irc; pub mod json; +pub mod karma_web; pub fn log(msg: &str) { eprintln!("[rustbot] {msg}"); diff --git a/tests/karma_web.rs b/tests/karma_web.rs new file mode 100644 index 0000000..c9446d5 --- /dev/null +++ b/tests/karma_web.rs @@ -0,0 +1,60 @@ +use rustbot::karma_web::{parse_board, render}; + +#[test] +fn parses_flat_number_format() { + let b = parse_board("libera", r#"{"romaka":2}"#); + assert_eq!(b.rows.len(), 1); + assert_eq!(b.rows[0].thing, "romaka"); + assert_eq!(b.rows[0].count, 2); + assert!(b.rows[0].reason.is_none()); +} + +#[test] +fn parses_object_format_with_reason_and_by() { + let b = parse_board("t", r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#); + assert_eq!(b.rows[0].count, 5); + assert_eq!(b.rows[0].reason.as_deref(), Some("clean PR")); + assert_eq!(b.rows[0].by.as_deref(), Some("alice")); +} + +#[test] +fn sorts_descending_by_count() { + let b = parse_board("t", r#"{"a":1,"b":9,"c":-3}"#); + let order: Vec<&str> = b.rows.iter().map(|r| r.thing.as_str()).collect(); + assert_eq!(order, vec!["b", "a", "c"]); +} + +#[test] +fn renders_scores_and_reason() { + let b = parse_board( + "libera", + r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#, + ); + let html = render(&[b], 0); + assert!(html.contains("libera"), "{html}"); + assert!(html.contains(">rust<"), "{html}"); + assert!(html.contains(">+5<"), "{html}"); + assert!(html.contains("clean PR"), "{html}"); + assert!(html.contains("alice"), "{html}"); +} + +#[test] +fn escapes_untrusted_irc_input() { + let b = parse_board( + "t", + r#"{"