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
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'&' => "&",
39 b'<' => "<",
40 b'>' => ">",
41 b'"' => """,
42 b'\'' => "'",
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}