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			if let Some(ref language_engine) = language_engine {
140				let text_function = TextFunction::new(language_engine.clone(), |_, _| {
141					if let Some(context) = TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.take() {
142						TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(context.clone()));
143						Ok(context)
144					} else {
145						Err(tera::Error::message(
146							"No context stored while rendering template, this is a bug in lib-humus!",
147						))
148					}
149				});
150				tera.register_function("text", text_function);
151			}
152
153			let mut templates_to_load: Vec<(PathBuf, Option<String>)> = format_desc
154				.additional_templates
155				.iter()
156				.map(|file| (template_directory.join(file), Some(file.clone())))
157				.collect();
158			let full_file_extension = format!(".{}", format_desc.extension);
159			// TODO: replace unwrap with proper error
160			for dir_entry in std::fs::read_dir(&template_directory).map_err(|e| {
161				TemplatingEngineLoaderError::TemplateDirectoryIoError {
162					path: template_directory.clone(),
163					error: e,
164				}
165			})? {
166				let dir_entry = dir_entry.map_err(|e| {
167					TemplatingEngineLoaderError::TemplateDirectoryIoError {
168						path: template_directory.clone(),
169						error: e,
170					}
171				})?;
172				let path = dir_entry.path();
173				if !path.is_file() {
174					continue;
175				}
176				let name = String::from_utf8_lossy(dir_entry.file_name().as_bytes()).to_string();
177				if name.ends_with(&full_file_extension) {
178					log::debug!("Discovered template: {name:?}");
179					templates_to_load.push((path, Some(name)));
180				}
181			}
182			if let Err(e) = tera.add_template_files(templates_to_load) {
183				error!("Error Parsing Template: {e}");
184				return Err(TemplatingEngineLoaderError::TemplateParseError {
185					path: path.to_path_buf(),
186					format: *format_id,
187					tera_error: e,
188				});
189			}
190			templating_engines.insert(*format_id, tera);
191		}
192
193		Ok(Self {
194			tera: templating_engines,
195			templates_manifest,
196			template_config: extra_config,
197
198			phantom_api_format: PhantomData,
199		})
200	}
201
202	/// Takes settings and a view, converting it to a serveable response.
203	///
204	/// Example:
205	/// ```rust,ignore
206	/// async fn hello_world_handler(
207	/// 	State(arc_state): State<Arc<ServiceSharedState>>,
208	/// 	Extension(settings): Extension<QuerySettings>,
209	/// ) -> Response {
210	/// 	let state = Arc::clone(&arc_state);
211	///
212	/// 	state.templating_engine.render_view(
213	/// 		&settings,
214	/// 		View::Message{
215	/// 			title: "Hey There!".to_string(),
216	/// 			message: "You are an awesome creature!".to_string()
217	/// 		},
218	/// 	)
219	/// }
220	///
221	/// ```
222	///
223	/// This function can, depending on the provided settings and the `ApiFormat` type operate in either template mode or API mode.
224	///
225	/// 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.
226	///
227	/// 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.
228	///
229	/// In template mode the following happens:
230	/// * [status code][HumusView::get_status_code] is fetched from the `view`.
231	/// * Template name and MimeType are fetched.
232	/// * The Template context is populated with metadata from the `view` and
233	///   [template_config][Self::template_config]. (Documentation linked below)
234	/// * The [initalize_template_context() hook method][HumusView::initalize_template_context]
235	///   is called on `view`.
236	/// * The template gets rendered, resulting in further processing or
237	///   an error response.
238	/// * The response is constructed using the MimeType from earlier and
239	///   the text from the template.
240	/// * The [update_response() hook method][HumusView::update_response] is called on `view`
241	/// * If the status code of the response from `get_api_response()` is 200
242	///   it will be replaced with the result of the [`get_status_code()` method
243	///   of the `view`][HumusView::get_status_code].
244	///
245	/// See also the [writing templates documentation][crate::doc::writing_templates].
246	///
247	/// In API mode the following happens:
248	/// * [status code][HumusView::get_status_code] is fetched from the `view`.
249	/// * The [`into_api_response()` method of the `view`][HumusView::into_api_response]
250	///   is used to generate an HTTP response.
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	pub fn render_view<S: HumusQuerySettings>(
256		&self,
257		settings: &S,
258		view: impl HumusView<S, ApiFormat>,
259		language_engine: &LanguageEngine,
260	) -> Response {
261		let format = settings.get_format();
262		let status_code = view.get_status_code(settings);
263
264		let mut response = if let Some(api_format) = ApiFormat::from_name(&format) {
265			view.into_api_response(settings, api_format)
266		} else if let Some(format_description) = self.templates_manifest.format.get(&format) {
267			let template_name = view.get_template_name();
268			let mime_type = &format_description.media_type;
269
270			let mut context = tera::Context::new();
271			context.insert("view", &template_name);
272			//intented for shared macros
273			context.insert("format", &format);
274			let language = settings
275				.get_preferred_language()
276				.unwrap_or_else(|| language_engine.language_manifest().default_language.clone());
277			context.insert("lang", &language);
278			context.insert("language_manifest", language_engine.language_manifest());
279			context.insert("media_type", &mime_type.to_string());
280			context.insert("http_status", &status_code.as_u16());
281			context.insert("data", &view);
282			context.insert("extra", &self.template_config);
283			view.initalize_template_context(&mut context, settings);
284
285			// This is okay because tera.render does **not** cross threads and there is no async involved.
286			TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(TextFunctionContext {
287				language: Some(language),
288				escape_function: tera::escape_html, //TODO: make this configurable
289				safe_suffix: language_engine
290					.language_manifest()
291					.get_safe_suffix_for_format(format),
292			}));
293			let Some(tera) = self.tera.get(&format) else {
294				error!("Problem finding tera instance for format {format:?}");
295				return (
296					StatusCode::INTERNAL_SERVER_ERROR,
297					format!("Unknown template output format: {format:?}"),
298				)
299					.into_response();
300			};
301			match tera.render(
302				&format!("{template_name}.{}", format_description.extension),
303				&context,
304			) {
305				Ok(text) => {
306					let response = (
307						[(
308							header::CONTENT_TYPE,
309							HeaderValue::from_str(mime_type.as_ref())
310								.expect("MimeType should always be a valid header value."),
311						)],
312						Into::<Body>::into(text),
313					)
314						.into_response();
315					view.update_response(response, settings)
316				}
317				Err(e) => {
318					error!(
319						"There was an error while rendering template {template_name}:\n{}",
320						render_tera_error(e)
321					);
322					(
323						StatusCode::INTERNAL_SERVER_ERROR,
324						format!("Template error in {template_name}, contact owner or see logs.\n"),
325					)
326						.into_response()
327				}
328			}
329		// Handle when the format string resolves to neither an API nor a template.
330		} else {
331			(
332				StatusCode::BAD_REQUEST,
333				"(unknown format) You have requested an unknown template or API format.\n"
334					.to_string(),
335			)
336				.into_response()
337		};
338
339		// Everything went well and nobody did the following work for us.
340		if response.status() == StatusCode::OK {
341			// Set status code
342			*response.status_mut() = status_code;
343		}
344
345		// return response
346		response
347	}
348}
349
350fn render_tera_error(error: tera::Error) -> String {
351	let mut text = error.to_string();
352	let mut error: &(dyn core::error::Error + 'static) = &error;
353	while let Some(source) = error.source() {
354		text = format!("{text}\n* {source}");
355		error = source;
356	}
357	text
358}