use crate::analytics::{AnalyticsConfig, SentryDsn};
use crate::assets::CacheBuster;
use crate::sitemap::{SitemapSet, SitemapUrl, build_sitemaps};
use std::sync::Arc;
use serde_json::Value;
use crate::templates::{
AnalyticsPaths, BaseTemplateData, FrontendRuntime, NOT_FOUND_TEMPLATE_NAME, PageTemplateData,
SentryBrowser, SocialImageMetadata, robots,
};
use crate::{Environment, env};
use super::error::WebServerError;
use super::pages::Pages;
pub struct FrontendParams<S = ()> {
pub base: BaseTemplateData,
pub pages: Pages<S>,
pub analytics: AnalyticsConfig,
pub sentry_browser_dsn: String,
}
impl<S> FrontendParams<S>
where
S: Clone + Send + Sync + 'static,
{
pub fn from_env(base: BaseTemplateData, pages: Pages<S>) -> Result<Self, WebServerError> {
Ok(Self {
base,
pages,
analytics: AnalyticsConfig::from_env().map_err(WebServerError::Env)?,
sentry_browser_dsn: env::required(crate::analytics::ENV_SENTRY_BROWSER_DSN)
.map_err(WebServerError::Env)?,
})
}
}
#[derive(Debug, Clone)]
pub struct WellKnown {
pub robots_txt: String,
pub humans_txt: String,
pub webmanifest: String,
pub sitemaps: SitemapSet,
}
pub struct Frontend<S> {
pub pages: Pages<S>,
pub base: BaseTemplateData,
pub runtime: FrontendRuntime,
pub well_known: WellKnown,
pub analytics: AnalyticsConfig,
pub sentry_dsn: SentryDsn,
pub not_found: (Arc<PageTemplateData>, Arc<Value>),
pub has_svg_icon: bool,
}
#[cfg(feature = "preset")]
const PRESET_HUMANS_TXT: &str = include_str!("../../assets/humans.txt");
impl<S> Frontend<S>
where
S: Clone + Send + Sync + 'static,
{
pub fn build(
params: FrontendParams<S>,
cache_buster: &CacheBuster,
environment: Environment,
) -> Result<Self, WebServerError> {
let sentry_dsn: SentryDsn =
SentryDsn::parse(¶ms.sentry_browser_dsn).map_err(WebServerError::SentryDsn)?;
let mut declared: Vec<String> = Vec::new();
declared.extend(params.base.style_sheets().iter().cloned());
declared.extend(params.base.scripts().iter().cloned());
declared.push(params.base.social_image().to_string());
declared.extend(params.pages.declared_assets());
crate::assets::validate_declared(cache_buster, &declared)?;
let icon_source: crate::assets::IconSource = crate::assets::validate_icons(cache_buster)?;
let paths: ProxyPaths = ProxyPaths::derive(params.base.project());
let social_image: SocialImageMetadata = probe_social_image(¶ms.base, cache_buster);
if !social_image.is_large_enough() {
tracing::error!(
"the social image is {}x{}, below the 600x314 floor for a \
`summary_large_image` card; shares will degrade to a thumbnail",
social_image.width.unwrap_or_default(),
social_image.height.unwrap_or_default(),
);
}
let sitemap_urls: Vec<SitemapUrl> = params
.pages
.sitemap_urls()?
.into_iter()
.map(|url| url.map_images(|image| cache_buster.get_file(image)))
.collect();
let last_modified: Option<chrono::DateTime<chrono::Utc>> =
crate::assets::content_modified(&["html", "static"]);
let sitemaps: SitemapSet =
build_sitemaps(params.base.base_url(), &sitemap_urls, last_modified)
.map_err(WebServerError::Sitemap)?;
let well_known: WellKnown = WellKnown {
robots_txt: robots_txt(params.base.base_url(), &sitemaps),
humans_txt: humans_txt(¶ms.base),
webmanifest: webmanifest(¶ms.base),
sitemaps,
};
let runtime: FrontendRuntime = FrontendRuntime {
theme_script: params.base.theme_script().source(),
social_image,
has_svg_icon: icon_source.is_vector(),
analytics: AnalyticsPaths {
script_path: paths.analytics_script.clone(),
event_path: paths.analytics_event.clone(),
},
sentry_browser: SentryBrowser {
script_path: paths.sentry_script.clone(),
tunnel_path: paths.sentry_tunnel.clone(),
environment: environment.to_string(),
},
};
let not_found: (Arc<PageTemplateData>, Arc<Value>) = (
Arc::new(
PageTemplateData::new(NOT_FOUND_TEMPLATE_NAME, "404", "/404")
.with_robots(robots::NOINDEX_FOLLOW),
),
Arc::new(Value::Null),
);
Ok(Self {
pages: params.pages,
base: params.base,
runtime,
well_known,
analytics: params.analytics,
sentry_dsn,
not_found,
has_svg_icon: icon_source.is_vector(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProxyPaths {
analytics_script: String,
analytics_event: String,
sentry_script: String,
sentry_tunnel: String,
}
impl ProxyPaths {
fn derive(project: &str) -> Self {
let slug: String = slugify(project);
let analytics: String = short_hash(project);
let sentry: String = short_hash(&format!("{project}:sentry"));
Self {
analytics_script: format!("/script/{slug}-{analytics}.js"),
analytics_event: format!("{}/{slug}-{analytics}", super::server::API_PREFIX),
sentry_script: format!("/script/{slug}-{sentry}.js"),
sentry_tunnel: format!("{}/{slug}-{sentry}", super::server::API_PREFIX),
}
}
}
fn slugify(project: &str) -> String {
let mut slug: String = String::with_capacity(project.len());
let mut previous_dash: bool = true;
for character in project.chars() {
if character.is_ascii_alphanumeric() {
slug.push(character.to_ascii_lowercase());
previous_dash = false;
} else if !previous_dash {
slug.push('-');
previous_dash = true;
}
}
String::from(slug.trim_matches('-'))
}
fn short_hash(input: &str) -> String {
format!("{:x}", md5::compute(input.as_bytes()))
.chars()
.take(8)
.collect()
}
fn probe_social_image(base: &BaseTemplateData, cache_buster: &CacheBuster) -> SocialImageMetadata {
let hashed: String = cache_buster.get_file(base.social_image());
crate::assets::probe_social_image(&hashed)
}
fn robots_txt(base_url: &str, sitemaps: &SitemapSet) -> String {
const BUDGET: usize = 400 * 1024;
let mut robots: String = String::from("User-agent: *\nAllow: /\n\n");
let mut omitted: usize = 0;
for path in sitemaps.paths() {
let line: String = format!("Sitemap: {base_url}{path}\n");
if robots.len() + line.len() > BUDGET {
omitted += 1;
continue;
}
robots.push_str(&line);
}
if omitted > 0 {
tracing::error!(
"omitted {omitted} sitemap line(s) from robots.txt to stay under \
Google's 500 KiB limit; the index is still listed first"
);
}
robots
}
#[cfg(feature = "preset")]
fn humans_txt(_base: &BaseTemplateData) -> String {
String::from(PRESET_HUMANS_TXT)
}
#[cfg(not(feature = "preset"))]
fn humans_txt(base: &BaseTemplateData) -> String {
format!(
"/* TEAM */\n\nName: {}\nSite: {}\n",
base.author(),
base.base_url()
)
}
fn webmanifest(base: &BaseTemplateData) -> String {
const ICONS: [(&str, &str); 2] = [("/icon-192.png", "192x192"), ("/icon-512.png", "512x512")];
let icons: Vec<String> = ICONS
.iter()
.map(|(source, sizes)| {
format!(
"{{ \"src\": \"{source}\", \"sizes\": \"{sizes}\", \"type\": \"image/png\", \"purpose\": \"any maskable\" }}"
)
})
.collect();
let color: &str = base.theme_color().primary();
format!(
"{{\n \"name\": {name:?},\n \"short_name\": {name:?},\n \"description\": {description:?},\n \"start_url\": \"/\",\n \"scope\": \"/\",\n \"display\": \"minimal-ui\",\n \"theme_color\": {color:?},\n \"background_color\": {color:?},\n \"icons\": [\n {icons}\n ]\n}}\n",
name = base.project(),
description = base.description(),
icons = icons.join(",\n "),
)
}
#[cfg(test)]
mod tests {
use super::{ProxyPaths, short_hash, slugify};
#[test]
fn a_project_name_becomes_a_lowercase_dashed_slug() {
assert_eq!(String::from("boggledygook"), slugify("Boggledygook"));
assert_eq!(String::from("eat-out"), slugify("Eat Out"));
assert_eq!(
String::from("template-web-server"),
slugify("Template Web Server")
);
assert_eq!(
String::from("palms-small-engine"),
slugify("Palms Small!Engine")
);
}
#[test]
fn the_proxy_paths_look_like_ordinary_bundler_output() {
let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
assert!(paths.analytics_script.starts_with("/script/boggledygook-"));
assert!(
std::path::Path::new(&paths.analytics_script)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("js"))
);
assert!(paths.analytics_event.starts_with("/api/v1/boggledygook-"));
for path in [&paths.analytics_script, &paths.analytics_event] {
for forbidden in ["plausible", "analytics", "tracking", "stats"] {
assert!(!path.contains(forbidden), "`{path}` contains `{forbidden}`");
}
}
}
#[test]
fn the_two_proxies_do_not_share_a_hash() {
let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
assert_ne!(paths.analytics_script, paths.sentry_script);
assert_ne!(paths.analytics_event, paths.sentry_tunnel);
}
#[test]
fn two_projects_never_share_a_path_so_one_filter_rule_cannot_block_both() {
let first: ProxyPaths = ProxyPaths::derive("Boggledygook");
let second: ProxyPaths = ProxyPaths::derive("Eat Out");
assert_ne!(first.analytics_script, second.analytics_script);
}
#[test]
fn the_derived_hash_is_stable_across_runs() {
let expected: String = short_hash("Boggledygook");
let actual: String = short_hash("Boggledygook");
assert_eq!(expected, actual);
let expected: usize = 8;
let actual: usize = short_hash("Boggledygook").len();
assert_eq!(expected, actual);
}
}