use crate::params::RoomParameters;
use minijinja::{AutoEscape, Environment, context};
use std::error::Error;
use std::fs;
use std::path::Path;
pub struct Templates {
env: Environment<'static>,
}
impl Templates {
pub fn load(web_root: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
let dir = Path::new(web_root).join("html");
let index = fs::read_to_string(dir.join("index_template.html"))?;
let full = fs::read_to_string(dir.join("full_template.html"))?;
let grid = fs::read_to_string(dir.join("grid_template.html"))?;
Ok(Self::from_sources_with_grid(index, full, grid)?)
}
fn from_sources(index: String, full: String) -> Result<Self, minijinja::Error> {
Self::from_sources_with_grid(
index,
full,
"<div id=\"sfu-grid\" class=\"sfu-grid hidden\"></div>".into(),
)
}
fn from_sources_with_grid(
index: String,
full: String,
grid: String,
) -> Result<Self, minijinja::Error> {
let mut env = Environment::new();
env.set_auto_escape_callback(|_| AutoEscape::None);
env.add_template_owned("index", index)?;
env.add_template_owned("full", full)?;
env.add_template_owned("grid", grid)?;
Ok(Self { env })
}
pub fn render_index(&self, params: &RoomParameters) -> Result<String, minijinja::Error> {
self.env.get_template("index")?.render(context_for(params))
}
pub fn render_full(&self, params: &RoomParameters) -> Result<String, minijinja::Error> {
self.env.get_template("full")?.render(context_for(params))
}
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn context_for(p: &RoomParameters) -> minijinja::Value {
let error_messages = serde_json::to_string(&p.error_messages).unwrap_or_else(|_| "null".into());
let warning_messages =
serde_json::to_string(&p.warning_messages).unwrap_or_else(|_| "null".into());
context! {
room_id => escape(&p.room_id),
room_link => escape(&p.room_link),
header_message => escape(&p.header_message),
wss_url => escape(&p.wss_url),
wss_post_url => escape(&p.wss_post_url),
signaling_version => p.signaling_version,
ice_server_url => escape(&p.ice_server_url),
ice_server_transports => escape(&p.ice_server_transports),
error_messages => error_messages,
warning_messages => warning_messages,
is_loopback => &p.is_loopback,
media_constraints => &p.media_constraints,
offer_options => &p.offer_options,
pc_config => &p.pc_config,
pc_constraints => &p.pc_constraints,
bypass_join_confirmation => &p.bypass_join_confirmation,
version_info => &p.version_info,
include_loopback_js => &p.include_loopback_js,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_plain_fields_and_injects_safe_fields_raw() {
let src = "id={{ room_id }} \
url='{{ wss_url }}' \
cfg={{ pc_config | safe }} \
loop={{ is_loopback }}\
{% if room_id %} has-room{% endif %}"
.to_string();
let t = Templates::from_sources(src.clone(), src).unwrap();
let params = RoomParameters {
room_id: "a<b".to_string(),
wss_url: "ws://host:9000/ws".to_string(),
pc_config: r#"{"x":1<2}"#.to_string(),
is_loopback: "1<2".to_string(),
..Default::default()
};
let out = t.render_index(¶ms).unwrap();
assert!(out.contains("id=a<b"), "plain field not escaped: {out}");
assert!(
out.contains("url='ws://host:9000/ws'"),
"url slashes mangled: {out}"
);
assert!(
out.contains(r#"cfg={"x":1<2}"#),
"| safe blob altered: {out}"
);
assert!(out.contains("loop=1<2"), "safe field not raw: {out}");
assert!(out.contains("has-room"), "{{% if %}} not handled: {out}");
}
#[test]
fn empty_message_lists_render_as_js_arrays() {
let src = "e={{ error_messages }} w={{ warning_messages }}".to_string();
let t = Templates::from_sources(src.clone(), src).unwrap();
let out = t.render_index(&RoomParameters::default()).unwrap();
assert_eq!(out, "e=[] w=[]", "message lists not marshaled to JS arrays");
}
#[test]
fn renders_full_template_and_serializes_message_lists() {
let t = Templates::from_sources(
"index".to_string(),
"{{ room_id }}|{{ error_messages }}|{{ warning_messages }}|{{ include_loopback_js }}"
.to_string(),
)
.unwrap();
let params = RoomParameters {
room_id: "room".into(),
client_id: "client".into(),
error_messages: vec!["bad offer".into()],
warning_messages: vec!["slow network".into()],
include_loopback_js: "<script src=\"loopback.js\"></script>".into(),
..Default::default()
};
let out = t.render_full(¶ms).unwrap();
assert!(out.contains("room|[\"bad offer\"]|[\"slow network\"]"));
assert!(out.contains("<script src=\"loopback.js\"></script>"));
}
#[test]
fn escapes_all_plain_string_delimiters_without_escaping_slashes() {
let t = Templates::from_sources("{{ value }}".into(), "full".into()).unwrap();
let params = RoomParameters {
room_id: "&<>'\"/".into(),
..Default::default()
};
let out = t.render_index(¶ms).unwrap();
assert_eq!(out, "");
let t = Templates::from_sources("{{ room_id }}".into(), "full".into()).unwrap();
let out = t.render_index(¶ms).unwrap();
assert_eq!(out, "&<>'"/");
}
#[test]
fn load_reads_index_and_full_templates_from_web_root() {
let root = std::env::temp_dir().join(format!("apprtc-templates-{}", rand::random::<u64>()));
std::fs::create_dir_all(root.join("html")).unwrap();
std::fs::write(root.join("html/index_template.html"), "index").unwrap();
std::fs::write(root.join("html/full_template.html"), "full").unwrap();
std::fs::write(root.join("html/grid_template.html"), "grid").unwrap();
let templates = Templates::load(root.to_str().unwrap()).unwrap();
assert_eq!(
templates.render_index(&RoomParameters::default()).unwrap(),
"index"
);
assert_eq!(
templates.render_full(&RoomParameters::default()).unwrap(),
"full"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn load_returns_error_when_template_is_missing() {
let root = std::env::temp_dir().join(format!("apprtc-missing-{}", rand::random::<u64>()));
std::fs::create_dir_all(root.join("html")).unwrap();
std::fs::write(root.join("html/index_template.html"), "index").unwrap();
assert!(Templates::load(root.to_str().unwrap()).is_err());
std::fs::remove_dir_all(root).unwrap();
}
}