lib-humus 0.6.0

Helps creating configurable frontends for humans and computers using axum, Tera and toml.
Documentation
// SPDX-FileCopyrightText: 2026 Slatian <baschdel@disroot.org>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

use axum::{
	body::Body, http::StatusCode, http::header, response::IntoResponse, response::Response,
};
use axum_extra::headers::HeaderValue;
use lib_humus_configuration::ErrorCause;
use lib_humus_configuration::read_from_toml_file;
use log::{error, info};
use tera::Tera;

use std::cell::Cell;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use crate::HumusQuerySettings;
use crate::HumusView;
use crate::language::LanguageEngine;
use crate::language::TextFunction;
use crate::language::TextFunctionContext;
use crate::templating::HumusApiFormat;
use crate::templating::HumusFormatIdentifier;
use crate::templating::TemplatesManifest;
use crate::templating::{ExtraConfig, TemplatingEngineLoaderError};

thread_local! (
	/// This is for setting a truly global language for the template just before rendering, rendering is **not** allowed to cross threads.
	static TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT: Cell<Option<TextFunctionContext>> = const { Cell::new(None) }
);

/// Implements the actual data to text conversion using the [tera] crate.
#[derive(Debug, Clone)]
pub struct TemplatingEngine<ApiFormat>
where
	ApiFormat: HumusApiFormat,
{
	/// An instance of the tera templating engine.
	pub tera: HashMap<HumusFormatIdentifier, Tera>,

	/// If it was possible to read any extra configuration it will be stored here.
	pub template_config: Option<ExtraConfig>,

	/// The template configuration read from the `templates.toml` manifest file.
	pub templates_manifest: TemplatesManifest,

	phantom_api_format: PhantomData<ApiFormat>,
}

impl<ApiFormat> TemplatingEngine<ApiFormat>
where
	ApiFormat: HumusApiFormat,
{
	/// Load this templating engine from a path to a templates directory and an optional path to a template configuration file (`extra.toml`).
	///
	/// * `tera` expects an empty [Tera] engine, that you have already added your customizations to. Use `Tera::default()` if you do not need to customize.
	/// * 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.
	///
	/// The following is expeced inside the path:
	/// * `{path}/`
	/// 	* `tera2/` - A directory containing the tera templates (This is different from previous versions of lib-humus where the templates were in the path directly)
	/// 		* `{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.
	/// 		* Additional templates can be specified using the `additional_templates` key in each format description.
	/// 	* `templates.toml` - Must contain the templates manifest
	/// 	* `extra.toml` - Optional file that contains default template configuration is loaded from when `extra_config_path` is `None`.
	pub fn load_from_directory(
		path: impl AsRef<Path>,
		extra_config_path: Option<&Path>,
		template_tera: Tera,
		language_engine: Option<Arc<LanguageEngine>>,
	) -> Result<Self, TemplatingEngineLoaderError> {
		let path = path.as_ref();

		let extra_config: Option<ExtraConfig> = read_from_toml_file(
			extra_config_path
				.map(|p| p.to_path_buf())
				.unwrap_or_else(|| path.join("extra.toml")),
		)
		.or_else(|e| match &e.cause {
			ErrorCause::FileRead { .. } => {
				// Only fatal if the file was explicitly requested.
				// An implicit request could also mean that
				// the template doesn't need a config file.
				if extra_config_path.is_some() {
					return Err(TemplatingEngineLoaderError::ConfigurationError(e));
				}
				Ok(None)
			}
			_ => {
				return Err(TemplatingEngineLoaderError::ConfigurationError(e));
			}
		})?;
		// Read the template manifest file
		let templates_manifest: TemplatesManifest =
			read_from_toml_file(path.join("templates.toml"))
				.map_err(TemplatingEngineLoaderError::TemplatesManifestError)?;

		let problems = templates_manifest.find_problems::<ApiFormat>();
		if !problems.is_empty() {
			return Err(TemplatingEngineLoaderError::TemplatesManifestProblems(
				problems,
			));
		}

		let template_directory = path.join("tera2");

		if !template_directory.is_dir() {
			return Err(TemplatingEngineLoaderError::TemplateDirectoryNotFound {
				path: template_directory,
			});
		}

		let mut templating_engines: HashMap<HumusFormatIdentifier, Tera> = HashMap::new();
		for (format_id, format_desc) in templates_manifest.format.iter() {
			info!("Loading templates for format {format_id:?} ...");
			let mut tera = template_tera.clone();
			// Register everything before loading the templates
			tera.register_filter("urlencode", tera_contrib::urlencode::urlencode);
			tera.register_filter(
				"urlencode_strict",
				tera_contrib::urlencode::urlencode_strict,
			);

			tera.register_test("matching", tera_contrib::regex::Matching::default());
			tera.register_filter(
				"regex_replace",
				tera_contrib::regex::RegexReplace::default(),
			);
			tera.register_filter("spaceless", tera_contrib::regex::spaceless);
			tera.register_filter("striptags", tera_contrib::regex::striptags);

			tera.register_filter("json_encode", tera_contrib::json::json_encode);

			if let Some(ref language_engine) = language_engine {
				let text_function = TextFunction::new(language_engine.clone(), |_, _| {
					if let Some(context) = TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.take() {
						TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(context.clone()));
						Ok(context)
					} else {
						Err(tera::Error::message(
							"No context stored while rendering template, this is a bug in lib-humus!",
						))
					}
				});
				tera.register_function("text", text_function);
			}

			let mut templates_to_load: Vec<(PathBuf, Option<String>)> = format_desc
				.additional_templates
				.iter()
				.map(|file| (template_directory.join(file), Some(file.clone())))
				.collect();
			let full_file_extension = format!(".{}", format_desc.extension);
			// TODO: replace unwrap with proper error
			for dir_entry in std::fs::read_dir(&template_directory).map_err(|e| {
				TemplatingEngineLoaderError::TemplateDirectoryIoError {
					path: template_directory.clone(),
					error: e,
				}
			})? {
				let dir_entry = dir_entry.map_err(|e| {
					TemplatingEngineLoaderError::TemplateDirectoryIoError {
						path: template_directory.clone(),
						error: e,
					}
				})?;
				let path = dir_entry.path();
				if !path.is_file() {
					continue;
				}
				let name = String::from_utf8_lossy(dir_entry.file_name().as_bytes()).to_string();
				if name.ends_with(&full_file_extension) {
					log::debug!("Discovered template: {name:?}");
					templates_to_load.push((path, Some(name)));
				}
			}
			if let Err(e) = tera.add_template_files(templates_to_load) {
				error!("Error Parsing Template: {e}");
				return Err(TemplatingEngineLoaderError::TemplateParseError {
					path: path.to_path_buf(),
					format: *format_id,
					tera_error: e,
				});
			}
			templating_engines.insert(*format_id, tera);
		}

		Ok(Self {
			tera: templating_engines,
			templates_manifest,
			template_config: extra_config,

			phantom_api_format: PhantomData,
		})
	}

	/// Takes settings and a view, converting it to a serveable response.
	///
	/// Example:
	/// ```rust,ignore
	/// async fn hello_world_handler(
	/// 	State(arc_state): State<Arc<ServiceSharedState>>,
	/// 	Extension(settings): Extension<QuerySettings>,
	/// ) -> Response {
	/// 	let state = Arc::clone(&arc_state);
	///
	/// 	state.templating_engine.render_view(
	/// 		&settings,
	/// 		View::Message{
	/// 			title: "Hey There!".to_string(),
	/// 			message: "You are an awesome creature!".to_string()
	/// 		},
	/// 	)
	/// }
	///
	/// ```
	///
	/// This function can, depending on the provided settings and the `ApiFormat` type operate in either template mode or API mode.
	///
	/// 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.
	///
	/// 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.
	///
	/// In template mode the following happens:
	/// * [status code][HumusView::get_status_code] is fetched from the `view`.
	/// * Template name and MimeType are fetched.
	/// * The Template context is populated with metadata from the `view` and
	///   [template_config][Self::template_config]. (Documentation linked below)
	/// * The [initalize_template_context() hook method][HumusView::initalize_template_context]
	///   is called on `view`.
	/// * The template gets rendered, resulting in further processing or
	///   an error response.
	/// * The response is constructed using the MimeType from earlier and
	///   the text from the template.
	/// * The [update_response() hook method][HumusView::update_response] is called on `view`
	/// * If the status code of the response from `get_api_response()` is 200
	///   it will be replaced with the result of the [`get_status_code()` method
	///   of the `view`][HumusView::get_status_code].
	///
	/// See also the [writing templates documentation][crate::doc::writing_templates].
	///
	/// In API mode the following happens:
	/// * [status code][HumusView::get_status_code] is fetched from the `view`.
	/// * The [`into_api_response()` method of the `view`][HumusView::into_api_response]
	///   is used to generate an HTTP response.
	/// * If the status code of the response from `get_api_response()` is 200
	///   it will be replaced with the result of the [`get_status_code()` method
	///   of the `view`][HumusView::get_status_code].
	///
	pub fn render_view<S: HumusQuerySettings>(
		&self,
		settings: &S,
		view: impl HumusView<S, ApiFormat>,
		language_engine: &LanguageEngine,
	) -> Response {
		let format = settings.get_format();
		let status_code = view.get_status_code(settings);

		let mut response = if let Some(api_format) = ApiFormat::from_name(&format) {
			view.into_api_response(settings, api_format)
		} else if let Some(format_description) = self.templates_manifest.format.get(&format) {
			let template_name = view.get_template_name();
			let mime_type = &format_description.media_type;

			let mut context = tera::Context::new();
			context.insert("view", &template_name);
			//intented for shared macros
			context.insert("format", &format);
			let language = settings
				.get_preferred_language()
				.unwrap_or_else(|| language_engine.language_manifest().default_language.clone());
			context.insert("lang", &language);
			context.insert("language_manifest", language_engine.language_manifest());
			context.insert("media_type", &mime_type.to_string());
			context.insert("http_status", &status_code.as_u16());
			context.insert("data", &view);
			context.insert("extra", &self.template_config);
			view.initalize_template_context(&mut context, settings);

			// This is okay because tera.render does **not** cross threads and there is no async involved.
			TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(TextFunctionContext {
				language: Some(language),
				escape_function: tera::escape_html, //TODO: make this configurable
				safe_suffix: language_engine
					.language_manifest()
					.get_safe_suffix_for_format(format),
			}));
			let Some(tera) = self.tera.get(&format) else {
				error!("Problem finding tera instance for format {format:?}");
				return (
					StatusCode::INTERNAL_SERVER_ERROR,
					format!("Unknown template output format: {format:?}"),
				)
					.into_response();
			};
			match tera.render(
				&format!("{template_name}.{}", format_description.extension),
				&context,
			) {
				Ok(text) => {
					let response = (
						[(
							header::CONTENT_TYPE,
							HeaderValue::from_str(mime_type.as_ref())
								.expect("MimeType should always be a valid header value."),
						)],
						Into::<Body>::into(text),
					)
						.into_response();
					view.update_response(response, settings)
				}
				Err(e) => {
					error!(
						"There was an error while rendering template {template_name}:\n{}",
						render_tera_error(e)
					);
					(
						StatusCode::INTERNAL_SERVER_ERROR,
						format!("Template error in {template_name}, contact owner or see logs.\n"),
					)
						.into_response()
				}
			}
		// Handle when the format string resolves to neither an API nor a template.
		} else {
			(
				StatusCode::BAD_REQUEST,
				"(unknown format) You have requested an unknown template or API format.\n"
					.to_string(),
			)
				.into_response()
		};

		// Everything went well and nobody did the following work for us.
		if response.status() == StatusCode::OK {
			// Set status code
			*response.status_mut() = status_code;
		}

		// return response
		response
	}
}

fn render_tera_error(error: tera::Error) -> String {
	let mut text = error.to_string();
	let mut error: &(dyn core::error::Error + 'static) = &error;
	while let Some(source) = error.source() {
		text = format!("{text}\n* {source}");
		error = source;
	}
	text
}