webserver_base/webserver/
frontend.rs1use crate::analytics::{AnalyticsConfig, SentryDsn};
9use crate::assets::CacheBuster;
10use crate::sitemap::{SitemapSet, SitemapUrl, build_sitemaps};
11use std::sync::Arc;
12
13use serde_json::Value;
14
15use crate::templates::{
16 AnalyticsPaths, BaseTemplateData, FrontendRuntime, NOT_FOUND_TEMPLATE_NAME, PageTemplateData,
17 SentryBrowser, SocialImageMetadata, robots,
18};
19use crate::{Environment, env};
20
21use super::error::WebServerError;
22use super::pages::Pages;
23
24pub struct FrontendParams<S = ()> {
29 pub base: BaseTemplateData,
31 pub pages: Pages<S>,
33 pub analytics: AnalyticsConfig,
35 pub sentry_browser_dsn: String,
39}
40
41impl<S> FrontendParams<S>
42where
43 S: Clone + Send + Sync + 'static,
44{
45 pub fn from_env(base: BaseTemplateData, pages: Pages<S>) -> Result<Self, WebServerError> {
51 Ok(Self {
52 base,
53 pages,
54 analytics: AnalyticsConfig::from_env().map_err(WebServerError::Env)?,
55 sentry_browser_dsn: env::required(crate::analytics::ENV_SENTRY_BROWSER_DSN)
56 .map_err(WebServerError::Env)?,
57 })
58 }
59}
60
61#[derive(Debug, Clone)]
68pub struct WellKnown {
69 pub robots_txt: String,
70 pub humans_txt: String,
71 pub webmanifest: String,
72 pub sitemaps: SitemapSet,
73}
74
75pub struct Frontend<S> {
77 pub pages: Pages<S>,
78 pub base: BaseTemplateData,
79 pub runtime: FrontendRuntime,
80 pub well_known: WellKnown,
81 pub analytics: AnalyticsConfig,
82 pub sentry_dsn: SentryDsn,
83 pub not_found: (Arc<PageTemplateData>, Arc<Value>),
85 pub has_svg_icon: bool,
87}
88
89#[cfg(feature = "preset")]
92const PRESET_HUMANS_TXT: &str = include_str!("../../assets/humans.txt");
93
94impl<S> Frontend<S>
95where
96 S: Clone + Send + Sync + 'static,
97{
98 pub fn build(
106 params: FrontendParams<S>,
107 cache_buster: &CacheBuster,
108 environment: Environment,
109 ) -> Result<Self, WebServerError> {
110 let sentry_dsn: SentryDsn =
111 SentryDsn::parse(¶ms.sentry_browser_dsn).map_err(WebServerError::SentryDsn)?;
112
113 let mut declared: Vec<String> = Vec::new();
117 declared.extend(params.base.style_sheets().iter().cloned());
118 declared.extend(params.base.scripts().iter().cloned());
119 declared.push(params.base.social_image().to_string());
120 declared.extend(params.pages.declared_assets());
121 crate::assets::validate_declared(cache_buster, &declared)?;
122
123 let icon_source: crate::assets::IconSource = crate::assets::validate_icons(cache_buster)?;
124
125 let paths: ProxyPaths = ProxyPaths::derive(params.base.project());
126 let social_image: SocialImageMetadata = probe_social_image(¶ms.base, cache_buster);
127
128 if !social_image.is_large_enough() {
129 tracing::error!(
133 "the social image is {}x{}, below the 600x314 floor for a \
134 `summary_large_image` card; shares will degrade to a thumbnail",
135 social_image.width.unwrap_or_default(),
136 social_image.height.unwrap_or_default(),
137 );
138 }
139
140 let sitemap_urls: Vec<SitemapUrl> = params
143 .pages
144 .sitemap_urls()?
145 .into_iter()
146 .map(|url| url.map_images(|image| cache_buster.get_file(image)))
147 .collect();
148 let last_modified: Option<chrono::DateTime<chrono::Utc>> =
149 crate::assets::content_modified(&["html", "static"]);
150 let sitemaps: SitemapSet =
151 build_sitemaps(params.base.base_url(), &sitemap_urls, last_modified)
152 .map_err(WebServerError::Sitemap)?;
153
154 let well_known: WellKnown = WellKnown {
155 robots_txt: robots_txt(params.base.base_url(), &sitemaps),
156 humans_txt: humans_txt(¶ms.base),
157 webmanifest: webmanifest(¶ms.base),
158 sitemaps,
159 };
160
161 let runtime: FrontendRuntime = FrontendRuntime {
162 theme_script: params.base.theme_script().source(),
163 social_image,
164 has_svg_icon: icon_source.is_vector(),
165 analytics: AnalyticsPaths {
166 script_path: paths.analytics_script.clone(),
167 event_path: paths.analytics_event.clone(),
168 },
169 sentry_browser: SentryBrowser {
170 script_path: paths.sentry_script.clone(),
171 tunnel_path: paths.sentry_tunnel.clone(),
172 environment: environment.to_string(),
173 },
174 };
175
176 let not_found: (Arc<PageTemplateData>, Arc<Value>) = (
177 Arc::new(
178 PageTemplateData::new(NOT_FOUND_TEMPLATE_NAME, "404", "/404")
179 .with_robots(robots::NOINDEX_FOLLOW),
180 ),
181 Arc::new(Value::Null),
182 );
183
184 Ok(Self {
185 pages: params.pages,
186 base: params.base,
187 runtime,
188 well_known,
189 analytics: params.analytics,
190 sentry_dsn,
191 not_found,
192 has_svg_icon: icon_source.is_vector(),
193 })
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
206struct ProxyPaths {
207 analytics_script: String,
208 analytics_event: String,
209 sentry_script: String,
210 sentry_tunnel: String,
211}
212
213impl ProxyPaths {
214 fn derive(project: &str) -> Self {
215 let slug: String = slugify(project);
216 let analytics: String = short_hash(project);
217 let sentry: String = short_hash(&format!("{project}:sentry"));
220
221 Self {
222 analytics_script: format!("/script/{slug}-{analytics}.js"),
223 analytics_event: format!("{}/{slug}-{analytics}", super::server::API_PREFIX),
224 sentry_script: format!("/script/{slug}-{sentry}.js"),
225 sentry_tunnel: format!("{}/{slug}-{sentry}", super::server::API_PREFIX),
226 }
227 }
228}
229
230fn slugify(project: &str) -> String {
232 let mut slug: String = String::with_capacity(project.len());
233 let mut previous_dash: bool = true;
234 for character in project.chars() {
235 if character.is_ascii_alphanumeric() {
236 slug.push(character.to_ascii_lowercase());
237 previous_dash = false;
238 } else if !previous_dash {
239 slug.push('-');
240 previous_dash = true;
241 }
242 }
243 String::from(slug.trim_matches('-'))
244}
245
246fn short_hash(input: &str) -> String {
249 format!("{:x}", md5::compute(input.as_bytes()))
250 .chars()
251 .take(8)
252 .collect()
253}
254
255fn probe_social_image(base: &BaseTemplateData, cache_buster: &CacheBuster) -> SocialImageMetadata {
257 let hashed: String = cache_buster.get_file(base.social_image());
258 crate::assets::probe_social_image(&hashed)
259}
260
261fn robots_txt(base_url: &str, sitemaps: &SitemapSet) -> String {
266 const BUDGET: usize = 400 * 1024;
268
269 let mut robots: String = String::from("User-agent: *\nAllow: /\n\n");
270 let mut omitted: usize = 0;
271 for path in sitemaps.paths() {
272 let line: String = format!("Sitemap: {base_url}{path}\n");
273 if robots.len() + line.len() > BUDGET {
274 omitted += 1;
275 continue;
276 }
277 robots.push_str(&line);
278 }
279
280 if omitted > 0 {
281 tracing::error!(
284 "omitted {omitted} sitemap line(s) from robots.txt to stay under \
285 Google's 500 KiB limit; the index is still listed first"
286 );
287 }
288
289 robots
290}
291
292#[cfg(feature = "preset")]
295fn humans_txt(_base: &BaseTemplateData) -> String {
296 String::from(PRESET_HUMANS_TXT)
297}
298
299#[cfg(not(feature = "preset"))]
302fn humans_txt(base: &BaseTemplateData) -> String {
303 format!(
304 "/* TEAM */\n\nName: {}\nSite: {}\n",
305 base.author(),
306 base.base_url()
307 )
308}
309
310fn webmanifest(base: &BaseTemplateData) -> String {
316 const ICONS: [(&str, &str); 2] = [("/icon-192.png", "192x192"), ("/icon-512.png", "512x512")];
317
318 let icons: Vec<String> = ICONS
319 .iter()
320 .map(|(source, sizes)| {
321 format!(
322 "{{ \"src\": \"{source}\", \"sizes\": \"{sizes}\", \"type\": \"image/png\", \"purpose\": \"any maskable\" }}"
323 )
324 })
325 .collect();
326
327 let color: &str = base.theme_color().primary();
328
329 format!(
330 "{{\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",
331 name = base.project(),
332 description = base.description(),
333 icons = icons.join(",\n "),
334 )
335}
336
337#[cfg(test)]
338mod tests {
339 use super::{ProxyPaths, short_hash, slugify};
340
341 #[test]
342 fn a_project_name_becomes_a_lowercase_dashed_slug() {
343 assert_eq!(String::from("boggledygook"), slugify("Boggledygook"));
344 assert_eq!(String::from("eat-out"), slugify("Eat Out"));
345 assert_eq!(
346 String::from("template-web-server"),
347 slugify("Template Web Server")
348 );
349 assert_eq!(
350 String::from("palms-small-engine"),
351 slugify("Palms Small!Engine")
352 );
353 }
354
355 #[test]
356 fn the_proxy_paths_look_like_ordinary_bundler_output() {
357 let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
358
359 assert!(paths.analytics_script.starts_with("/script/boggledygook-"));
360 assert!(
361 std::path::Path::new(&paths.analytics_script)
362 .extension()
363 .is_some_and(|extension| extension.eq_ignore_ascii_case("js"))
364 );
365 assert!(paths.analytics_event.starts_with("/api/v1/boggledygook-"));
366
367 for path in [&paths.analytics_script, &paths.analytics_event] {
369 for forbidden in ["plausible", "analytics", "tracking", "stats"] {
370 assert!(!path.contains(forbidden), "`{path}` contains `{forbidden}`");
371 }
372 }
373 }
374
375 #[test]
376 fn the_two_proxies_do_not_share_a_hash() {
377 let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
378
379 assert_ne!(paths.analytics_script, paths.sentry_script);
380 assert_ne!(paths.analytics_event, paths.sentry_tunnel);
381 }
382
383 #[test]
384 fn two_projects_never_share_a_path_so_one_filter_rule_cannot_block_both() {
385 let first: ProxyPaths = ProxyPaths::derive("Boggledygook");
386 let second: ProxyPaths = ProxyPaths::derive("Eat Out");
387
388 assert_ne!(first.analytics_script, second.analytics_script);
389 }
390
391 #[test]
392 fn the_derived_hash_is_stable_across_runs() {
393 let expected: String = short_hash("Boggledygook");
394 let actual: String = short_hash("Boggledygook");
395 assert_eq!(expected, actual);
396
397 let expected: usize = 8;
398 let actual: usize = short_hash("Boggledygook").len();
399 assert_eq!(expected, actual);
400 }
401}