use beecast_dto::CastMeta;
const TEMPLATE: &str = include_str!("page.html");
const PLAYER_JS: &str = include_str!("vendor/asciinema-player.min.js");
const PLAYER_CSS: &str = include_str!("vendor/asciinema-player.css");
pub fn build_page(cast_ndjson: &str, meta: &CastMeta, fallback_title: &str) -> String {
let title = meta.title.as_deref().unwrap_or(fallback_title);
let summary = meta.summary.as_deref().unwrap_or("");
let meta_json = serde_json::to_string(meta).expect("CastMeta serializes: it is plain strings and floats");
render(
TEMPLATE,
&[
("@@BEECAST_TITLE@@", &esc_html(title)),
("@@BEECAST_SUMMARY_HIDDEN@@", if summary.is_empty() { " hidden" } else { "" }),
("@@BEECAST_SUMMARY@@", &esc_html(summary)),
("@@BEECAST_PLAYER_CSS@@", PLAYER_CSS),
("@@BEECAST_PLAYER_JS@@", PLAYER_JS),
("@@BEECAST_CAST_JSON@@", &js_string_literal(cast_ndjson)),
("@@BEECAST_META_JSON@@", &script_safe(&meta_json)),
("@@BEECAST_FOOTER@@", &format!("beecast v{}", env!("CARGO_PKG_VERSION"))),
],
)
}
fn render(template: &str, subs: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len() + subs.iter().map(|(_, v)| v.len()).sum::<usize>());
let mut rest = template;
loop {
let hit = subs.iter().filter_map(|(k, v)| rest.find(k).map(|i| (i, *k, *v))).min_by_key(|(i, _, _)| *i);
match hit {
Some((i, k, v)) => {
out.push_str(&rest[..i]);
out.push_str(v);
rest = &rest[i + k.len()..];
}
None => {
out.push_str(rest);
return out;
}
}
}
}
fn esc_html(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
fn js_string_literal(s: &str) -> String {
serde_json::to_string(s).expect("strings always serialize").replace('<', "\\u003c")
}
fn script_safe(json: &str) -> String {
json.replace('<', "\\u003c")
}
#[cfg(test)]
mod tests {
use super::*;
use beecast_dto::{CastMeta, Chapter};
fn demo_meta() -> CastMeta {
CastMeta {
title: Some("Demo <run>".into()),
summary: Some("A & B.".into()),
chapters: vec![Chapter { t: 0.0, title: "Start".into() }, Chapter { t: 12.5, title: "Mid".into() }],
}
}
#[test]
fn page_is_self_contained() {
let page =
build_page("{\"version\":2,\"width\":80,\"height\":24}\n[1.0,\"o\",\"hi\"]\n", &demo_meta(), "demo.cast");
assert!(!page.contains("<script src"), "no external scripts");
assert!(!page.contains("<link rel=\"stylesheet\""), "no external stylesheets");
assert!(page.contains("<link rel=\"icon\" href=\"data:image/svg+xml,"), "inline favicon");
assert!(page.contains(&PLAYER_JS[..200]) && page.len() > PLAYER_JS.len() + PLAYER_CSS.len());
}
#[test]
fn page_respects_title_summary_and_chapters() {
let page = build_page("{\"version\":2,\"width\":80,\"height\":24}\n", &demo_meta(), "demo.cast");
assert!(page.contains("<title>Demo <run></title>"), "title, escaped");
assert!(page.contains("<p id=\"summary\">A & B.</p>"), "summary, escaped, not hidden");
assert!(page.contains("\"chapters\":[{\"t\":0.0,\"title\":\"Start\"},{\"t\":12.5,\"title\":\"Mid\"}]"));
}
#[test]
fn page_without_meta_falls_back_to_the_filename() {
let page = build_page("{\"version\":2,\"width\":80,\"height\":24}\n", &CastMeta::default(), "rec.cast");
assert!(page.contains("<title>rec.cast</title>"));
assert!(page.contains("<p id=\"summary\" hidden></p>"), "empty summary stays hidden");
}
#[test]
fn hostile_cast_content_cannot_break_out_of_the_script() {
let cast = "{\"version\":2,\"width\":80,\"height\":24}\n[1.0,\"o\",\"</script><script>alert(1)\"]\n";
let page = build_page(cast, &CastMeta::default(), "x.cast");
let payload_zone = &page[page.find("const CAST_DATA").unwrap()..];
assert!(!payload_zone.contains("</script><script>alert"), "the recording's text is neutralized");
assert!(payload_zone.contains("\\u003c/script>"), "escaped as \\u003c");
}
#[test]
fn hostile_meta_titles_are_neutralized_too() {
let meta = CastMeta {
title: Some("<script>x".into()),
summary: None,
chapters: vec![Chapter { t: 0.0, title: "</script>y".into() }],
};
let page = build_page("{\"version\":2,\"width\":80,\"height\":24}\n", &meta, "x.cast");
assert!(page.contains("<title><script>x</title>"));
assert!(!page.contains("\"title\":\"</script>"), "chapter titles are script-safe in the embedded META");
}
#[test]
fn render_never_rescans_substituted_values() {
let out = render("[@@A@@|@@B@@]", &[("@@A@@", "value-with-@@B@@"), ("@@B@@", "bee")]);
assert_eq!(out, "[value-with-@@B@@|bee]");
}
#[test]
fn no_tokens_survive_in_the_output() {
let page = build_page("{\"version\":2,\"width\":80,\"height\":24}\n", &demo_meta(), "demo.cast");
assert!(!page.contains("@@BEECAST_"), "all template tokens substituted");
}
#[test]
fn vendored_bundle_is_inline_safe() {
assert!(!PLAYER_JS.contains("</script"), "bundle would terminate the inline <script>");
assert!(!PLAYER_JS.contains("<!--"), "bundle would enter the script double-escaped state");
assert!(!PLAYER_JS.to_lowercase().contains("worker"), "bundle must not load the worker sidecar");
assert!(!PLAYER_CSS.contains("url("), "player CSS must not fetch fonts/images");
}
}