Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1use axum::{
2 http::StatusCode,
3 response::{Html, IntoResponse, Response},
4};
5use chrono::Datelike;
6
7use crate::templates::RequestCtx;
8use crate::AppState;
9
10/// Render a template to a String with the standard page context injected.
11///
12/// Every template can rely on `request`, `now`, `site`, and `base_url` being
13/// present; callers pass page-specific fields through `extra`.
14pub fn render_to_string(
15 state: &AppState,
16 template: &str,
17 path: &str,
18 extra: minijinja::Value,
19) -> Result<String, Response> {
20 let tmpl = state.env.get_template(template).map_err(|e| {
21 tracing::error!("template '{}': {}", template, e);
22 server_error(state, path, &format!("template '{template}': {e}"))
23 })?;
24 tmpl.render(minijinja::context! {
25 request => RequestCtx { path: path.to_string() },
26 now => minijinja::context! { year => chrono::Local::now().year() },
27 site => minijinja::context! {
28 title => &state.config.site_title,
29 base_url => &state.config.base_url,
30 },
31 base_url => &state.config.base_url,
32 ..extra
33 })
34 .map_err(|e| {
35 tracing::error!("render '{}': {}", template, e);
36 // minijinja's `Display` carries the bare error; the source chain carries
37 // the location and line span, which is what the operator needs to look at.
38 let mut detail = format!("render '{template}': {e}");
39 let mut source = std::error::Error::source(&e);
40 while let Some(s) = source {
41 detail.push_str(&format!("\n caused by: {s}"));
42 source = s.source();
43 }
44 server_error(state, path, &detail)
45 })
46}
47
48/// Convenience wrapper around `render_to_string` for HTML responses.
49pub fn render(state: &AppState, template: &str, path: &str, extra: minijinja::Value) -> Response {
50 match render_to_string(state, template, path, extra) {
51 Ok(body) => Html(body).into_response(),
52 Err(resp) => resp,
53 }
54}
55
56/// The themed 404 page with a 404 status. Used by the router fallback and by
57/// routes that look up a missing resource (e.g. an unknown ticker).
58pub fn not_found(state: &AppState) -> Response {
59 let body = render(
60 state,
61 "pages/not_found.html",
62 "/404",
63 minijinja::context! { title => "Not found" },
64 );
65 (StatusCode::NOT_FOUND, body).into_response()
66}
67
68/// The themed 500 page with the underlying error detail. Single-operator app
69/// (no public sign-up), so leaking the message back is fine.
70/// It is the whole point of the page. Falls back to plain text if the error
71/// page itself fails to render, so we never recurse.
72fn server_error(state: &AppState, path: &str, detail: &str) -> Response {
73 let ctx = minijinja::context! {
74 title => "Page failed to render",
75 path => path,
76 detail => detail,
77 };
78 let body = state
79 .env
80 .get_template("pages/error.html")
81 .and_then(|t| {
82 t.render(minijinja::context! {
83 request => RequestCtx { path: path.to_string() },
84 now => minijinja::context! { year => chrono::Local::now().year() },
85 site => minijinja::context! {
86 title => &state.config.site_title,
87 base_url => &state.config.base_url,
88 },
89 base_url => &state.config.base_url,
90 ..ctx
91 })
92 });
93 match body {
94 Ok(html) => (StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response(),
95 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, detail.to_string()).into_response(),
96 }
97}