1
Fork 0

Checkpoint non-atomic commit

This commit is contained in:
Steffo 2024-11-19 06:06:48 +01:00
parent 8b01cc14c0
commit f223ce64fe
Signed by: steffo
GPG key ID: 5ADA3868646C3FC0
18 changed files with 103 additions and 227 deletions

View file

@ -1,3 +1,3 @@
[workspace]
resolver = "2"
members = ["acrate_database", "acrate_rd", "acrate_nodeinfo", "acrate_rdserver", "acrate_mime"]
members = ["acrate_database", "acrate_rd", "acrate_nodeinfo", "acrate_rdserver"]

View file

@ -12,13 +12,18 @@ Federation database
>
> This software suite is in active development!
> [!Warning]
>
> **Monorepo!** Make sure to open the root directory in your IDE, or weird things might happen until the packages are published!
## Crates
### Libraries
- `acrate_database`: Database schema, migrations, and high level database-reliant structures for the [`acrate`] project
- `acrate_database`: Database schema, migrations, and high level database-reliant structures
- `acrate_nodeinfo`: Rust typing and utilities for the NodeInfo format
- `acrate_rd`: Rust typing and utilities for the JSON and XML resource descriptior formats
### Binaries
- `acrate-webfinger`: WebFinger server
- `acrate_rdserver`: Resource descriptor web server

View file

@ -14,6 +14,7 @@ diesel = { version = "2.2.4", features = ["postgres", "uuid"] }
diesel-async = { version = "0.5.1", features = ["postgres"] }
diesel_migrations = { version = "2.2.0", optional = true }
log = "0.4.22"
mediatype = "0.19.18"
micronfig = { version = "0.3.0", optional = true }
mime = "0.3.17"
pretty_env_logger = { version = "0.5.0", optional = true }

View file

@ -26,12 +26,12 @@
//! - [`acrate_rdserver`]
//!
use std::str::FromStr;
use diesel::deserialize::FromSql;
use diesel::{AsExpression, Associations, FromSqlRow, Identifiable, Insertable, IntoSql, PgTextExpressionMethods, QueryResult, Queryable, QueryableByName, Selectable, SelectableHelper, ExpressionMethods, BelongingToDsl};
use diesel::pg::{Pg, PgConnection};
use diesel::serialize::{Output, ToSql};
use diesel_async::AsyncPgConnection;
use mediatype::MediaTypeBuf;
use uuid::Uuid;
use super::schema;
@ -40,7 +40,7 @@ use super::schema;
/// Wrapper to use [`mime::Mime`] with [`diesel`].
#[derive(Debug, Clone, PartialEq, Eq, FromSqlRow, AsExpression)]
#[diesel(sql_type = diesel::sql_types::Text)]
pub struct Mime(pub mime::Mime);
pub struct MediaTypeDatabase(pub MediaTypeBuf);
/// A matchable record denoting the existence of a resource descriptor.
@ -173,7 +173,7 @@ pub struct MetaLink {
/// The media type of the value of the link.
///
/// Can be [`None`] if it shouldn't be specified.
pub type_: Option<Mime>,
pub type_: Option<MediaTypeDatabase>,
/// The URI to the document this property is linking the subject to.
///
@ -206,7 +206,7 @@ pub struct MetaLinkInsert {
/// The media type of the value of the link.
///
/// Can be [`None`] if it shouldn't be specified.
pub type_: Option<Mime>,
pub type_: Option<MediaTypeDatabase>,
/// The URI to the document this property is linking the subject to.
///
@ -348,8 +348,8 @@ pub struct MetaPropertyInsert {
pub value: Option<String>,
}
/// Allow [`diesel::sql_types::Text`] values to be parsed as [`Mime`].
impl<DB> FromSql<diesel::sql_types::Text, DB> for Mime
/// Allow [`diesel::sql_types::Text`] values to be parsed as [`MediaTypeDatabase`].
impl<DB> FromSql<diesel::sql_types::Text, DB> for MediaTypeDatabase
where
DB: diesel::backend::Backend,
String: FromSql<diesel::sql_types::Text, DB>,
@ -359,25 +359,25 @@ impl<DB> FromSql<diesel::sql_types::Text, DB> for Mime
let s = <String as FromSql<diesel::sql_types::Text, DB>>::from_sql(bytes)?;
log::trace!("Attempting to parse as a media type: {s:?}");
let mime = mime::Mime::from_str(&s)?;
let mt: MediaTypeBuf = s.parse()?;
log::trace!("Successfully parsed media type: {mime:?}");
Ok(Self(mime))
log::trace!("Successfully parsed media type: {mt:?}");
Ok(Self(mt))
}
}
/// Allow [`diesel::sql_types::Text`] values to be written to with [`Mime`].
impl<DB> ToSql<diesel::sql_types::Text, DB> for Mime
/// Allow [`diesel::sql_types::Text`] values to be written to with [`MediaTypeDatabase`].
impl<DB> ToSql<diesel::sql_types::Text, DB> for MediaTypeDatabase
where
DB: diesel::backend::Backend,
str: ToSql<diesel::sql_types::Text, DB>,
{
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> diesel::serialize::Result {
log::trace!("Getting the essence of a media type to prepare for serialization...");
let mime = self.0.essence_str();
let mt = self.0.as_str();
log::trace!("Serializing media type as TEXT: {mime:?}");
<str as ToSql<diesel::sql_types::Text, DB>>::to_sql(mime, out)
log::trace!("Serializing media type as TEXT: {mt:?}");
<str as ToSql<diesel::sql_types::Text, DB>>::to_sql(mt, out)
}
}

View file

@ -1,13 +0,0 @@
[package]
name = "acrate_mime"
version = "0.1.0"
authors = ["Stefano Pigozzi <me@steffo.eu>"]
edition = "2021"
description = "Rust typing and utilities for MIME / media types"
repository = "https://forge.steffo.eu/unimore/tirocinio-canali-steffo-acrate"
license = "EUPL-1.2"
keywords = ["mime", "mimetype", "media", "media-type", "mime-type"]
categories = ["web-programming"]
[dependencies]
mime = "0.3.17"

View file

@ -1,14 +0,0 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View file

@ -12,7 +12,7 @@ categories = ["web-programming"]
[dependencies]
acrate_rd = { path = "../acrate_rd" }
log = "0.4.22"
mime = "0.3.17"
mediatype = { version = "0.19.18", features = ["serde"] }
reqwest = { version = "0.12.9", features = ["json", "stream"] }
serde = { version = "1.0.214", features = ["derive"] }
serde_json = "1.0.132"

View file

@ -8,7 +8,7 @@
//! - <https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md>
//!
use std::str::FromStr;
use mediatype::MediaTypeBuf;
use serde::Deserialize;
use thiserror::Error;
@ -356,19 +356,20 @@ impl NodeInfo1 {
.get(reqwest::header::CONTENT_TYPE)
.ok_or(ContentTypeMissing)?;
log::trace!("Extracting media type from the `Content-Type` header...");
let mime_type = content_type.to_str()
.map_err(ContentTypeUnprintable)?;
log::trace!("Parsing media type: {mime_type:?}");
let mime_type = mime::Mime::from_str(mime_type)
log::trace!("Extracting media type from the `Content-Type` header: {content_type:?}");
let media_type: MediaTypeBuf = content_type
.to_str()
.map_err(ContentTypeUnprintable)?
.parse()
.map_err(ContentTypeInvalid)?;
log::trace!("Checking if media type is supported: {mime_type:?}");
let mime_is_json = mime_type == mime::APPLICATION_JSON;
log::trace!("Is media type application/json? {mime_is_json:?}");
log::trace!("Checking if media type is supported: {media_type:?}");
let mime_is_json = media_type.essence().eq(&"application/json".parse::<MediaTypeBuf>().unwrap());
log::trace!("Is media type `application/json`? {mime_is_json:?}");
if !mime_is_json {
log::error!("MIME type `{mime_type}` is not acceptable for JSON parsing.");
log::error!("Media type `{media_type}` is not acceptable for NodeInfo parsing.");
return Err(ContentTypeUnsupported);
}
@ -434,19 +435,20 @@ impl NodeInfo2 {
.get(reqwest::header::CONTENT_TYPE)
.ok_or(ContentTypeMissing)?;
log::trace!("Extracting media type from the `Content-Type` header...");
let mime_type = content_type.to_str()
.map_err(ContentTypeUnprintable)?;
log::trace!("Parsing media type: {mime_type:?}");
let mime_type = mime::Mime::from_str(mime_type)
log::trace!("Extracting media type from the `Content-Type` header: {content_type:?}");
let media_type: MediaTypeBuf = content_type
.to_str()
.map_err(ContentTypeUnprintable)?
.parse()
.map_err(ContentTypeInvalid)?;
log::trace!("Checking if media type is supported...");
let mime_is_json = mime_type == mime::APPLICATION_JSON;
log::trace!("Is media type application/json? {mime_is_json:?}");
log::trace!("Checking if media type is supported: {media_type:?}");
let mime_is_json = media_type.essence().eq(&"application/json".parse::<MediaTypeBuf>().unwrap());
log::trace!("Is media type `application/json`? {mime_is_json:?}");
if !mime_is_json {
log::error!("MIME type `{mime_type}` is not acceptable for JSON parsing.");
log::error!("Media type `{media_type}` is not acceptable for NodeInfo parsing.");
return Err(ContentTypeUnsupported);
}
@ -481,7 +483,7 @@ pub enum NodeInfoGetError {
/// The `Content-Type` header of the response is not a valid [`mime::Mime`] type.
#[error("the Content-Type header of the response is not a valid media type")]
ContentTypeInvalid(mime::FromStrError),
ContentTypeInvalid(mediatype::MediaTypeError),
/// The `Content-Type` header of the response is not a supported [`mime::Mime`] type.
#[error("the Content-Type header of the response is not a supported media type")]

View file

@ -11,7 +11,7 @@ categories = ["web-programming"]
[dependencies]
log = "0.4.22"
mime = "0.3.17"
mediatype = { version = "0.19.18", features = ["serde"] }
quick-xml = { version = "0.37.0", features = ["overlapped-lists", "serialize"] }
reqwest = { version = "0.12.9", features = ["json", "stream"] }
serde = { version = "1.0.214", features = ["derive"] }

View file

@ -30,7 +30,7 @@ impl ResourceDescriptor {
///
/// ```
/// # tokio_test::block_on(async {
/// use acrate_hostmeta::any::ResourceDescriptor;
/// use acrate_rd::any::ResourceDescriptor;
///
/// let client = reqwest::Client::new();
/// let url: reqwest::Url = "https://junimo.party/.well-known/host-meta".parse()
@ -185,7 +185,7 @@ impl ResourceDescriptor {
///
/// ```
/// # tokio_test::block_on(async {
/// use acrate_hostmeta::any::ResourceDescriptor;
/// use acrate_rd::any::ResourceDescriptor;
///
/// let client = reqwest::Client::new();
/// let base: reqwest::Url = "https://junimo.party".parse()

View file

@ -1,7 +1,7 @@
//! Definition and implementation of [`ResourceDescriptorJRD`].
use std::collections::HashMap;
use std::str::FromStr;
use mediatype::MediaTypeBuf;
use serde::{Serialize, Deserialize};
use thiserror::Error;
use crate::xrd::{ResourceDescriptorLinkXRD, ResourceDescriptorPropertyXRD, ResourceDescriptorTitleXRD, ResourceDescriptorXRD};
@ -76,8 +76,7 @@ pub struct ResourceDescriptorLinkJRD {
/// - <https://datatracker.ietf.org/doc/html/rfc7033#section-4.4.4.2>
///
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(with = "crate::utils::serde_mime_opt")]
pub r#type: Option<mime::Mime>,
pub r#type: Option<MediaTypeBuf>,
/// URI to the resource put in relation.
///
@ -173,24 +172,23 @@ impl ResourceDescriptorJRD {
.get(reqwest::header::CONTENT_TYPE)
.ok_or(ContentTypeMissing)?;
log::trace!("Extracting media type from the `Content-Type` header...");
let mime_type = content_type.to_str()
.map_err(ContentTypeUnprintable)?;
log::trace!("Parsing media type: {mime_type:?}");
let mime_type = mime::Mime::from_str(mime_type)
log::trace!("Extracting media type from the `Content-Type` header: {content_type:?}");
let media_type: MediaTypeBuf = content_type
.to_str()
.map_err(ContentTypeUnprintable)?
.parse()
.map_err(ContentTypeInvalid)?;
log::trace!("Checking if media type is supported: {mime_type:?}");
let mime_is_json = mime_type == mime::APPLICATION_JSON;
log::trace!("Is media type application/json? {mime_is_json:?}");
let mime_is_jrd =
mime_type.type_() == mime::APPLICATION
&& mime_type.subtype() == "jrd"
&& mime_type.suffix() == Some(mime::JSON);
log::trace!("Is media type application/jrd+json? {mime_is_jrd:?}");
log::trace!("Checking if media type is supported: {media_type:?}");
let mime_is_json = media_type.essence().eq(&"application/json".parse::<MediaTypeBuf>().unwrap());
log::trace!("Is media type `application/json`? {mime_is_json:?}");
let mime_is_jrd = media_type.essence().eq(&"application/jrd+json".parse::<MediaTypeBuf>().unwrap());
log::trace!("Is media type `application/jrd+json`? {mime_is_jrd:?}");
if !(mime_is_json || mime_is_jrd) {
log::error!("MIME type `{mime_type}` is not acceptable for JRD parsing.");
log::error!("Media type `{media_type}` is not acceptable for JRD parsing.");
return Err(ContentTypeUnsupported);
}
@ -267,7 +265,7 @@ pub enum GetJRDError {
/// The `Content-Type` header of the response is not a valid [`mime::Mime`] type.
#[error("the Content-Type header of the response is not a valid media type")]
ContentTypeInvalid(mime::FromStrError),
ContentTypeInvalid(mediatype::MediaTypeError),
/// The `Content-Type` header of the response is not a supported [`mime::Mime`] type.
#[error("the Content-Type header of the response is not a supported media type")]

View file

@ -8,4 +8,3 @@
pub mod jrd;
pub mod xrd;
pub mod any;
mod utils;

View file

@ -1,105 +0,0 @@
//! Various utilities reused in the whole crate.
/// Module to use in `serde(with = ...)` to [`serde`] a [`mime::Mime`].
#[allow(dead_code)]
pub mod serde_mime {
use std::fmt::Formatter;
use std::str::FromStr;
use serde::de::{Error, Visitor};
use serde::{Deserializer, Serializer};
pub struct MimeVisitor;
impl<'de> Visitor<'de> for MimeVisitor {
type Value = mime::Mime;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("a media type (MIME type)")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error,
{
mime::Mime::from_str(v)
.map_err(|_| E::custom("failed to parse media type"))
}
}
pub fn deserialize<'de, De>(deserializer: De) -> Result<<MimeVisitor as Visitor<'de>>::Value, De::Error>
where
De: Deserializer<'de>
{
let s = deserializer.deserialize_str(MimeVisitor)?;
Ok(s)
}
pub fn serialize<Ser>(data: mime::Mime, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
where
Ser: Serializer
{
let s = data.essence_str();
serializer.serialize_str(s)
}
}
/// Module to use in `serde(with = ...)` to [`serde`] an [`Option`] of [`mime::Mime`].
#[allow(dead_code)]
pub mod serde_mime_opt {
use std::fmt::Formatter;
use std::str::FromStr;
use serde::de::{Error, Visitor};
use serde::{Deserializer, Serializer};
pub struct MimeVisitor;
impl<'de> Visitor<'de> for MimeVisitor {
type Value = Option<mime::Mime>;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("optionally, a media type (MIME type)")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error,
{
Ok(
Some(
mime::Mime::from_str(v)
.map_err(|_| E::custom("failed to parse media type"))?
)
)
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: Error,
{
Ok(None)
}
}
pub fn deserialize<'de, De>(deserializer: De) -> Result<<MimeVisitor as Visitor<'de>>::Value, De::Error>
where
De: Deserializer<'de>
{
let s = deserializer.deserialize_str(MimeVisitor)?;
Ok(s)
}
pub fn serialize<Ser>(data: &Option<mime::Mime>, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
where
Ser: Serializer
{
match data {
None => {
serializer.serialize_none()
}
Some(data) => {
let s = data.essence_str();
serializer.serialize_str(s)
}
}
}
}

View file

@ -1,6 +1,6 @@
//! Definition and implementation of [`ResourceDescriptorXRD`].
use std::str::FromStr;
use mediatype::MediaTypeBuf;
use serde::{Serialize, Deserialize};
use thiserror::Error;
use crate::jrd::{ResourceDescriptorJRD, ResourceDescriptorLinkJRD};
@ -82,8 +82,7 @@ pub struct ResourceDescriptorLinkXRD {
///
#[serde(rename = "@type")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(with = "crate::utils::serde_mime_opt")]
pub r#type: Option<mime::Mime>,
pub r#type: Option<MediaTypeBuf>,
/// URI to the resource put in relation.
///
@ -209,22 +208,20 @@ impl ResourceDescriptorXRD {
.get(reqwest::header::CONTENT_TYPE)
.ok_or(ContentTypeMissing)?;
log::trace!("Extracting media type from the `Content-Type` header...");
let mime_type = content_type.to_str()
.map_err(ContentTypeUnprintable)?;
log::trace!("Parsing media type: {mime_type:?}");
let mime_type = mime::Mime::from_str(mime_type)
log::trace!("Extracting media type from the `Content-Type` header: {content_type:?}");
let media_type: MediaTypeBuf = content_type
.to_str()
.map_err(ContentTypeUnprintable)?
.parse()
.map_err(ContentTypeInvalid)?;
log::trace!("Checking if media type is supported: {mime_type:?}");
let mime_is_xrd =
mime_type.type_().as_str() == mime::APPLICATION
&& mime_type.subtype() == "xrd"
&& mime_type.suffix() == Some(mime::XML);
log::trace!("Is media type application/xrd+xml? {mime_is_xrd:?}");
log::trace!("Checking if media type is supported: {media_type:?}");
let mime_is_xrd = media_type.essence().eq(&"application/xrd+xml".parse::<MediaTypeBuf>().unwrap());
log::trace!("Is media type `application/xrd+xml`? {mime_is_xrd:?}");
if !mime_is_xrd {
log::error!("MIME type `{mime_type}` is not acceptable for XRD parsing.");
log::error!("MIME type `{media_type}` is not acceptable for XRD parsing.");
return Err(ContentTypeUnsupported);
}
@ -309,7 +306,7 @@ pub enum GetXRDError {
/// The `Content-Type` header of the response is not a valid [`mime::Mime`] type.
#[error("the Content-Type header of the response is not a valid media type")]
ContentTypeInvalid(mime::FromStrError),
ContentTypeInvalid(mediatype::MediaTypeError),
/// The `Content-Type` header of the response is not a supported [`mime::Mime`] type.
#[error("the Content-Type header of the response is not a supported media type")]

View file

@ -23,7 +23,7 @@ quick-xml = { version = "0.37.0", features = ["serialize"] }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.132"
tokio = { version = "1.41.1", features = ["macros", "net", "rt-multi-thread"] }
mime = "0.3.17"
mediatype = { version = "0.19.18", features = ["serde"] }
[lints.clippy]
tabs-in-doc-comments = "allow"

View file

@ -15,12 +15,12 @@ async fn main() -> anyhow::Result<std::convert::Infallible> {
let mut mj = minijinja::Environment::<'static>::new();
log::trace!("Adding webfinger page to the Minijinja environment...");
mj.add_template("webfinger.html.j2", include_str!("webfinger.html.j2"))
.expect("webfinger.html.j2 to be a valid Minijinja template");
mj.add_template("rd.html.j2", include_str!("rd.html.j2"))
.expect("rd.html.j2 to be a valid Minijinja template");
log::trace!("Creating Axum router...");
let app = axum::Router::new()
.route("/.well-known/webfinger", axum::routing::get(route::webfinger_handler))
.route("/*path", axum::routing::get(route::webfinger_handler))
.layer(Extension(Arc::new(mj)));
log::trace!("Axum router created successfully!");

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>{{ subject }} · Acrate Webfinger</title>
<title>{{ subject }} · Acrate RDServer</title>
<style>
:root {
--wf-yellow: #e2bb03;
@ -80,7 +80,7 @@
<body>
<header>
<h1>
Acrate Webfinger
<span id="path">{{ path }}</span>
</h1>
</header>
<main>

View file

@ -1,7 +1,9 @@
use std::sync::Arc;
use axum::Extension;
use axum::extract::Path;
use axum::http::{HeaderMap, Response, StatusCode};
use axum_extra::extract::Query;
use mediatype::MediaTypeBuf;
use serde::Deserialize;
use acrate_database::diesel::GroupedBy;
use acrate_database::diesel_async::{AsyncConnection, AsyncPgConnection};
@ -18,10 +20,10 @@ pub struct WebfingerQuery {
pub rel: Vec<String>,
}
const WEBFINGER_DOC: &str = "/.well-known/webfinger";
#[allow(unused_variables)] // Inspection seems to be broken on this function. Idk why...
#[axum::debug_handler]
pub async fn webfinger_handler(
Path(path): Path<String>,
Query(WebfingerQuery {resource, rel}): Query<WebfingerQuery>,
headers: HeaderMap,
Extension(mj): Extension<Arc<minijinja::Environment<'static>>>,
@ -31,6 +33,9 @@ pub async fn webfinger_handler(
let resource = resource.unwrap_or_else(|| "".to_string());
log::debug!("Resource is: {resource:#?}");
let path = format!("/{path}");
log::debug!("Path is: {path:#?}");
log::debug!("Rel is: {rel:#?}");
let accept = headers.get("Accept")
@ -47,7 +52,7 @@ pub async fn webfinger_handler(
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
let subjects = MetaSubject::aquery_matching(&mut conn, WEBFINGER_DOC, &resource)
let subjects = MetaSubject::aquery_matching(&mut conn, &path, &resource)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -76,15 +81,15 @@ pub async fn webfinger_handler(
let subject = subject.subject.clone();
let aliases = MetaAlias::aquery_matching(&mut conn, WEBFINGER_DOC, &resource)
let aliases = MetaAlias::aquery_matching(&mut conn, &path, &resource)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let properties = MetaProperty::aquery_matching(&mut conn, WEBFINGER_DOC, &resource)
let properties = MetaProperty::aquery_matching(&mut conn, &path, &resource)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let links = MetaLink::aquery_matching(&mut conn, WEBFINGER_DOC, &resource)
let links = MetaLink::aquery_matching(&mut conn, &path, &resource)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -241,7 +246,7 @@ pub async fn webfinger_handler(
})
.collect();
let links: Vec<(String, Option<mime::Mime>, Option<String>, Option<String>, Vec<(String, Option<String>)>, Vec<(String, String)>)> = links_full
let links: Vec<(String, Option<MediaTypeBuf>, Option<String>, Option<String>, Vec<(String, Option<String>)>, Vec<(String, String)>)> = links_full
.into_iter()
.map(|(link, properties, titles)| {
(
@ -259,10 +264,11 @@ pub async fn webfinger_handler(
})
.collect();
let html = mj.get_template("webfinger.html.j2")
.expect("webfinger.html.j2 to exist")
let html = mj.get_template("rd.html.j2")
.expect("rd.html.j2 to exist")
.render(
minijinja::context!(
path => path,
subject => subject,
aliases => aliases,
properties => properties,