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::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 = 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(&params.base),
156            webmanifest: webmanifest(&params.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/// Where the first-party proxies live on this origin.
197///
198/// Derived from the project name, never configured. Plausible's guidance is to
199/// avoid their documented default paths because blocklists target them, and to
200/// avoid words like "analytics" or "stats". A per-project name also means no
201/// single filter rule can take out every site at once, which a shared
202/// library-wide constant would invite. The shape — `name-hash.js` — is what
203/// every bundler on the web already emits.
204#[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        // A second, unrelated hash, so the two scripts do not visibly belong to
217        // one another.
218        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
229/// `Boggledygook!` → `boggledygook`.
230fn 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
245/// Eight hex characters: enough to be unguessable, short enough to look like
246/// ordinary bundler output.
247fn short_hash(input: &str) -> String {
248    format!("{:x}", md5::compute(input.as_bytes()))
249        .chars()
250        .take(8)
251        .collect()
252}
253
254/// Resolves and measures the social card image on disk.
255fn 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
260/// `robots.txt`, naming the sitemap index first and then every url set.
261///
262/// Index first is deliberate: Google discards a robots.txt past 500 KiB, and
263/// truncation is positional, so the one line that must survive goes at the top.
264fn robots_txt(base_url: &str, sitemaps: &SitemapSet) -> String {
265    /// Well under Google's 500 KiB ceiling, with room for the directives above.
266    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        // Silent truncation is the failure mode this whole design avoids, so
281        // the generator must not commit it either.
282        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/// The site's humans.txt: the author's own, written once and inherited by every
292/// project that enables `preset`.
293#[cfg(feature = "preset")]
294fn humans_txt(_base: &BaseTemplateData) -> String {
295    String::from(PRESET_HUMANS_TXT)
296}
297
298/// A minimal humans.txt, so the layout's `rel="author"` link never 404s on a
299/// project that does not use the author's own.
300#[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
309/// The web app manifest.
310///
311/// `minimal-ui` rather than `standalone`: an installed multi-page content site
312/// with no back button strands the reader. It still satisfies the installability
313/// bar, so nothing is given up.
314fn 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        // None of Plausible's forbidden words appear anywhere.
367        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}