1use crate::analytics::{AnalyticsConfig, SentryDsn};
9use crate::assets::CacheBuster;
10use crate::feed::{ATOM_PATH, Feed, FeedSet, FeedSite, JSON_PATH, RSS_PATH, build_feeds};
11use crate::sitemap::{SitemapSet, SitemapUrl, build_sitemaps};
12use std::sync::Arc;
13
14use serde_json::Value;
15
16use crate::templates::{
17 AnalyticsPaths, BaseTemplateData, FeedLinks, FrontendRuntime, NOT_FOUND_TEMPLATE_NAME,
18 PageTemplateData, SentryBrowser, SocialImageMetadata, robots,
19};
20use crate::{Environment, env};
21use chrono::Datelike as _;
22
23use super::error::WebServerError;
24use super::pages::Pages;
25
26pub struct FrontendParams<S = ()> {
31 pub base: BaseTemplateData,
33 pub pages: Pages<S>,
35 pub analytics: AnalyticsConfig,
37 pub sentry_browser_dsn: String,
41}
42
43impl<S> FrontendParams<S>
44where
45 S: Clone + Send + Sync + 'static,
46{
47 pub fn from_env(base: BaseTemplateData, pages: Pages<S>) -> Result<Self, WebServerError> {
53 Ok(Self {
54 base,
55 pages,
56 analytics: AnalyticsConfig::from_env().map_err(WebServerError::Env)?,
57 sentry_browser_dsn: env::required(crate::analytics::ENV_SENTRY_BROWSER_DSN)
58 .map_err(WebServerError::Env)?,
59 })
60 }
61}
62
63#[derive(Debug, Clone)]
70pub struct WellKnown {
71 pub robots_txt: String,
72 pub humans_txt: String,
73 pub webmanifest: String,
74 pub sitemaps: SitemapSet,
75 pub feeds: Option<FeedSet>,
80}
81
82pub struct Frontend<S> {
84 pub pages: Pages<S>,
85 pub base: BaseTemplateData,
86 pub runtime: FrontendRuntime,
87 pub well_known: WellKnown,
88 pub analytics: AnalyticsConfig,
89 pub sentry_dsn: SentryDsn,
90 pub not_found: (Arc<PageTemplateData>, Arc<Value>),
92 pub has_svg_icon: bool,
94}
95
96#[cfg(feature = "preset")]
99const PRESET_HUMANS_TXT: &str = include_str!("../../assets/humans.txt");
100
101impl<S> Frontend<S>
102where
103 S: Clone + Send + Sync + 'static,
104{
105 pub fn build(
113 params: FrontendParams<S>,
114 feed: Option<Feed>,
115 cache_buster: &CacheBuster,
116 environment: Environment,
117 ) -> Result<Self, WebServerError> {
118 let sentry_dsn: SentryDsn =
119 SentryDsn::parse(¶ms.sentry_browser_dsn).map_err(WebServerError::SentryDsn)?;
120
121 let mut declared: Vec<String> = Vec::new();
125 declared.extend(params.base.style_sheets().iter().cloned());
126 declared.extend(params.base.scripts().iter().cloned());
127 declared.push(params.base.social_image().to_string());
128 declared.extend(params.pages.declared_assets());
129 crate::assets::validate_declared(cache_buster, &declared)?;
130
131 let icon_source: crate::assets::IconSource = crate::assets::validate_icons(cache_buster)?;
132
133 let paths: ProxyPaths = ProxyPaths::derive(params.base.project());
134 let social_image: SocialImageMetadata = probe_social_image(¶ms.base, cache_buster);
135
136 if !social_image.is_large_enough() {
137 tracing::error!(
141 "the social image is {}x{}, below the 600x314 floor for a \
142 `summary_large_image` card; shares will degrade to a thumbnail",
143 social_image.width.unwrap_or_default(),
144 social_image.height.unwrap_or_default(),
145 );
146 }
147
148 let sitemap_urls: Vec<SitemapUrl> = params
151 .pages
152 .sitemap_urls()?
153 .into_iter()
154 .map(|url| url.map_images(|image| cache_buster.get_file(image)))
155 .collect();
156 let last_modified: Option<chrono::DateTime<chrono::Utc>> =
157 crate::assets::content_modified_in(cache_buster.root(), &["html", "static"]);
158 let sitemaps: SitemapSet =
159 build_sitemaps(params.base.base_url(), &sitemap_urls, last_modified)
160 .map_err(WebServerError::Sitemap)?;
161
162 let feed_title: Option<String> = feed.as_ref().map(|feed| feed.title.clone());
163 let feeds: Option<FeedSet> = match feed {
164 None => None,
165 Some(feed) => {
166 if !params.pages.paths().contains(&feed.page_url.as_str()) {
167 return Err(WebServerError::Feed(
168 crate::feed::FeedError::PageNotDeclared {
169 page_url: feed.page_url.clone(),
170 },
171 ));
172 }
173 let site: FeedSite = feed_site(¶ms.base, cache_buster);
174 Some(
175 build_feeds(&site, &feed, |path: &str| {
176 cache_buster
177 .is_hashed(path)
178 .then(|| cache_buster.get_file(path))
179 })
180 .map_err(WebServerError::Feed)?,
181 )
182 }
183 };
184
185 let feed_links: Option<FeedLinks> = feed_title.map(|title| FeedLinks {
186 title,
187 rss: format!("{}{RSS_PATH}", params.base.base_url()),
188 atom: format!("{}{ATOM_PATH}", params.base.base_url()),
189 json: format!("{}{JSON_PATH}", params.base.base_url()),
190 });
191
192 let well_known: WellKnown = WellKnown {
193 robots_txt: robots_txt(params.base.base_url(), &sitemaps, feeds.is_some()),
194 humans_txt: humans_txt(¶ms.base),
195 webmanifest: webmanifest(¶ms.base),
196 sitemaps,
197 feeds,
198 };
199
200 let runtime: FrontendRuntime = FrontendRuntime {
201 theme_script: params.base.theme_script().source(),
202 social_image,
203 has_svg_icon: icon_source.is_vector(),
204 analytics: AnalyticsPaths {
205 script_path: paths.analytics_script.clone(),
206 event_path: paths.analytics_event.clone(),
207 },
208 sentry_browser: SentryBrowser {
209 script_path: paths.sentry_script.clone(),
210 tunnel_path: paths.sentry_tunnel.clone(),
211 environment: environment.to_string(),
212 },
213 feed: feed_links,
214 };
215
216 let not_found: (Arc<PageTemplateData>, Arc<Value>) = (
217 Arc::new(
218 PageTemplateData::new(NOT_FOUND_TEMPLATE_NAME, "404", "/404")
219 .with_robots(robots::NOINDEX_FOLLOW),
220 ),
221 Arc::new(Value::Null),
222 );
223
224 Ok(Self {
225 pages: params.pages,
226 base: params.base,
227 runtime,
228 well_known,
229 analytics: params.analytics,
230 sentry_dsn,
231 not_found,
232 has_svg_icon: icon_source.is_vector(),
233 })
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
246struct ProxyPaths {
247 analytics_script: String,
248 analytics_event: String,
249 sentry_script: String,
250 sentry_tunnel: String,
251}
252
253impl ProxyPaths {
254 fn derive(project: &str) -> Self {
255 let slug: String = slugify(project);
256 let analytics: String = short_hash(project);
257 let sentry: String = short_hash(&format!("{project}:sentry"));
260
261 Self {
262 analytics_script: format!("/script/{slug}-{analytics}.js"),
263 analytics_event: format!("{}/{slug}-{analytics}", super::server::API_PREFIX),
264 sentry_script: format!("/script/{slug}-{sentry}.js"),
265 sentry_tunnel: format!("{}/{slug}-{sentry}", super::server::API_PREFIX),
266 }
267 }
268}
269
270fn slugify(project: &str) -> String {
272 let mut slug: String = String::with_capacity(project.len());
273 let mut previous_dash: bool = true;
274 for character in project.chars() {
275 if character.is_ascii_alphanumeric() {
276 slug.push(character.to_ascii_lowercase());
277 previous_dash = false;
278 } else if !previous_dash {
279 slug.push('-');
280 previous_dash = true;
281 }
282 }
283 String::from(slug.trim_matches('-'))
284}
285
286fn short_hash(input: &str) -> String {
289 format!("{:x}", md5::compute(input.as_bytes()))
290 .chars()
291 .take(8)
292 .collect()
293}
294
295fn feed_site(base: &BaseTemplateData, cache_buster: &CacheBuster) -> FeedSite {
301 let absolute = |path: &str| format!("{}{path}", base.base_url());
302 let year: i32 = chrono::Utc::now().year();
303
304 FeedSite {
305 base_url: String::from(base.base_url()),
306 author: String::from(base.author()),
307 language: format!("{}-{}", base.language_code(), base.country_code()),
309 icon_url: cache_buster
310 .is_hashed("static/image/favicon/favicon-512.png")
311 .then(|| absolute("/icon-512.png")),
312 favicon_url: cache_buster
313 .is_hashed("static/image/favicon/favicon.ico")
314 .then(|| absolute("/favicon.ico")),
315 copyright: format!("© {}–{year} {}", base.copyright_start(), base.author()),
316 }
317}
318
319fn probe_social_image(base: &BaseTemplateData, cache_buster: &CacheBuster) -> SocialImageMetadata {
321 crate::assets::probe_social_image(cache_buster.file(base.social_image()))
322}
323
324fn robots_txt(base_url: &str, sitemaps: &SitemapSet, has_feed: bool) -> String {
338 const BUDGET: usize = 400 * 1024;
340
341 let mut paths: Vec<String> = sitemaps.paths();
342 if has_feed {
343 paths.insert(1, String::from(ATOM_PATH));
344 }
345
346 let mut robots: String = String::from("User-agent: *\nAllow: /\n\n");
347 let mut omitted: usize = 0;
348 for path in paths {
349 let line: String = format!("Sitemap: {base_url}{path}\n");
350 if robots.len() + line.len() > BUDGET {
351 omitted += 1;
352 continue;
353 }
354 robots.push_str(&line);
355 }
356
357 if omitted > 0 {
358 tracing::error!(
361 "omitted {omitted} sitemap line(s) from robots.txt to stay under \
362 Google's 500 KiB limit; the index is still listed first"
363 );
364 }
365
366 robots
367}
368
369#[cfg(feature = "preset")]
372fn humans_txt(_base: &BaseTemplateData) -> String {
373 String::from(PRESET_HUMANS_TXT)
374}
375
376#[cfg(not(feature = "preset"))]
379fn humans_txt(base: &BaseTemplateData) -> String {
380 format!(
381 "/* TEAM */\n\nName: {}\nSite: {}\n",
382 base.author(),
383 base.base_url()
384 )
385}
386
387fn webmanifest(base: &BaseTemplateData) -> String {
393 const ICONS: [(&str, &str); 2] = [("/icon-192.png", "192x192"), ("/icon-512.png", "512x512")];
394
395 let icons: Vec<String> = ICONS
396 .iter()
397 .map(|(source, sizes)| {
398 format!(
399 "{{ \"src\": \"{source}\", \"sizes\": \"{sizes}\", \"type\": \"image/png\", \"purpose\": \"any maskable\" }}"
400 )
401 })
402 .collect();
403
404 let color: &str = base.theme_color().primary();
405
406 format!(
407 "{{\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",
408 name = base.project(),
409 description = base.description(),
410 icons = icons.join(",\n "),
411 )
412}
413
414#[cfg(test)]
415mod tests {
416 use super::{ProxyPaths, short_hash, slugify};
417
418 #[test]
419 fn a_project_name_becomes_a_lowercase_dashed_slug() {
420 assert_eq!(String::from("boggledygook"), slugify("Boggledygook"));
421 assert_eq!(String::from("eat-out"), slugify("Eat Out"));
422 assert_eq!(
423 String::from("template-web-server"),
424 slugify("Template Web Server")
425 );
426 assert_eq!(
427 String::from("palms-small-engine"),
428 slugify("Palms Small!Engine")
429 );
430 }
431
432 #[test]
433 fn the_proxy_paths_look_like_ordinary_bundler_output() {
434 let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
435
436 assert!(paths.analytics_script.starts_with("/script/boggledygook-"));
437 assert!(
438 std::path::Path::new(&paths.analytics_script)
439 .extension()
440 .is_some_and(|extension| extension.eq_ignore_ascii_case("js"))
441 );
442 assert!(paths.analytics_event.starts_with("/api/v1/boggledygook-"));
443
444 for path in [&paths.analytics_script, &paths.analytics_event] {
446 for forbidden in ["plausible", "analytics", "tracking", "stats"] {
447 assert!(!path.contains(forbidden), "`{path}` contains `{forbidden}`");
448 }
449 }
450 }
451
452 #[test]
453 fn the_two_proxies_do_not_share_a_hash() {
454 let paths: ProxyPaths = ProxyPaths::derive("Boggledygook");
455
456 assert_ne!(paths.analytics_script, paths.sentry_script);
457 assert_ne!(paths.analytics_event, paths.sentry_tunnel);
458 }
459
460 #[test]
461 fn two_projects_never_share_a_path_so_one_filter_rule_cannot_block_both() {
462 let first: ProxyPaths = ProxyPaths::derive("Boggledygook");
463 let second: ProxyPaths = ProxyPaths::derive("Eat Out");
464
465 assert_ne!(first.analytics_script, second.analytics_script);
466 }
467
468 #[test]
469 fn the_derived_hash_is_stable_across_runs() {
470 let expected: String = short_hash("Boggledygook");
471 let actual: String = short_hash("Boggledygook");
472 assert_eq!(expected, actual);
473
474 let expected: usize = 8;
475 let actual: usize = short_hash("Boggledygook").len();
476 assert_eq!(expected, actual);
477 }
478}