Skip to main content

lib_humus/templating/
templating_engine.rs

1// SPDX-FileCopyrightText: 2026 Slatian <baschdel@disroot.org>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5use axum::{
6	body::Body, http::StatusCode, http::header, response::IntoResponse, response::Response,
7};
8use axum_extra::headers::HeaderValue;
9use lib_humus_configuration::ErrorCause;
10use lib_humus_configuration::read_from_toml_file;
11use log::{error, info};
12use tera::Tera;
13
14use std::cell::Cell;
15use std::collections::HashMap;
16use std::marker::PhantomData;
17use std::os::unix::ffi::OsStrExt;
18use std::path::Path;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use crate::HumusQuerySettings;
23use crate::HumusView;
24use crate::language::LanguageEngine;
25use crate::language::TextFunction;
26use crate::language::TextFunctionContext;
27use crate::templating::HumusApiFormat;
28use crate::templating::HumusFormatIdentifier;
29use crate::templating::TemplatesManifest;
30use crate::templating::{ExtraConfig, TemplatingEngineLoaderError};
31
32thread_local! (
33	/// This is for setting a truly global language for the template just before rendering, rendering is **not** allowed to cross threads.
34	static TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT: Cell<Option<TextFunctionContext>> = const { Cell::new(None) }
35);
36
37/// Implements the actual data to text conversion using the [tera] crate.
38#[derive(Debug, Clone)]
39pub struct TemplatingEngine<ApiFormat>
40where
41	ApiFormat: HumusApiFormat,
42{
43	/// An instance of the tera templating engine.
44	pub tera: HashMap<HumusFormatIdentifier, Tera>,
45
46	/// If it was possible to read any extra configuration it will be stored here.
47	pub template_config: Option<ExtraConfig>,
48
49	/// The template configuration read from the `templates.toml` manifest file.
50	pub templates_manifest: TemplatesManifest,
51
52	phantom_api_format: PhantomData<ApiFormat>,
53}
54
55impl<ApiFormat> TemplatingEngine<ApiFormat>
56where
57	ApiFormat: HumusApiFormat,
58{
59	/// Load this templating engine from a path to a templates directory and an optional path to a template configuration file (`extra.toml`).
60	///
61	/// * `tera` expects an empty [Tera] engine, that you have already added your customizations to. Use `Tera::default()` if you do not need to customize.
62	/// * When a `language_engine` is supplied this automatically sets up the `TextFunction` (`text()` inside the template) with an implicit path to pass the language through.
63	///
64	/// The following is expeced inside the path:
65	/// * `{path}/`
66	/// 	* `tera2/` - A directory containing the tera templates (This is different from previous versions of lib-humus where the templates were in the path directly)
67	/// 		* `{view_id}.{format_extension}` Each template must be named after the id of the view it represents taken from [HumusView::get_template_name] and the extension that is specified in the templates manifest.
68	/// 		* Additional templates can be specified using the `additional_templates` key in each format description.
69	/// 	* `templates.toml` - Must contain the templates manifest
70	/// 	* `extra.toml` - Optional file that contains default template configuration is loaded from when `extra_config_path` is `None`.
71	pub fn load_from_directory(
72		path: impl AsRef<Path>,
73		extra_config_path: Option<&Path>,
74		template_tera: Tera,
75		language_engine: Option<Arc<LanguageEngine>>,
76	) -> Result<Self, TemplatingEngineLoaderError> {
77		let path = path.as_ref();
78
79		let extra_config: Option<ExtraConfig> = read_from_toml_file(
80			extra_config_path
81				.map(|p| p.to_path_buf())
82				.unwrap_or_else(|| path.join("extra.toml")),
83		)
84		.or_else(|e| match &e.cause {
85			ErrorCause::FileRead { .. } => {
86				// Only fatal if the file was explicitly requested.
87				// An implicit request could also mean that
88				// the template doesn't need a config file.
89				if extra_config_path.is_some() {
90					return Err(TemplatingEngineLoaderError::ConfigurationError(e));
91				}
92				Ok(None)
93			}
94			_ => {
95				return Err(TemplatingEngineLoaderError::ConfigurationError(e));
96			}
97		})?;
98		// Read the template manifest file
99		let templates_manifest: TemplatesManifest =
100			read_from_toml_file(path.join("templates.toml"))
101				.map_err(TemplatingEngineLoaderError::TemplatesManifestError)?;
102
103		let problems = templates_manifest.find_problems::<ApiFormat>();
104		if !problems.is_empty() {
105			return Err(TemplatingEngineLoaderError::TemplatesManifestProblems(
106				problems,
107			));
108		}
109
110		let template_directory = path.join("tera2");
111
112		if !template_directory.is_dir() {
113			return Err(TemplatingEngineLoaderError::TemplateDirectoryNotFound {
114				path: template_directory,
115			});
116		}
117
118		let mut templating_engines: HashMap<HumusFormatIdentifier, Tera> = HashMap::new();
119		for (format_id, format_desc) in templates_manifest.format.iter() {
120			info!("Loading templates for format {format_id:?} ...");
121			let mut tera = template_tera.clone();
122			// Register everything before loading the templates
123			tera.register_filter("urlencode", tera_contrib::urlencode::urlencode);
124			tera.register_filter(
125				"urlencode_strict",
126				tera_contrib::urlencode::urlencode_strict,
127			);
128
129			tera.register_test("matching", tera_contrib::regex::Matching::default());
130			tera.register_filter(
131				"regex_replace",
132				tera_contrib::regex::RegexReplace::default(),
133			);
134			tera.register_filter("spaceless", tera_contrib::regex::spaceless);
135			tera.register_filter("striptags", tera_contrib::regex::striptags);
136
137			tera.register_filter("json_encode", tera_contrib::json::json_encode);
138
139			tera.register_filter("date", tera_contrib::dates::date);
140			tera.register_test("is_after", tera_contrib::dates::is_after);
141			tera.register_test("is_before", tera_contrib::dates::is_before);
142			tera.register_function("now", tera_contrib::dates::now);
143
144			tera.register_filter(
145				"filesize_format",
146				tera_contrib::filesize_format::filesize_format,
147			);
148
149			if let Some(ref language_engine) = language_engine {
150				let text_function = TextFunction::new(language_engine.clone(), |_, _| {
151					if let Some(context) = TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.take() {
152						TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(context.clone()));
153						Ok(context)
154					} else {
155						Err(tera::Error::message(
156							"No context stored while rendering template, this is a bug in lib-humus!",
157						))
158					}
159				});
160				tera.register_function("text", text_function);
161			}
162
163			let mut templates_to_load: Vec<(PathBuf, Option<String>)> = format_desc
164				.additional_templates
165				.iter()
166				.map(|file| (template_directory.join(file), Some(file.clone())))
167				.collect();
168			let full_file_extension = format!(".{}", format_desc.extension);
169			// TODO: replace unwrap with proper error
170			for dir_entry in std::fs::read_dir(&template_directory).map_err(|e| {
171				TemplatingEngineLoaderError::TemplateDirectoryIoError {
172					path: template_directory.clone(),
173					error: e,
174				}
175			})? {
176				let dir_entry = dir_entry.map_err(|e| {
177					TemplatingEngineLoaderError::TemplateDirectoryIoError {
178						path: template_directory.clone(),
179						error: e,
180					}
181				})?;
182				let path = dir_entry.path();
183				if !path.is_file() {
184					continue;
185				}
186				let name = String::from_utf8_lossy(dir_entry.file_name().as_bytes()).to_string();
187				if name.ends_with(&full_file_extension) {
188					log::debug!("Discovered template: {name:?}");
189					templates_to_load.push((path, Some(name)));
190				}
191			}
192			if let Err(e) = tera.add_template_files(templates_to_load) {
193				error!("Error Parsing Template: {e}");
194				return Err(TemplatingEngineLoaderError::TemplateParseError {
195					path: path.to_path_buf(),
196					format: *format_id,
197					tera_error: e,
198				});
199			}
200			templating_engines.insert(*format_id, tera);
201		}
202
203		Ok(Self {
204			tera: templating_engines,
205			templates_manifest,
206			template_config: extra_config,
207
208			phantom_api_format: PhantomData,
209		})
210	}
211
212	/// Takes settings and a view, converting it to a serveable response.
213	///
214	/// Example:
215	/// ```rust,ignore
216	/// async fn hello_world_handler(
217	/// 	State(arc_state): State<Arc<ServiceSharedState>>,
218	/// 	Extension(settings): Extension<QuerySettings>,
219	/// ) -> Response {
220	/// 	let state = Arc::clone(&arc_state);
221	///
222	/// 	state.templating_engine.render_view(
223	/// 		&settings,
224	/// 		View::Message{
225	/// 			title: "Hey There!".to_string(),
226	/// 			message: "You are an awesome creature!".to_string()
227	/// 		},
228	/// 	)
229	/// }
230	///
231	/// ```
232	///
233	/// This function can, depending on the provided settings and the `ApiFormat` type operate in either template mode or API mode.
234	///
235	/// It operates in API mode when the [from_name() method of the ApiFormat type][HumusApiFormat::from_name] returns a `Some`, otherwise it operates in template mode.
236	///
237	/// If the requested format id neither resolves to a valid API nor an existing template a status code 400 and a response body starting with `(unknown format)` followed by a human readable error message is generated as the response.
238	///
239	/// In template mode the following happens:
240	/// * [status code][HumusView::get_status_code] is fetched from the `view`.
241	/// * Template name and MimeType are fetched.
242	/// * The Template context is populated with metadata from the `view` and
243	///   [template_config][Self::template_config]. (Documentation linked below)
244	/// * The [initalize_template_context() hook method][HumusView::initalize_template_context]
245	///   is called on `view`.
246	/// * The template gets rendered, resulting in further processing or
247	///   an error response.
248	/// * The response is constructed using the MimeType from earlier and
249	///   the text from the template.
250	/// * The [update_response() hook method][HumusView::update_response] is called on `view`
251	/// * If the status code of the response from `get_api_response()` is 200
252	///   it will be replaced with the result of the [`get_status_code()` method
253	///   of the `view`][HumusView::get_status_code].
254	///
255	/// See also the [writing templates documentation][crate::doc::writing_templates].
256	///
257	/// In API mode the following happens:
258	/// * [status code][HumusView::get_status_code] is fetched from the `view`.
259	/// * The [`into_api_response()` method of the `view`][HumusView::into_api_response]
260	///   is used to generate an HTTP response.
261	/// * If the status code of the response from `get_api_response()` is 200
262	///   it will be replaced with the result of the [`get_status_code()` method
263	///   of the `view`][HumusView::get_status_code].
264	///
265	pub fn render_view<S: HumusQuerySettings>(
266		&self,
267		settings: &S,
268		view: impl HumusView<S, ApiFormat>,
269		language_engine: &LanguageEngine,
270	) -> Response {
271		let format = settings.get_format();
272		let status_code = view.get_status_code(settings);
273
274		let mut response = if let Some(api_format) = ApiFormat::from_name(&format) {
275			view.into_api_response(settings, api_format)
276		} else if let Some(format_description) = self.templates_manifest.format.get(&format) {
277			let template_name = view.get_template_name();
278			let mime_type = &format_description.media_type;
279
280			let mut context = tera::Context::new();
281			context.insert("view", &template_name);
282			//intented for shared macros
283			context.insert("format", &format);
284			let language = settings
285				.get_preferred_language()
286				.unwrap_or_else(|| language_engine.language_manifest().default_language.clone());
287			context.insert("lang", &language);
288			context.insert("language_manifest", language_engine.language_manifest());
289			context.insert("media_type", &mime_type.to_string());
290			context.insert("http_status", &status_code.as_u16());
291			context.insert("data", &view);
292			context.insert("extra", &self.template_config);
293			view.initalize_template_context(&mut context, settings);
294
295			// This is okay because tera.render does **not** cross threads and there is no async involved.
296			TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(TextFunctionContext {
297				language: Some(language),
298				escape_function: tera::escape_html, //TODO: make this configurable
299				safe_suffix: language_engine
300					.language_manifest()
301					.get_safe_suffix_for_format(format),
302			}));
303			let Some(tera) = self.tera.get(&format) else {
304				error!("Problem finding tera instance for format {format:?}");
305				return (
306					StatusCode::INTERNAL_SERVER_ERROR,
307					format!("Unknown template output format: {format:?}"),
308				)
309					.into_response();
310			};
311			match tera.render(
312				&format!("{template_name}.{}", format_description.extension),
313				&context,
314			) {
315				Ok(text) => {
316					let response = (
317						[(
318							header::CONTENT_TYPE,
319							HeaderValue::from_str(mime_type.as_ref())
320								.expect("MimeType should always be a valid header value."),
321						)],
322						Into::<Body>::into(text),
323					)
324						.into_response();
325					view.update_response(response, settings)
326				}
327				Err(e) => {
328					error!(
329						"There was an error while rendering template {template_name}:\n{}",
330						render_tera_error(e)
331					);
332					(
333						StatusCode::INTERNAL_SERVER_ERROR,
334						format!("Template error in {template_name}, contact owner or see logs.\n"),
335					)
336						.into_response()
337				}
338			}
339		// Handle when the format string resolves to neither an API nor a template.
340		} else {
341			(
342				StatusCode::BAD_REQUEST,
343				"(unknown format) You have requested an unknown template or API format.\n"
344					.to_string(),
345			)
346				.into_response()
347		};
348
349		// Everything went well and nobody did the following work for us.
350		if response.status() == StatusCode::OK {
351			// Set status code
352			*response.status_mut() = status_code;
353		}
354
355		// return response
356		response
357	}
358}
359
360fn render_tera_error(error: tera::Error) -> String {
361	let mut text = error.to_string();
362	let mut error: &(dyn core::error::Error + 'static) = &error;
363	while let Some(source) = error.source() {
364		text = format!("{text}\n* {source}");
365		error = source;
366	}
367	text
368}