tag2upload_service_manager/
ui_render.rs

1//! support for defining routes - mostly, Tera templates
2//!
3//! We embed the templates in the binary because that's simple to deploy,
4//! and avoids entanglement of the build system (and asset configuration).
5
6use crate::prelude::*;
7
8pub use tera::Tera;
9pub use rocket::http::ContentType;
10
11use rocket::request::{FromRequest, Outcome, Request};
12
13pub type RenderedTemplate = Result<(ContentType, String), WebError>;
14
15pub struct EmbeddedTemplateIsJustAPart;
16
17pub struct EmbeddedTemplate {
18    pub name: &'static str,
19    pub contents: &'static str,
20    pub is_part: Option<EmbeddedTemplateIsJustAPart>,
21}
22
23define_derive_deftly! {
24    FromRequest for struct, expect items:
25
26    #[async_trait]
27    impl<'r> FromRequest<'r> for $ttype {
28        type Error = String;
29
30        async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
31            use Outcome as O;
32
33            O::Success($tname { $(
34                $fname: match FromRequest::from_request(req).await {
35                    O::Success(y) => y,
36                    O::Forward(x) => return O::Forward(x),
37                    O::Error(e)   => return O::Error(e),
38                },
39            ) })
40        }
41    }
42}
43
44#[derive(Deftly)]
45#[derive_deftly(FromRequest)]
46pub struct UiReqInfo {
47    pub vhost: ui_vhost::UiResult,
48    pub send_format: SendFormat,
49}
50
51pub enum SendFormat {
52    Html,
53    Json,
54}
55
56#[async_trait]
57impl<'r> FromRequest<'r> for SendFormat {
58    type Error = String;
59
60    async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
61        FromRequest::from_request(req).await.map(|a: &rocket::http::Accept| {
62            if a.preferred().is_json() {
63                SendFormat::Json
64            } else {
65                SendFormat::Html
66            }
67        }).map_error(|(_, inf)| match inf {})
68    }
69}
70
71macro_rules! load_template_parts { { $( $name:literal ),* $(,)? } => { $(
72    inventory::submit!(EmbeddedTemplate {
73        name: $name,
74        contents: include_str!(concat!("../ui/", $name)),
75        is_part: Some(EmbeddedTemplateIsJustAPart),
76    });
77)* } }
78
79/// Return a `Template` from a build-in template
80///
81// We can't test this because it would try to read some "FILENAME.html".
82/// ```ignore
83/// template! {
84///     "NAME.html";
85///   [ content_type: CONTENT_TYPE; ]
86///     CONTEXT
87/// }
88/// ```
89///
90/// Expands to an expression returning a value of type [`RenderedTemplate`].
91///
92/// `NAME.html` corresponds to the file `ui/NAME.html`.
93/// (It doesn't need to end in `.html`.
94/// But, the extension influences tera's autoescaping.)
95/// 
96/// `{ CONTEXT_FIELD_NAME: CONTEXT_VALUE, .. }`
97/// are values for the template.
98/// Each `CONTEXT_VALUE` must be [`Serialize`].
99///
100/// `CONTEXT` is either `(EXPR)`, where `EXPR` is of type `tera::Context`,
101/// or `{ CONTEXT_FIELD_NAME [: CONTEXT_VALUE ], .. }`
102/// which is fed to `tera_context!`.
103#[macro_export]
104macro_rules! template { {
105    $name:literal, $req_info:expr;
106 $( content_type: $ctype:expr; )?
107    { $($context_always:tt)* }
108 $( { $($context_html_only:tt)* } )?
109} => {
110    $crate::template! {
111        $name, $req_info;
112     $( $content_type: $ctype )?
113        ($crate::tera_context!( $( $context_always )* ),
114         $crate::tera_context!( $( $( $context_html_only )* )? ))
115    }
116};
117{
118    $name:literal, $req_info:expr;
119 $( content_type: $ctype:expr; )?
120    ( $context_always:expr, $context_html_only:expr )
121    $(,)?
122} => {
123    {
124        use $crate::ui_render::*;
125
126        let _: $crate::ui_vhost::IsUi =
127            $req_info.vhost.check(WE::PageNotFoundHere)?;
128
129        match $req_info.send_format {
130            SendFormat::Html => {}
131            SendFormat::Json => {
132                let json = $context_always.into_json();
133                let json = serde_json::to_string(&json)
134                    .with_context(|| format!("render json for {}", $name))
135                    .map_err(IE::new_quiet)
136                    .map_err(WebError::from)?;
137                return Ok((ContentType::JSON, json));
138            }
139        }
140        let mut context = $context_always;
141        context.extend($context_html_only);
142
143        #[allow(dead_code)]
144        let content_type = $name.rsplit_once('.')
145            .and_then(|(_, ext)| ContentType::from_extension(ext));
146      $(
147        let content_type: ContentType = Some($ctype);
148      )?
149        let content_type = content_type
150            .ok_or_else(|| WebError::from(IE::new_quiet(anyhow!(
151                "cannot deduce Content-Type for template {}", $name
152            ))))?;
153
154        let s = $crate::template_html_unchecked! { $name, context }?;
155        Ok((content_type, s))
156    }
157} }
158
159/// Render an HTML page template without any checks
160///
161///  * Does not check (via `IsUi`) that we are on the right vhost
162///  * Doesn't check for Accept JSON (so always renders template)
163///  * Doesn't calculate the Content-Type
164#[macro_export]
165macro_rules! template_html_unchecked { {
166    $name:literal, $context:expr $(,)?
167} => {
168    {
169        let gl = globals();
170
171        inventory::submit!(EmbeddedTemplate {
172            name: $name,
173            contents: include_str!(concat!("../ui/", $name)),
174            is_part: None,
175        });
176
177        #[cfg(test)]
178        gl.t_note_template_rendered($name);
179
180        let s = gl.tera.render(
181            $name,
182            &$context,
183        )
184            .context($name)
185            .map_err(IE::new_quiet)
186            .map_err(WebError::from)?;
187
188        Ok::<_, WebError>(s)
189    }
190} }
191
192/// Invoke an HTML page template
193///
194/// Like `template!` but only takes the `{ }` context syntax,
195/// and additionally includes a navbar as `navbar` in the context,
196/// based on `$name` and
197/// the prevailing constant `NAVBAR` (from the calling scope).
198#[macro_export]
199macro_rules! template_page { {
200    $name:literal, $req_info:expr;
201    { $($context:tt)* }
202} => {
203    $crate::template! {
204        $name, $req_info;
205        {
206            $($context)*
207            t2usm_version: &globals().version_info,
208        }
209        {
210            navbar: $crate::ui_render::make_navbar_for($name, NAVBAR),
211            t2usm_version: &globals().version_info.to_string(),
212        }
213    }
214} }
215
216/// Reimplementation, roughly, of `rocket_dyn_templates::context!`
217///
218/// ```
219/// # use tag2upload_service_manager::tera_context;
220/// # let VALUE = "12";
221/// # let NAME_SAME_AS_VALUE = "42";
222/// let _: tera::Context = tera_context! {
223///     NAME: VALUE,
224///     NAME_SAME_AS_VALUE,
225/// };
226/// ```
227#[macro_export]
228macro_rules! tera_context { {
229    $( $k:ident $( : $v:expr )? ),* $(,)?
230} => { {
231    #[allow(unused_mut)]
232    let mut context = tera::Context::new();
233    $( $crate::tera_context!( @ context $k $( $v )? ); )*
234    context
235} }; {
236    @ $context:ident $k:ident
237} => {
238    $crate::tera_context!(@ $context $k $k)
239}; {
240    @ $context:ident $k:ident $v:expr
241} => {
242    $context.insert(stringify!($k), &$v)
243} }
244
245pub fn tera_templates(config: &Config) -> Result<Tera, StartupError> {
246    if let Some(dir) = &config.files.template_dir {
247        let glob = format!("{dir}/*[^~#]");
248        debug!(?glob, "loading tera templates");
249        Tera::new(&glob)
250            .context(glob)
251            .map_err(StartupError::Templates)
252    } else {
253        embedded_tera_templates()
254    }
255}
256
257pub fn embedded_tera_templates() -> Result<Tera, StartupError> {
258    let mut tera = Tera::default();
259
260    tera.add_raw_templates(
261        inventory::iter::<EmbeddedTemplate>().map(
262            |EmbeddedTemplate { name, contents, is_part: _ }| {
263                trace!(name, "loading builtin templat");
264                (name, contents)
265            }
266        )
267    ).into_internal("failed to initialise templating")?;
268
269    Ok(tera)
270}
271
272/// title text; template name; route path
273pub type NavbarEntry<'s> = (&'s str, &'s str, &'s str);
274
275pub fn make_navbar_for(for_templ: &str, navbar: &[NavbarEntry]) -> String {
276    let mut out = String::new();
277    let mut delim = "";
278    for (title, templ, path) in navbar.iter().copied() {
279        write_string!(out, "{}", mem::replace(&mut delim, " | "));
280        let post = if templ != for_templ {
281            write_string!(out, "<a href={path}>");
282            "</a>"
283        } else {
284            ""
285        };
286        write_string!(out, "<b>{title}</b>{post}");
287    }
288    out
289}
290
291inventory::collect!(EmbeddedTemplate);
292
293#[test]
294fn check_embedded_tera_templates() {
295    let t: Tera = embedded_tera_templates().expect("bad templates?");
296    println!("{t:?}");
297}