Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
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
126 env
127}
128
129/// Escape a value for inclusion inside a `"..."` Typst string literal.
130fn typst_str_filter(value: Value) -> Result<String, Error> {
131 let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
132 let mut out = String::with_capacity(s.len());
133 for c in s.chars() {
134 match c {
135 '\\' => out.push_str("\\\\"),
136 '"' => out.push_str("\\\""),
137 '\n' => out.push_str("\\n"),
138 '\r' => out.push_str("\\r"),
139 '\t' => out.push_str("\\t"),
140 _ => out.push(c),
141 }
142 }
143 Ok(out)
144}
145
146/// Escape a value for inclusion inside a Typst content block `[...]`.
147/// Backslash-escapes the markup specials so a label like `*foo*` renders as
148/// literal text, not bolded.
149fn typst_md_filter(value: Value) -> Result<String, Error> {
150 let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
151 let mut out = String::with_capacity(s.len());
152 for c in s.chars() {
153 match c {
154 // '/' is escaped so collector-supplied text containing `//` cannot
155 // start a Typst line comment and swallow the rest of the line.
156 '\\' | '[' | ']' | '*' | '_' | '`' | '#' | '$' | '<' | '@' | '~' | '/' => {
157 out.push('\\');
158 out.push(c);
159 }
160 _ => out.push(c),
161 }
162 }
163 Ok(out)
164}
165
166fn urlencode_filter(value: Value) -> Result<String, Error> {
167 let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
168 Ok(urlencoding::encode(&s).into_owned())
169}
170
171/// Subset of Django's url_for/url tags. We only need to emit a URL string,
172/// so we only support the names referenced by templates.
173fn url_for(_state: &State, endpoint: String, kwargs: Kwargs) -> Result<String, Error> {
174 let take_str = |k: &str| -> Result<Option<String>, Error> {
175 let v: Option<Value> = kwargs.get(k).ok();
176 match v {
177 None => Ok(None),
178 Some(val) => {
179 if val.is_undefined() || val.is_none() {
180 Ok(None)
181 } else {
182 Ok(Some(val.to_string()))
183 }
184 }
185 }
186 };
187
188 let path = match endpoint.as_str() {
189 "home" | "index" => "/".to_string(),
190 "login" => "/login".to_string(),
191 "logout" => "/logout".to_string(),
192 "properties" => "/properties".to_string(),
193 "property" => {
194 let id = take_str("property_id")?.unwrap_or_default();
195 format!("/{id}")
196 }
197 "property_delete" => {
198 let id = take_str("property_id")?.unwrap_or_default();
199 format!("/properties/{id}/delete")
200 }
201 "property_cards" => {
202 let id = take_str("property_id")?.unwrap_or_default();
203 format!("/properties/{id}/cards")
204 }
205 "property_public" => {
206 let id = take_str("property_id")?.unwrap_or_default();
207 format!("/properties/{id}/public")
208 }
209 "documentation" => "/documentation".to_string(),
210 "changelog" => "/changelog".to_string(),
211 "favicon" => "/favicon.ico".to_string(),
212 "static" => {
213 let filename = take_str("filename")?.unwrap_or_default();
214 format!("/static/{filename}")
215 }
216 other => {
217 return Err(Error::new(
218 ErrorKind::InvalidOperation,
219 format!("unknown route in url_for: {other}"),
220 ));
221 }
222 };
223 kwargs.assert_all_used()?;
224 Ok(path)
225}
226
227/// Mimics Django's humanize "naturaltime" for createdAt timestamps.
228fn naturaltime_filter(value: Value) -> Result<String, Error> {
229 let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());
230 let dt = chrono::DateTime::parse_from_rfc3339(&s)
231 .map(|d| d.with_timezone(&chrono::Utc))
232 .ok();
233 let Some(dt) = dt else { return Ok(s) };
234 let now = chrono::Utc::now();
235 let diff = now.signed_duration_since(dt);
236 let secs = diff.num_seconds();
237 Ok(if secs < 60 {
238 "just now".to_string()
239 } else if secs < 3600 {
240 let m = secs / 60;
241 format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
242 } else if secs < 86_400 {
243 let h = secs / 3600;
244 format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
245 } else if secs < 86_400 * 30 {
246 let d = secs / 86_400;
247 format!("{d} day{} ago", if d == 1 { "" } else { "s" })
248 } else if secs < 86_400 * 365 {
249 let m = secs / (86_400 * 30);
250 format!("{m} month{} ago", if m == 1 { "" } else { "s" })
251 } else {
252 let y = secs / (86_400 * 365);
253 format!("{y} year{} ago", if y == 1 { "" } else { "s" })
254 })
255}