use std::fmt;
use std::sync::Arc;
use super::error::InertiaError;
use super::head::{Head, escape};
use super::props::SharedProps;
use crate::http::security::CspNonce;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AssetVersion(Arc<str>);
impl AssetVersion {
pub fn new(version: impl AsRef<str>) -> Result<Self, InertiaError> {
let version = version.as_ref();
axum::http::HeaderValue::from_str(version)
.map_err(axum::http::Error::from)
.map_err(InertiaError::Header)?;
Ok(AssetVersion(Arc::from(version)))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for AssetVersion {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone)]
pub struct ScriptBody {
html: Arc<str>,
nonce: Option<CspNonce>,
head: Option<Head>,
}
impl ScriptBody {
pub(crate) fn from_escaped(
html: Arc<str>,
nonce: Option<CspNonce>,
head: Option<Head>,
) -> ScriptBody {
ScriptBody { html, nonce, head }
}
#[must_use]
pub fn head(&self) -> Option<&Head> {
self.head.as_ref()
}
#[must_use]
pub fn nonce(&self) -> Option<&CspNonce> {
self.nonce.as_ref()
}
#[must_use]
pub fn nonce_attribute(&self) -> String {
self.nonce
.as_ref()
.map(CspNonce::attribute)
.unwrap_or_default()
}
}
impl fmt::Display for ScriptBody {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.html)
}
}
pub trait RootDocument: Send + Sync {
fn render(&self, body: ScriptBody) -> String;
}
impl<T> RootDocument for T
where
T: Fn(ScriptBody) -> String + Send + Sync,
{
fn render(&self, body: ScriptBody) -> String {
self(body)
}
}
#[derive(Clone)]
pub struct InertiaConfig {
inner: Arc<ConfigInner>,
}
impl fmt::Debug for InertiaConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InertiaConfig")
.field("version", &self.inner.version)
.field("shared_props", &self.inner.shared_props.is_empty())
.finish_non_exhaustive()
}
}
#[derive(Clone)]
struct ConfigInner {
version: Option<AssetVersion>,
root_document: Arc<dyn RootDocument>,
shared_props: SharedProps,
page_id: String,
}
impl InertiaConfig {
pub fn new(
version: impl AsRef<str>,
root_document: impl RootDocument + 'static,
) -> Result<InertiaConfig, InertiaError> {
Ok(InertiaConfig {
inner: Arc::new(ConfigInner {
version: Some(AssetVersion::new(version)?),
root_document: Arc::new(root_document),
shared_props: SharedProps::new(),
page_id: "app".to_string(),
}),
})
}
pub fn versionless(root_document: impl RootDocument + 'static) -> InertiaConfig {
InertiaConfig {
inner: Arc::new(ConfigInner {
version: None,
root_document: Arc::new(root_document),
shared_props: SharedProps::new(),
page_id: "app".to_string(),
}),
}
}
pub fn with_shared(mut self, shared: SharedProps) -> Self {
Arc::make_mut(&mut self.inner).shared_props = shared;
self
}
pub fn with_page_id(mut self, id: impl Into<String>) -> Self {
Arc::make_mut(&mut self.inner).page_id = id.into();
self
}
pub fn version(&self) -> Option<&AssetVersion> {
self.inner.version.as_ref()
}
pub(crate) fn version_str(&self) -> &str {
self.inner.version.as_ref().map_or("", AssetVersion::as_str)
}
pub(crate) fn root_document(&self) -> &Arc<dyn RootDocument> {
&self.inner.root_document
}
pub(crate) fn shared_props(&self) -> &SharedProps {
&self.inner.shared_props
}
pub(crate) fn page_id(&self) -> &str {
&self.inner.page_id
}
}
fn head_markup(head: Option<&Head>, title: &str) -> String {
match head {
None => format!("<title>{}</title>", escape(title)),
Some(head) if head.title().is_some() => head.to_html(),
Some(head) => head.clone().with_title(title).to_html(),
}
}
pub fn default_root_document(title: &str) -> impl RootDocument + use<> {
let title = title.to_string();
move |body: ScriptBody| {
let nonce = body.nonce_attribute();
let head = head_markup(body.head(), &title);
format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n \
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n \
{head}\n <link{nonce} rel=\"stylesheet\" href=\"/css/app.css\" />\n</head>\n\
<body>\n {body}\n <script{nonce} type=\"module\" src=\"/js/app.js\"></script>\n</body>\n</html>"
)
}
}
pub fn vite_root_document(
title: &str,
assets: &crate::assets::Assets,
entry: &str,
) -> impl RootDocument + use<> {
let title = title.to_string();
let resolved = assets.resolve(entry);
let dev = assets.is_dev();
move |body: ScriptBody| {
let nonce = body.nonce().map(CspNonce::as_str);
let styles = crate::assets::style_tags(
resolved.as_ref().map(|r| r.css.as_slice()).unwrap_or(&[]),
nonce,
);
let scripts =
crate::assets::script_tags(resolved.as_ref().map(|r| r.js.as_str()), dev, nonce);
let head = head_markup(body.head(), &title);
format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n \
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n \
{head}\n {styles}\n</head>\n\
<body>\n {body}\n {scripts}\n</body>\n</html>"
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::{Assets, AssetsConfig};
fn body(head: Option<Head>) -> ScriptBody {
ScriptBody::from_escaped(Arc::from("<div id=\"app\"></div>"), None, head)
}
fn vite(title: &str) -> impl RootDocument + use<> {
vite_root_document(
title,
&Assets::dev(&AssetsConfig::new()),
"resources/js/app.tsx",
)
}
#[test]
fn the_default_document_without_a_head_is_the_document_it_always_was() {
let html = default_root_document("Acme").render(body(None));
assert_eq!(
html,
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n \
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n \
<title>Acme</title>\n <link rel=\"stylesheet\" href=\"/css/app.css\" />\n</head>\n\
<body>\n <div id=\"app\"></div>\n \
<script type=\"module\" src=\"/js/app.js\"></script>\n</body>\n</html>"
);
}
#[test]
fn the_vite_document_without_a_head_is_the_document_it_always_was() {
let html = vite("Acme").render(body(None));
assert_eq!(
html,
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n \
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n \
<title>Acme</title>\n \n</head>\n\
<body>\n <div id=\"app\"></div>\n \
<script type=\"module\" src=\"/@vite/client\"></script>\n \
<script type=\"module\" src=\"/resources/js/app.tsx\"></script>\n</body>\n</html>"
);
}
#[test]
fn a_head_with_a_title_replaces_the_application_title() {
let head = Head::new()
.with_title("Ada Lovelace")
.with_description("Notes on the Analytical Engine.");
for html in [
default_root_document("Acme").render(body(Some(head.clone()))),
vite("Acme").render(body(Some(head.clone()))),
] {
assert!(html.contains("<title>Ada Lovelace</title>"), "{html}");
assert!(
html.contains(
"<meta name=\"description\" \
content=\"Notes on the Analytical Engine.\" />"
),
"{html}"
);
assert!(!html.contains("Acme"), "{html}");
}
}
#[test]
fn a_head_without_a_title_borrows_the_application_title() {
let head = Head::new().with_og_image("https://example.com/og.png");
for html in [
default_root_document("Acme").render(body(Some(head.clone()))),
vite("Acme").render(body(Some(head.clone()))),
] {
assert!(html.contains("<title>Acme</title>"), "{html}");
assert!(
html.contains("<meta property=\"og:title\" content=\"Acme\" />"),
"{html}"
);
assert!(
html.contains(
"<meta property=\"og:image\" content=\"https://example.com/og.png\" />"
),
"{html}"
);
}
}
#[test]
fn a_hostile_application_title_cannot_open_a_tag_in_either_document() {
let hostile = "<script>alert(1)</script>";
for html in [
default_root_document(hostile).render(body(None)),
vite(hostile).render(body(None)),
default_root_document(hostile).render(body(Some(Head::new()))),
vite(hostile).render(body(Some(Head::new()))),
] {
assert!(
html.contains("<title><script>alert(1)</script></title>"),
"{html}"
);
assert!(!html.contains("<script>alert(1)"), "{html}");
}
}
#[test]
fn a_hostile_page_title_cannot_open_a_tag_in_either_document() {
let head = Head::new().with_title("<script>alert(1)</script>");
for html in [
default_root_document("Acme").render(body(Some(head.clone()))),
vite("Acme").render(body(Some(head.clone()))),
] {
assert!(
html.contains("<title><script>alert(1)</script></title>"),
"{html}"
);
assert!(
html.contains(
"<meta property=\"og:title\" \
content=\"<script>alert(1)</script>\" />"
),
"{html}"
);
assert!(!html.contains("<script>alert(1)"), "{html}");
}
}
}