repos
3.5 KB raw
use axum::{
    http::StatusCode,
    response::{Html, IntoResponse, Response},
};
use chrono::Datelike;

use crate::templates::RequestCtx;
use crate::AppState;

/// Render a template to a String with the standard page context injected.
///
/// Every template can rely on `request`, `now`, `site`, and `base_url` being
/// present; callers pass page-specific fields through `extra`.
pub fn render_to_string(
    state: &AppState,
    template: &str,
    path: &str,
    extra: minijinja::Value,
) -> Result<String, Response> {
    let tmpl = state.env.get_template(template).map_err(|e| {
        tracing::error!("template '{}': {}", template, e);
        server_error(state, path, &format!("template '{template}': {e}"))
    })?;
    tmpl.render(minijinja::context! {
        request => RequestCtx { path: path.to_string() },
        now => minijinja::context! { year => chrono::Local::now().year() },
        site => minijinja::context! {
            title => &state.config.site_title,
            base_url => &state.config.base_url,
        },
        base_url => &state.config.base_url,
        ..extra
    })
    .map_err(|e| {
        tracing::error!("render '{}': {}", template, e);
        // minijinja's `Display` carries the bare error; the source chain carries
        // the location and line span, which is what the operator needs to look at.
        let mut detail = format!("render '{template}': {e}");
        let mut source = std::error::Error::source(&e);
        while let Some(s) = source {
            detail.push_str(&format!("\n  caused by: {s}"));
            source = s.source();
        }
        server_error(state, path, &detail)
    })
}

/// Convenience wrapper around `render_to_string` for HTML responses.
pub fn render(state: &AppState, template: &str, path: &str, extra: minijinja::Value) -> Response {
    match render_to_string(state, template, path, extra) {
        Ok(body) => Html(body).into_response(),
        Err(resp) => resp,
    }
}

/// The themed 404 page with a 404 status. Used by the router fallback and by
/// routes that look up a missing resource (e.g. an unknown ticker).
pub fn not_found(state: &AppState) -> Response {
    let body = render(
        state,
        "pages/not_found.html",
        "/404",
        minijinja::context! { title => "Not found" },
    );
    (StatusCode::NOT_FOUND, body).into_response()
}

/// The themed 500 page with the underlying error detail. Single-operator app
/// (no public sign-up), so leaking the message back is fine.
/// It is the whole point of the page. Falls back to plain text if the error
/// page itself fails to render, so we never recurse.
fn server_error(state: &AppState, path: &str, detail: &str) -> Response {
    let ctx = minijinja::context! {
        title => "Page failed to render",
        path => path,
        detail => detail,
    };
    let body = state
        .env
        .get_template("pages/error.html")
        .and_then(|t| {
            t.render(minijinja::context! {
                request => RequestCtx { path: path.to_string() },
                now => minijinja::context! { year => chrono::Local::now().year() },
                site => minijinja::context! {
                    title => &state.config.site_title,
                    base_url => &state.config.base_url,
                },
                base_url => &state.config.base_url,
                ..ctx
            })
        });
    match body {
        Ok(html) => (StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response(),
        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, detail.to_string()).into_response(),
    }
}