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::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
24/// Everything a frontend requires.
25///
26/// A named-field struct rather than five positional arguments: two of them are
27/// strings, and this one cannot be built with them transposed.
28pub struct FrontendParams<S = ()> {
29    /// What is true of every page on the site.
30    pub base: BaseTemplateData,
31    /// The pages themselves, which become both routes and sitemap entries.
32    pub pages: Pages<S>,
33    /// Which Plausible site this frontend reports to.
34    pub analytics: AnalyticsConfig,
35    /// The browser Sentry DSN — separate from the server's, because Sentry
36    /// recommends a project per language and per deployable, and mixing Rust
37    /// panics with JavaScript exceptions in one stream helps nobody.
38    pub sentry_browser_dsn: String,
39}
40
41impl<S> FrontendParams<S>
42where
43    S: Clone + Send + Sync + 'static,
44{
45    /// Reads the analytics script id and browser DSN from the environment.
46    ///
47    /// # Errors
48    ///
49    /// [`WebServerError::Env`] if either variable is unset or blank.
50    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/// The well-known documents a frontend serves, held in memory.
62///
63/// None of these is written to disk. They are generated (robots, sitemaps, the
64/// web manifest) or embedded (humans.txt), they are served at fixed never-cached
65/// routes where a content hash would mean nothing, and keeping them in memory is
66/// what lets a production image be read-only.
67#[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
75/// A frontend, assembled.
76pub 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    /// The 404, declared by the library rather than by each project.
84    pub not_found: (Arc<PageTemplateData>, Arc<Value>),
85    /// Whether an SVG icon exists to link.
86    pub has_svg_icon: bool,
87}
88
89/// The bytes of Todd Everett Griffin's humans.txt, shared by every project that
90/// enables `preset` so it is written once and never drifts.
91#[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    /// Builds a frontend: derives the proxy paths, probes the social image,
99    /// and renders the well-known documents.
100    ///
101    /// # Errors
102    ///
103    /// [`WebServerError::SentryDsn`] if the browser DSN is malformed, or
104    /// [`WebServerError::Sitemap`] if the sitemaps cannot be built.
105    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(&params.sentry_browser_dsn).map_err(WebServerError::SentryDsn)?;
112
113        // Every asset a page will reference is proved to resolve before the
114        // server binds. Falling back to an un-hashed path would produce a link
115        // that 404s for the visitor and reports nothing to us.
116        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(&params.base, cache_buster);
127
128        if !social_image.is_large_enough() {
129            // Boots, but every X share renders a thumbnail instead of the wide
130            // card the layout declares — exactly the kind of silent degradation
131            // that must reach Sentry, and `warn!` never does.
132            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        // Images are declared logically but served hashed; the sitemap has to
141        // name the URL that actually resolves.
142        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(&params.base),
157            webmanifest: webmanifest(&params.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/// Where the first-party proxies live on this origin.
198///
199/// Derived from the project name, never configured. Plausible's guidance is to
200/// avoid their documented default paths because blocklists target them, and to
201/// avoid words like "analytics" or "stats". A per-project name also means no
202/// single filter rule can take out every site at once, which a shared
203/// library-wide constant would invite. The shape — `name-hash.js` — is what
204/// every bundler on the web already emits.
205#[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        // A second, unrelated hash, so the two scripts do not visibly belong to
218        // one another.
219        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
230/// `Boggledygook!` → `boggledygook`.
231fn 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
246/// Eight hex characters: enough to be unguessable, short enough to look like
247/// ordinary bundler output.
248fn short_hash(input: &str) -> String {
249    format!("{:x}", md5::compute(input.as_bytes()))
250        .chars()
251        .take(8)
252        .collect()
253}
254
255/// Resolves and measures the social card image on disk.
256fn 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
261/// `robots.txt`, naming the sitemap index first and then every url set.
262///
263/// Index first is deliberate: Google discards a robots.txt past 500 KiB, and
264/// truncation is positional, so the one line that must survive goes at the top.
265fn robots_txt(base_url: &str, sitemaps: &SitemapSet) -> String {
266    /// Well under Google's 500 KiB ceiling, with room for the directives above.
267    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        // Silent truncation is the failure mode this whole design avoids, so
282        // the generator must not commit it either.
283        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/// The site's humans.txt: the author's own, written once and inherited by every
293/// project that enables `preset`.
294#[cfg(feature = "preset")]
295fn humans_txt(_base: &BaseTemplateData) -> String {
296    String::from(PRESET_HUMANS_TXT)
297}
298
299/// A minimal humans.txt, so the layout's `rel="author"` link never 404s on a
300/// project that does not use the author's own.
301#[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
310/// The web app manifest.
311///
312/// `minimal-ui` rather than `standalone`: an installed multi-page content site
313/// with no back button strands the reader. It still satisfies the installability
314/// bar, so nothing is given up.
315fn 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        // None of Plausible's forbidden words appear anywhere.
368        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}