repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

8.6 KB · 256 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
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}