Skip to main content

bobashare_web/views/
mod.rs

1//! Frontend views (as opposed to REST API)
2
3use std::path::Path;
4
5use askama::Template;
6use axum::{
7    http,
8    response::{Html, IntoResponse, Response},
9    routing::get,
10    Router,
11};
12use chrono::TimeDelta;
13use http::header::{HeaderName, HeaderValue};
14use hyper::StatusCode;
15use tower::ServiceBuilder;
16use tower_http::set_header::SetResponseHeaderLayer;
17use tracing::{event, Level};
18use url::Url;
19
20use crate::AppState;
21
22pub mod about;
23pub mod display;
24pub mod filters;
25pub mod upload;
26
27mod prelude {
28    pub use super::CurrentNavigation;
29}
30
31// 's is for &AppState
32// TODO: should this be Copy
33#[derive(Debug, Clone)]
34pub struct TemplateState<'s> {
35    version: &'static str,
36    instance_name: &'s str,
37    base_url: &'s Url,
38    max_file_size: u64,
39    max_expiry: Option<TimeDelta>,
40    extra_footer_text: Option<&'s str>,
41    about_page: Option<&'s Path>,
42
43    // None if the current page is not a navbar item
44    current_navigation: Option<CurrentNavigation>,
45}
46impl<'s> From<&'s AppState> for TemplateState<'s> {
47    fn from(state: &'s AppState) -> Self {
48        Self {
49            version: env!("CARGO_PKG_VERSION"),
50            instance_name: &state.instance_name,
51            base_url: &state.base_url,
52            max_file_size: state.max_file_size,
53            max_expiry: state.max_expiry,
54            extra_footer_text: state.extra_footer_text.as_deref(),
55            about_page: state.about_page.as_deref(),
56            current_navigation: None, // will be set to Some in individual handlers
57        }
58    }
59}
60
61// which page is current navigated to, for navbar formatting
62#[derive(Debug, Clone, Copy)]
63#[non_exhaustive]
64pub enum CurrentNavigation {
65    Upload,
66    Paste,
67    About,
68}
69
70#[derive(Template)]
71#[template(path = "error.html.jinja", blocks = ["title"])]
72pub struct ErrorTemplate<'s> {
73    pub state: TemplateState<'s>,
74    pub code: StatusCode,
75    pub message: String,
76}
77
78pub struct ErrorResponse(Response);
79impl From<ErrorTemplate<'_>> for ErrorResponse {
80    fn from(tmpl: ErrorTemplate) -> Self {
81        let error_msg = &tmpl.message;
82        match tmpl.render() {
83            Ok(s) => {
84                let status_num = tmpl.code.as_u16();
85                if tmpl.code.is_server_error() {
86                    event!(Level::ERROR, status = status_num, error_msg);
87                } else if tmpl.code.is_client_error() {
88                    event!(Level::WARN, status = status_num, error_msg);
89                } else {
90                    event!(Level::INFO, status = status_num, error_msg);
91                }
92                Self((tmpl.code, Html(s)).into_response())
93            }
94            Err(e) => {
95                let status = tmpl.code.as_u16();
96                event!(Level::ERROR, status, error_msg, render_error = ?e, "error rendering error page template, so HTTP 500 returned:");
97                Self(
98                    (
99                        StatusCode::INTERNAL_SERVER_ERROR,
100                        format!("internal error rendering error page template: {:?}", e),
101                    )
102                        .into_response(),
103                )
104            }
105        }
106    }
107}
108impl From<askama::Error> for ErrorResponse {
109    fn from(err: askama::Error) -> Self {
110        event!(Level::ERROR, render_error = ?err, "error rendering template");
111        Self(
112            (
113                StatusCode::INTERNAL_SERVER_ERROR,
114                format!("internal error rendering template: {:?}", err),
115            )
116                .into_response(),
117        )
118    }
119}
120impl IntoResponse for ErrorResponse {
121    fn into_response(self) -> axum::response::Response {
122        self.0
123    }
124}
125
126// The ErrorResponse is actually the same size as Response, and there's no
127// consumer of this api that would be "infected" by this large error value, so
128// this lint isn't necessary.
129//
130// See https://github.com/rust-lang/rust-clippy/issues/10211
131//
132// This is changed from expect to allow as the false positive doesn't appear on
133// arm targets.
134#[allow(clippy::result_large_err)]
135pub(crate) fn render_template<T: askama::Template>(tmpl: T) -> Result<Response, ErrorResponse> {
136    let rendered = tmpl.render()?;
137    Ok((StatusCode::OK, Html(rendered)).into_response())
138}
139
140pub fn router() -> Router<&'static AppState> {
141    let x_robots_tag_no_index = SetResponseHeaderLayer::overriding(
142        HeaderName::from_static("x-robots-tag"),
143        HeaderValue::from_static("noindex"),
144    );
145    let content_security_policy = SetResponseHeaderLayer::overriding(
146        http::header::CONTENT_SECURITY_POLICY,
147        HeaderValue::from_static("default-src 'self'; img-src 'self' data:;"),
148    );
149    let display_pages_headers = ServiceBuilder::new()
150        .layer(x_robots_tag_no_index.clone())
151        .layer(content_security_policy.clone());
152
153    let display_pages_routes = Router::new()
154        .route("/{id}", get(display::display))
155        .route("/raw/{id}", get(display::raw))
156        .layer(display_pages_headers);
157
158    Router::new()
159        .route("/", get(upload::upload))
160        .route("/paste/", get(upload::paste))
161        .route("/about/", get(about::about))
162        .merge(display_pages_routes)
163}