repos
/ status-rust master

status-rust

mirror archived upstream

Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.

axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite

10.5 KB · 312 lines · Rust Raw History
  1use minijinja::value::{Kwargs, Value};
  2use minijinja::{path_loader, AutoEscape, Environment, Error, ErrorKind, Output, State};
  3use serde::Serialize;
  4use serde_json::Value as JsonValue;
  5use std::path::Path;
  6
  7/// Jinja2-faithful HTML formatter. Does NOT escape `/`, so vite asset URLs
  8/// like `/static/base-abc123.js` come through clean instead of `/...`.
  9fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {
 10    if value.is_safe() {
 11        write!(out, "{value}").map_err(Error::from)?;
 12        return Ok(());
 13    }
 14    let auto_escape = match state.auto_escape() {
 15        AutoEscape::Html => true,
 16        AutoEscape::None => false,
 17        _ => return minijinja::escape_formatter(out, state, value),
 18    };
 19    if !auto_escape {
 20        write!(out, "{value}").map_err(Error::from)?;
 21        return Ok(());
 22    }
 23    if let Some(s) = value.as_str() {
 24        write_jinja2_html(out, s).map_err(Error::from)?;
 25    } else if value.is_undefined() || value.is_none() {
 26        // emit nothing
 27    } else {
 28        let stringified = value.to_string();
 29        write_jinja2_html(out, &stringified).map_err(Error::from)?;
 30    }
 31    Ok(())
 32}
 33
 34fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {
 35    let mut last = 0;
 36    for (i, b) in s.bytes().enumerate() {
 37        let escape = match b {
 38            b'&' => "&amp;",
 39            b'<' => "&lt;",
 40            b'>' => "&gt;",
 41            b'"' => "&#34;",
 42            b'\'' => "&#39;",
 43            _ => continue,
 44        };
 45        if last < i {
 46            out.write_str(&s[last..i])?;
 47        }
 48        out.write_str(escape)?;
 49        last = i + 1;
 50    }
 51    if last < s.len() {
 52        out.write_str(&s[last..])?;
 53    }
 54    Ok(())
 55}
 56
 57#[derive(Debug, Clone, Serialize)]
 58pub struct RequestCtx {
 59    pub url: String,
 60    pub url_root: String,
 61    pub base_url: String,
 62    pub path: String,
 63}
 64
 65#[derive(Debug, Clone, Serialize, Default)]
 66pub struct UserCtx {
 67    pub is_authenticated: bool,
 68}
 69
 70fn read_manifest(path: &Path) -> JsonValue {
 71    let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
 72    serde_json::from_str(&text).unwrap_or(JsonValue::Null)
 73}
 74
 75fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {
 76    if let Some(chunk) = manifest.get(entry) {
 77        if kind == "css" {
 78            if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {
 79                if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {
 80                    return format!("/static/{first}");
 81                }
 82            }
 83        }
 84        if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {
 85            return format!("/static/{file}");
 86        }
 87    }
 88    format!("/static/{entry}")
 89}
 90
 91pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {
 92    let mut env = Environment::new();
 93    env.set_loader(path_loader(templates_dir));
 94    env.set_formatter(jinja2_html_formatter);
 95
 96    #[cfg(debug_assertions)]
 97    {
 98        let path = manifest_path.to_path_buf();
 99        env.add_function(
100            "vite_asset",
101            move |entry: String, kind: Option<String>| -> Result<String, Error> {
102                let kind = kind.unwrap_or_else(|| "file".to_string());
103                let manifest = read_manifest(&path);
104                Ok(lookup_asset(&manifest, &entry, &kind))
105            },
106        );
107    }
108    #[cfg(not(debug_assertions))]
109    {
110        let manifest = read_manifest(manifest_path);
111        env.add_function(
112            "vite_asset",
113            move |entry: String, kind: Option<String>| -> Result<String, Error> {
114                let kind = kind.unwrap_or_else(|| "file".to_string());
115                Ok(lookup_asset(&manifest, &entry, &kind))
116            },
117        );
118    }
119
120    env.add_function("url_for", url_for);
121    env.add_filter("naturaltime", naturaltime_filter);
122    env.add_filter("urlencode", urlencode_filter);
123    env.add_filter("typst_str", typst_str_filter);
124    env.add_filter("typst_md", typst_md_filter);
125    env.add_filter(
126        "url_path",
127        |v: Value| -> Result<String, Error> {
128            let s = v.as_str().map(|s| s.to_string()).unwrap_or_else(|| v.to_string());
129            // Returns the path+query+fragment portion of a URL. Mirrors the
130            // Django filter used by the old crawler-insights table.
131            let mut parts = s.splitn(4, '/');
132            let _scheme = parts.next();
133            let _empty = parts.next();
134            let _host = parts.next();
135            Ok(format!("/{}", parts.next().unwrap_or("")))
136        },
137    );
138    env.add_filter(
139        "format_ms_savings",
140        |v: Value| -> Result<String, Error> {
141            // Mirrors the Django filter: render an ms value as "1.2 s" or
142            // "420 ms". Empty string for zero / null so the template can show a
143            // "—" placeholder.
144            let ms = v
145                .as_i64()
146                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
147                .unwrap_or(0);
148            if ms <= 0 {
149                Ok(String::new())
150            } else if ms >= 1000 {
151                Ok(format!("{:.1} s", ms as f64 / 1000.0))
152            } else {
153                Ok(format!("{ms} ms"))
154            }
155        },
156    );
157    env.add_filter(
158        "intcomma",
159        |v: Value| -> Result<String, Error> {
160            let n = v.as_i64().unwrap_or(0);
161            let s = n.abs().to_string();
162            let mut out = String::new();
163            for (i, ch) in s.chars().rev().enumerate() {
164                if i > 0 && i % 3 == 0 {
165                    out.insert(0, ',');
166                }
167                out.insert(0, ch);
168            }
169            if n < 0 {
170                out.insert(0, '-');
171            }
172            Ok(out)
173        },
174    );
175
176    env
177}
178
179fn urlencode_filter(value: Value) -> Result<String, Error> {
180    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
181    Ok(urlencoding::encode(&s).into_owned())
182}
183
184/// Escape a value for inclusion inside a `"..."` Typst string literal.
185fn typst_str_filter(value: Value) -> Result<String, Error> {
186    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
187    let mut out = String::with_capacity(s.len());
188    for c in s.chars() {
189        match c {
190            '\\' => out.push_str("\\\\"),
191            '"' => out.push_str("\\\""),
192            '\n' => out.push_str("\\n"),
193            '\r' => out.push_str("\\r"),
194            '\t' => out.push_str("\\t"),
195            _ => out.push(c),
196        }
197    }
198    Ok(out)
199}
200
201/// Escape a value for inclusion inside a Typst content block `[...]`.
202/// Backslash-escapes the markup specials so a label like `*foo*` renders as
203/// literal text, not bolded.
204fn typst_md_filter(value: Value) -> Result<String, Error> {
205    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
206    let mut out = String::with_capacity(s.len());
207    for c in s.chars() {
208        match c {
209            '\\' | '[' | ']' | '*' | '_' | '`' | '#' | '$' | '<' | '@' | '~' => {
210                out.push('\\');
211                out.push(c);
212            }
213            _ => out.push(c),
214        }
215    }
216    Ok(out)
217}
218
219/// Subset of Django's url_for/url tags. We only emit URL strings.
220fn url_for(_state: &State, endpoint: String, kwargs: Kwargs) -> Result<String, Error> {
221    let take_str = |k: &str| -> Result<Option<String>, Error> {
222        let v: Option<Value> = kwargs.get(k).ok();
223        match v {
224            None => Ok(None),
225            Some(val) => {
226                if val.is_undefined() || val.is_none() {
227                    Ok(None)
228                } else {
229                    Ok(Some(val.to_string()))
230                }
231            }
232        }
233    };
234
235    let path = match endpoint.as_str() {
236        "home" | "index" => "/".to_string(),
237        "login" => "/login".to_string(),
238        "logout" => "/logout".to_string(),
239        "properties" => "/properties".to_string(),
240        "property" => {
241            let id = take_str("property_id")?.unwrap_or_default();
242            format!("/{id}")
243        }
244        "property_delete" => {
245            let id = take_str("property_id")?.unwrap_or_default();
246            format!("/properties/{id}/delete")
247        }
248        "property_public" | "adjust_is_public_property" => {
249            let id = take_str("property_id")?.unwrap_or_default();
250            format!("/properties/{id}/public")
251        }
252        "property_status" => {
253            let id = take_str("property_id")?.unwrap_or_default();
254            format!("/properties/{id}/status")
255        }
256        "property_recrawl" => {
257            let id = take_str("property_id")?.unwrap_or_default();
258            format!("/properties/{id}/recrawl")
259        }
260        "property_rerun_lighthouse" => {
261            let id = take_str("property_id")?.unwrap_or_default();
262            format!("/properties/{id}/rerun-lighthouse")
263        }
264        "changelog" => "/changelog".to_string(),
265        "favicon" => "/favicon.ico".to_string(),
266        "robots" => "/robots.txt".to_string(),
267        "sitemap" => "/sitemap.xml".to_string(),
268        "static" => {
269            let filename = take_str("filename")?.unwrap_or_default();
270            format!("/static/{filename}")
271        }
272        other => {
273            return Err(Error::new(
274                ErrorKind::InvalidOperation,
275                format!("unknown route in url_for: {other}"),
276            ));
277        }
278    };
279    kwargs.assert_all_used()?;
280    Ok(path)
281}
282
283/// Mimics Django's humanize "naturaltime" for createdAt timestamps.
284fn naturaltime_filter(value: Value) -> Result<String, Error> {
285    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
286    let dt = chrono::DateTime::parse_from_rfc3339(&s)
287        .map(|d| d.with_timezone(&chrono::Utc))
288        .ok();
289    let Some(dt) = dt else { return Ok(s) };
290    let now = chrono::Utc::now();
291    let diff = now.signed_duration_since(dt);
292    let secs = diff.num_seconds();
293    Ok(if secs < 60 {
294        "just now".to_string()
295    } else if secs < 3600 {
296        let m = secs / 60;
297        format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
298    } else if secs < 86_400 {
299        let h = secs / 3600;
300        format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
301    } else if secs < 86_400 * 30 {
302        let d = secs / 86_400;
303        format!("{d} day{} ago", if d == 1 { "" } else { "s" })
304    } else if secs < 86_400 * 365 {
305        let m = secs / (86_400 * 30);
306        format!("{m} month{} ago", if m == 1 { "" } else { "s" })
307    } else {
308        let y = secs / (86_400 * 365);
309        format!("{y} year{} ago", if y == 1 { "" } else { "s" })
310    })
311}