Skip to main content

webserver_base/webserver/
frontend.rs

1//! What it takes to serve HTML to humans.
2//!
3//! `WebServer::frontend` is the line between a site and a service. Everything a
4//! frontend *must* have is a field here, so omitting one is a compile error
5//! rather than a page that quietly ships without a share card. A server that
6//! never calls it — a sidecar, a JSON API — needs none of it and boots clean.
7
8use 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
26/// Everything a frontend requires.
27///
28/// A named-field struct rather than five positional arguments: two of them are
29/// strings, and this one cannot be built with them transposed.
30pub struct FrontendParams<S = ()> {
31    /// What is true of every page on the site.
32    pub base: BaseTemplateData,
33    /// The pages themselves, which become both routes and sitemap entries.
34    pub pages: Pages<S>,
35    /// Which Plausible site this frontend reports to.
36    pub analytics: AnalyticsConfig,
37    /// The browser Sentry DSN — separate from the server's, because Sentry
38    /// recommends a project per language and per deployable, and mixing Rust
39    /// panics with JavaScript exceptions in one stream helps nobody.
40    pub sentry_browser_dsn: String,
41}
42
43impl<S> FrontendParams<S>
44where
45    S: Clone + Send + Sync + 'static,
46{
47    /// Reads the analytics script id and browser DSN from the environment.
48    ///
49    /// # Errors
50    ///
51    /// [`WebServerError::Env`] if either variable is unset or blank.
52    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/// The well-known documents a frontend serves, held in memory.
64///
65/// None of these is written to disk. They are generated (robots, sitemaps, the
66/// web manifest) or embedded (humans.txt), they are served at fixed never-cached
67/// routes where a content hash would mean nothing, and keeping them in memory is
68/// what lets a production image be read-only.
69#[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    /// The three feed documents, when the site declared a feed. Unlike the rest
76    /// of this struct these are *not* served uncached: a feed is the most-polled
77    /// document a site has, and stripping its validators would re-send every
78    /// byte on every poll.
79    pub feeds: Option<FeedSet>,
80}
81
82/// A frontend, assembled.
83pub 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    /// The 404, declared by the library rather than by each project.
91    pub not_found: (Arc<PageTemplateData>, Arc<Value>),
92    /// Whether an SVG icon exists to link.
93    pub has_svg_icon: bool,
94}
95
96/// The bytes of Todd Everett Griffin's humans.txt, shared by every project that
97/// enables `preset` so it is written once and never drifts.
98#[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    /// Builds a frontend: derives the proxy paths, probes the social image,
106    /// and renders the well-known documents.
107    ///
108    /// # Errors
109    ///
110    /// [`WebServerError::SentryDsn`] if the browser DSN is malformed, or
111    /// [`WebServerError::Sitemap`] if the sitemaps cannot be built.
112    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(&params.sentry_browser_dsn).map_err(WebServerError::SentryDsn)?;
120
121        // Every asset a page will reference is proved to resolve before the
122        // server binds. Falling back to an un-hashed path would produce a link
123        // that 404s for the visitor and reports nothing to us.
124        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(&params.base, cache_buster);
135
136        if !social_image.is_large_enough() {
137            // Boots, but every X share renders a thumbnail instead of the wide
138            // card the layout declares — exactly the kind of silent degradation
139            // that must reach Sentry, and `warn!` never does.
140            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        // Images are declared logically but served hashed; the sitemap has to
149        // name the URL that actually resolves.
150        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(&params.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(&params.base),
195            webmanifest: webmanifest(&params.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/// Where the first-party proxies live on this origin.
238///
239/// Derived from the project name, never configured. Plausible's guidance is to
240/// avoid their documented default paths because blocklists target them, and to
241/// avoid words like "analytics" or "stats". A per-project name also means no
242/// single filter rule can take out every site at once, which a shared
243/// library-wide constant would invite. The shape — `name-hash.js` — is what
244/// every bundler on the web already emits.
245#[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        // A second, unrelated hash, so the two scripts do not visibly belong to
258        // one another.
259        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
270/// `Boggledygook!` → `boggledygook`.
271fn 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
286/// Eight hex characters: enough to be unguessable, short enough to look like
287/// ordinary bundler output.
288fn short_hash(input: &str) -> String {
289    format!("{:x}", md5::compute(input.as_bytes()))
290        .chars()
291        .take(8)
292        .collect()
293}
294
295/// Channel metadata for the feed, derived rather than configured a second time.
296///
297/// Everything here already exists on [`BaseTemplateData`] or in the generated
298/// icon set; asking a project to restate it would be one more pair of values
299/// free to drift apart.
300fn 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        // RFC 5646 wants a hyphen where `og:locale` wants an underscore.
308        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
319/// Resolves and measures the social card image on disk.
320fn probe_social_image(base: &BaseTemplateData, cache_buster: &CacheBuster) -> SocialImageMetadata {
321    crate::assets::probe_social_image(cache_buster.file(base.social_image()))
322}
323
324/// `robots.txt`, naming the sitemap index, then the feed, then every url set.
325///
326/// Order is deliberate: Google discards a robots.txt past 500 KiB and
327/// truncation is positional, so the lines that must survive go at the top.
328///
329/// Only Atom is listed, and only once. Google accepts an RSS 2.0 or Atom 1.0
330/// file as a sitemap and recommends submitting one *alongside* a full sitemap —
331/// the sitemap is the inventory, the feed is the recency signal. Atom wins
332/// because it has a real per-entry `<updated>`; RSS carries publication dates
333/// only, so a revised post looks untouched. Listing both would report the same
334/// URLs twice for nothing, and JSON Feed is not a supported sitemap format at
335/// all. The feed outranks the `sitemap-N.xml` lines for survival because those
336/// are already named by the index above them.
337fn robots_txt(base_url: &str, sitemaps: &SitemapSet, has_feed: bool) -> String {
338    /// Well under Google's 500 KiB ceiling, with room for the directives above.
339    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        // Silent truncation is the failure mode this whole design avoids, so
359        // the generator must not commit it either.
360        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/// The site's humans.txt: the author's own, written once and inherited by every
370/// project that enables `preset`.
371#[cfg(feature = "preset")]
372fn humans_txt(_base: &BaseTemplateData) -> String {
373    String::from(PRESET_HUMANS_TXT)
374}
375
376/// A minimal humans.txt, so the layout's `rel="author"` link never 404s on a
377/// project that does not use the author's own.
378#[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
387/// The web app manifest.
388///
389/// `minimal-ui` rather than `standalone`: an installed multi-page content site
390/// with no back button strands the reader. It still satisfies the installability
391/// bar, so nothing is given up.
392fn 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        // None of Plausible's forbidden words appear anywhere.
445        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}