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::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 = crate::assets::content_modified(&["html", "static"]);
149 let sitemaps: SitemapSet =
150 build_sitemaps(params.base.base_url(), &sitemap_urls, last_modified)
151 .map_err(WebServerError::Sitemap)?;
152
153 let well_known: WellKnown = WellKnown {
154 robots_txt: robots_txt(params.base.base_url(), &sitemaps),
155 humans_txt: humans_txt(¶ms.base),
156 webmanifest: webmanifest(¶ms.base),
157 sitemaps,
158 };
159
160 let runtime: FrontendRuntime = FrontendRuntime {
161 theme_script: params.base.theme_script().source(),
162 social_image,
163 has_svg_icon: icon_source.is_vector(),
164 analytics: AnalyticsPaths {
165 script_path: paths.analytics_script.clone(),
166 event_path: paths.analytics_event.clone(),
167 },
168 sentry_browser: SentryBrowser {
169 script_path: paths.sentry_script.clone(),
170 tunnel_path: paths.sentry_tunnel.clone(),
171 environment: environment.to_string(),
172 },
173 };
174
175 let not_found: (Arc<PageTemplateData>, Arc<Value>) = (
176 Arc::new(
177 PageTemplateData::new(NOT_FOUND_TEMPLATE_NAME, "404", "/404")
178 .with_robots(robots::NOINDEX_FOLLOW),
179 ),
180 Arc::new(Value::Null),
181 );
182
183 Ok(Self {
184 pages: params.pages,
185 base: params.base,
186 runtime,
187 well_known,
188 analytics: params.analytics,
189 sentry_dsn,
190 not_found,
191 has_svg_icon: icon_source.is_vector(),
192 })
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
205struct ProxyPaths {
206 analytics_script: String,
207 analytics_event: String,
208 sentry_script: String,
209 sentry_tunnel: String,
210}
211
212impl ProxyPaths {
213 fn derive(project: &str) -> Self {
214 let slug: String = slugify(project);
215 let analytics: String = short_hash(project);
216 let sentry: String = short_hash(&format!("{project}:sentry"));
219
220 Self {
221 analytics_script: format!("/script/{slug}-{analytics}.js"),
222 analytics_event: format!("{}/{slug}-{analytics}", super::server::API_PREFIX),
223 sentry_script: format!("/script/{slug}-{sentry}.js"),
224 sentry_tunnel: format!("{}/{slug}-{sentry}", super::server::API_PREFIX),
225 }
226 }
227}
228
229fn slugify(project: &str) -> String {
231 let mut slug: String = String::with_capacity(project.len());
232 let mut previous_dash: bool = true;
233 for character in project.chars() {
234 if character.is_ascii_alphanumeric() {
235 slug.push(character.to_ascii_lowercase());
236 previous_dash = false;
237 } else if !previous_dash {
238 slug.push('-');
239 previous_dash = true;
240 }
241 }
242 String::from(slug.trim_matches('-'))
243}
244
245fn short_hash(input: &str) -> String {
248 format!("{:x}", md5::compute(input.as_bytes()))
249 .chars()
250 .take(8)
251 .collect()
252}
253
254fn probe_social_image(base: &BaseTemplateData, cache_buster: &CacheBuster) -> SocialImageMetadata {
256 let hashed: String = cache_buster.get_file(base.social_image());
257 crate::assets::probe_social_image(&hashed)
258}
259
260fn robots_txt(base_url: &str, sitemaps: &SitemapSet) -> String {
265 const BUDGET: usize = 400 * 1024;
267
268 let mut robots: String = String::from("User-agent: *\nAllow: /\n\n");
269 let mut omitted: usize = 0;
270 for path in sitemaps.paths() {
271 let line: String = format!("Sitemap: {base_url}{path}\n");
272 if robots.len() + line.len() > BUDGET {
273 omitted += 1;
274 continue;
275 }
276 robots.push_str(&line);
277 }
278
279 if omitted > 0 {
280 tracing::error!(
283 "omitted {omitted} sitemap line(s) from robots.txt to stay under \
284 Google's 500 KiB limit; the index is still listed first"
285 );
286 }
287
288 robots
289}
290
291#[cfg(feature = "preset")]
294fn humans_txt(_base: &BaseTemplateData) -> String {
295 String::from(PRESET_HUMANS_TXT)
296}
297
298#[cfg(not(feature = "preset"))]
301fn humans_txt(base: &BaseTemplateData) -> String {
302 format!(
303 "/* TEAM */\n\nName: {}\nSite: {}\n",
304 base.author(),
305 base.base_url()
306 )
307}
308
309fn webmanifest(base: &BaseTemplateData) -> String {
315 const ICONS: [(&str, &str); 2] = [("/icon-192.png", "192x192"), ("/icon-512.png", "512x512")];
316
317 let icons: Vec<String> = ICONS
318 .iter()
319 .map(|(source, sizes)| {
320 format!(
321 "{{ \"src\": \"{source}\", \"sizes\": \"{sizes}\", \"type\": \"image/png\", \"purpose\": \"any maskable\" }}"
322 )
323 })
324 .collect();
325
326 let color: &str = base.theme_color().primary();
327
328 format!(
329 "{{\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",
330 name = base.project(),
331 description = base.description(),
332 icons = icons.join(",\n "),
333 )
334}
335
336#[cfg(test)]
337mod tests {
338 use super::{ProxyPaths, short_hash, slugify};
339
340 #[test]
341 fn a_project_name_becomes_a_lowercase_dashed_slug() {
342 assert_eq!(String::from("boggledygook"), slugify("Boggledygook"));
343 assert_eq!(String::from("eat-out"), slugify("Eat Out"));
344 assert_eq!(
345 String::from("template-web-server"),
346 slugify("Template Web Server")
347 );
348 assert_eq!(
349 String::from("palms-small-engine"),
350 slugify("Palms Small!Engine")
351 );
352 }
353
354 #[test]
355 fn the_proxy_paths_look_like_ordinary_bundler_output() {
356 let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
357
358 assert!(paths.analytics_script.starts_with("/script/boggledygook-"));
359 assert!(
360 std::path::Path::new(&paths.analytics_script)
361 .extension()
362 .is_some_and(|extension| extension.eq_ignore_ascii_case("js"))
363 );
364 assert!(paths.analytics_event.starts_with("/api/v1/boggledygook-"));
365
366 for path in [&paths.analytics_script, &paths.analytics_event] {
368 for forbidden in ["plausible", "analytics", "tracking", "stats"] {
369 assert!(!path.contains(forbidden), "`{path}` contains `{forbidden}`");
370 }
371 }
372 }
373
374 #[test]
375 fn the_two_proxies_do_not_share_a_hash() {
376 let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
377
378 assert_ne!(paths.analytics_script, paths.sentry_script);
379 assert_ne!(paths.analytics_event, paths.sentry_tunnel);
380 }
381
382 #[test]
383 fn two_projects_never_share_a_path_so_one_filter_rule_cannot_block_both() {
384 let first: ProxyPaths = ProxyPaths::derive("Boggledygook");
385 let second: ProxyPaths = ProxyPaths::derive("Eat Out");
386
387 assert_ne!(first.analytics_script, second.analytics_script);
388 }
389
390 #[test]
391 fn the_derived_hash_is_stable_across_runs() {
392 let expected: String = short_hash("Boggledygook");
393 let actual: String = short_hash("Boggledygook");
394 assert_eq!(expected, actual);
395
396 let expected: usize = 8;
397 let actual: usize = short_hash("Boggledygook").len();
398 assert_eq!(expected, actual);
399 }
400}