Skip to main content

pamoja_dashboard/
assets.rs

1//! The static page assets, served either embedded or live from disk.
2//!
3//! In production the page is baked into the firmware with `include_bytes!`, so there
4//! is no filesystem dependency and the dashboard is part of the image. In development
5//! the same directory is read from disk on every request, so editing the app and
6//! reloading shows the change with no recompile. The dashboard is a multi-file
7//! ES-module zQuery app, so both modes resolve any nested path under the web root.
8
9#[cfg(feature = "serve")]
10use std::path::PathBuf;
11
12/// One bundled file: its URL path, its MIME type, and its bytes.
13struct Asset {
14    path: &'static str,
15    content_type: &'static str,
16    bytes: &'static [u8],
17}
18
19const HTML: &str = "text/html; charset=utf-8";
20const CSS: &str = "text/css; charset=utf-8";
21const JS: &str = "application/javascript; charset=utf-8";
22// Only the full bundle embeds the per-locale JSON; the floor tier carries none.
23#[cfg(not(feature = "tier-c"))]
24const JSON: &str = "application/json; charset=utf-8";
25
26// The smallest tier embeds only the self-contained floor page at `/`: one ultra-minimal,
27// gzippable document that renders the status table with the smallest possible script, and
28// degrades to the device's server-rendered `/lite` table when scripting is off. The rich app
29// modules are not part of this image.
30#[cfg(feature = "tier-c")]
31const EMBEDDED: &[Asset] = &[Asset {
32    path: "/",
33    content_type: HTML,
34    bytes: include_bytes!("../web/lite.html"),
35}];
36
37// The full bundle, embedded at compile time. `/` maps to the page shell. The order does not
38// matter; lookups are by exact path.
39#[cfg(not(feature = "tier-c"))]
40const EMBEDDED: &[Asset] = &[
41    Asset {
42        path: "/",
43        content_type: HTML,
44        bytes: include_bytes!("../web/index.html"),
45    },
46    Asset {
47        path: "/global.css",
48        content_type: CSS,
49        bytes: include_bytes!("../web/global.css"),
50    },
51    // The floor page is also reachable from the full app (the top bar's "Lite" link), so a
52    // viewer on a weak phone can drop to it; it is excluded from the page-load footprint.
53    Asset {
54        path: "/lite.html",
55        content_type: HTML,
56        bytes: include_bytes!("../web/lite.html"),
57    },
58    Asset {
59        path: "/zquery.min.js",
60        content_type: JS,
61        bytes: include_bytes!("../web/zquery.min.js"),
62    },
63    Asset {
64        path: "/app/app.js",
65        content_type: JS,
66        bytes: include_bytes!("../web/app/app.js"),
67    },
68    Asset {
69        path: "/app/store.js",
70        content_type: JS,
71        bytes: include_bytes!("../web/app/store.js"),
72    },
73    Asset {
74        path: "/app/routes.js",
75        content_type: JS,
76        bytes: include_bytes!("../web/app/routes.js"),
77    },
78    Asset {
79        path: "/app/nav.js",
80        content_type: JS,
81        bytes: include_bytes!("../web/app/nav.js"),
82    },
83    Asset {
84        path: "/app/lib/feed.js",
85        content_type: JS,
86        bytes: include_bytes!("../web/app/lib/feed.js"),
87    },
88    Asset {
89        path: "/app/lib/edits.js",
90        content_type: JS,
91        bytes: include_bytes!("../web/app/lib/edits.js"),
92    },
93    Asset {
94        path: "/app/lib/catalog.js",
95        content_type: JS,
96        bytes: include_bytes!("../web/app/lib/catalog.js"),
97    },
98    Asset {
99        path: "/app/lib/discovery.js",
100        content_type: JS,
101        bytes: include_bytes!("../web/app/lib/discovery.js"),
102    },
103    Asset {
104        path: "/app/lib/detail.js",
105        content_type: JS,
106        bytes: include_bytes!("../web/app/lib/detail.js"),
107    },
108    Asset {
109        path: "/app/lib/i18n.js",
110        content_type: JS,
111        bytes: include_bytes!("../web/app/lib/i18n.js"),
112    },
113    Asset {
114        path: "/app/lib/pair.js",
115        content_type: JS,
116        bytes: include_bytes!("../web/app/lib/pair.js"),
117    },
118    Asset {
119        path: "/app/lib/crypto/bytes.js",
120        content_type: JS,
121        bytes: include_bytes!("../web/app/lib/crypto/bytes.js"),
122    },
123    Asset {
124        path: "/app/lib/crypto/sha256.js",
125        content_type: JS,
126        bytes: include_bytes!("../web/app/lib/crypto/sha256.js"),
127    },
128    Asset {
129        path: "/app/lib/crypto/hmac.js",
130        content_type: JS,
131        bytes: include_bytes!("../web/app/lib/crypto/hmac.js"),
132    },
133    Asset {
134        path: "/app/lib/crypto/hkdf.js",
135        content_type: JS,
136        bytes: include_bytes!("../web/app/lib/crypto/hkdf.js"),
137    },
138    Asset {
139        path: "/app/lib/viz/index.js",
140        content_type: JS,
141        bytes: include_bytes!("../web/app/lib/viz/index.js"),
142    },
143    Asset {
144        path: "/app/lib/viz/util.js",
145        content_type: JS,
146        bytes: include_bytes!("../web/app/lib/viz/util.js"),
147    },
148    Asset {
149        path: "/app/lib/viz/links.js",
150        content_type: JS,
151        bytes: include_bytes!("../web/app/lib/viz/links.js"),
152    },
153    Asset {
154        path: "/app/lib/viz/charts.js",
155        content_type: JS,
156        bytes: include_bytes!("../web/app/lib/viz/charts.js"),
157    },
158    Asset {
159        path: "/app/lib/viz/gauges.js",
160        content_type: JS,
161        bytes: include_bytes!("../web/app/lib/viz/gauges.js"),
162    },
163    Asset {
164        path: "/app/lib/viz/glyphs.js",
165        content_type: JS,
166        bytes: include_bytes!("../web/app/lib/viz/glyphs.js"),
167    },
168    Asset {
169        path: "/app/components/top-bar.js",
170        content_type: JS,
171        bytes: include_bytes!("../web/app/components/top-bar.js"),
172    },
173    Asset {
174        path: "/app/components/dashboard-page.js",
175        content_type: JS,
176        bytes: include_bytes!("../web/app/components/dashboard-page.js"),
177    },
178    Asset {
179        path: "/app/components/sensor-modal.js",
180        content_type: JS,
181        bytes: include_bytes!("../web/app/components/sensor-modal.js"),
182    },
183    Asset {
184        path: "/app/components/pairing-modal.js",
185        content_type: JS,
186        bytes: include_bytes!("../web/app/components/pairing-modal.js"),
187    },
188    Asset {
189        path: "/app/components/manage-modal.js",
190        content_type: JS,
191        bytes: include_bytes!("../web/app/components/manage-modal.js"),
192    },
193    Asset {
194        path: "/app/components/group-modal.js",
195        content_type: JS,
196        bytes: include_bytes!("../web/app/components/group-modal.js"),
197    },
198    Asset {
199        path: "/app/components/mesh-modal.js",
200        content_type: JS,
201        bytes: include_bytes!("../web/app/components/mesh-modal.js"),
202    },
203    Asset {
204        path: "/app/components/network-view.js",
205        content_type: JS,
206        bytes: include_bytes!("../web/app/components/network-view.js"),
207    },
208    Asset {
209        path: "/app/components/alarm-bar.js",
210        content_type: JS,
211        bytes: include_bytes!("../web/app/components/alarm-bar.js"),
212    },
213    // English is always embedded as the fallback locale; the other seed locales are
214    // feature-gated so a constrained build embeds only the languages it needs (see
215    // `locale_asset`).
216    Asset {
217        path: "/app/i18n/en.json",
218        content_type: JSON,
219        bytes: include_bytes!("../web/app/i18n/en.json"),
220    },
221];
222
223// The optional seed-locale bundles, each baked in only when its feature is on, so Tier B can
224// drop the languages a deployment does not need from flash. English is not here; it is always
225// embedded in `EMBEDDED`.
226#[cfg(not(feature = "tier-c"))]
227fn locale_asset(path: &str) -> Option<(&'static str, &'static [u8])> {
228    let bytes: Option<&'static [u8]> = match path {
229        #[cfg(feature = "locale-sw")]
230        "/app/i18n/sw.json" => Some(include_bytes!("../web/app/i18n/sw.json")),
231        #[cfg(feature = "locale-ar")]
232        "/app/i18n/ar.json" => Some(include_bytes!("../web/app/i18n/ar.json")),
233        #[cfg(feature = "locale-fr")]
234        "/app/i18n/fr.json" => Some(include_bytes!("../web/app/i18n/fr.json")),
235        #[cfg(feature = "locale-pt")]
236        "/app/i18n/pt.json" => Some(include_bytes!("../web/app/i18n/pt.json")),
237        #[cfg(feature = "locale-hi")]
238        "/app/i18n/hi.json" => Some(include_bytes!("../web/app/i18n/hi.json")),
239        _ => None,
240    };
241    bytes.map(|bytes| (JSON, bytes))
242}
243
244/// The locale tags this build actually embeds, in menu order (English first).
245///
246/// The page reads this from `GET /locales` and offers only these languages, so a Tier B build
247/// that dropped a locale from flash never shows it in the switcher.
248///
249/// # Returns
250///
251/// The embedded locale tags; empty on a floor (`tier-c`) build, which ships no locale bundles.
252#[cfg(not(feature = "tier-c"))]
253pub(crate) fn embedded_locales() -> Vec<&'static str> {
254    [
255        ("en", true),
256        ("sw", cfg!(feature = "locale-sw")),
257        ("ar", cfg!(feature = "locale-ar")),
258        ("fr", cfg!(feature = "locale-fr")),
259        ("pt", cfg!(feature = "locale-pt")),
260        ("hi", cfg!(feature = "locale-hi")),
261    ]
262    .into_iter()
263    .filter_map(|(tag, embedded)| embedded.then_some(tag))
264    .collect()
265}
266
267/// The locale tags this build embeds; empty on a floor (`tier-c`) build.
268///
269/// # Returns
270///
271/// An empty list: the floor page bakes in a single English page and serves no locale bundles.
272#[cfg(feature = "tier-c")]
273pub(crate) fn embedded_locales() -> Vec<&'static str> {
274    Vec::new()
275}
276
277// Resolves an embedded asset: the fixed bundle first, then any feature-gated locale bundle.
278fn embedded_get(path: &str) -> Option<(&'static str, Vec<u8>)> {
279    if let Some(asset) = EMBEDDED.iter().find(|a| a.path == path) {
280        return Some((asset.content_type, asset.bytes.to_vec()));
281    }
282    #[cfg(not(feature = "tier-c"))]
283    if let Some((content_type, bytes)) = locale_asset(path) {
284        return Some((content_type, bytes.to_vec()));
285    }
286    None
287}
288
289// The MIME type for a file, by extension, for the directory (development) mode.
290fn mime_for(path: &str) -> &'static str {
291    if path.ends_with(".html") {
292        HTML
293    } else if path.ends_with(".css") {
294        CSS
295    } else if path.ends_with(".js") || path.ends_with(".mjs") {
296        JS
297    } else if path.ends_with(".svg") {
298        "image/svg+xml"
299    } else if path.ends_with(".json") {
300        "application/json; charset=utf-8"
301    } else if path.ends_with(".ico") {
302        "image/x-icon"
303    } else if path.ends_with(".woff2") {
304        "font/woff2"
305    } else {
306        "application/octet-stream"
307    }
308}
309
310/// Where the page assets come from.
311#[derive(Clone, Debug)]
312pub enum Assets {
313    /// Baked into the binary at compile time: the production path.
314    Embedded,
315    /// Read from a directory on each request: the hot-reloading development path.
316    #[cfg(feature = "serve")]
317    Dir(PathBuf),
318}
319
320impl Assets {
321    /// Resolves a request path to a file's MIME type and bytes.
322    ///
323    /// The request path `"/"` resolves to the page shell. In [`Assets::Dir`] mode any
324    /// file under the directory is served (typed by extension), read fresh from disk so
325    /// edits show up on reload; a path that escapes the directory resolves to `None`.
326    ///
327    /// # Arguments
328    ///
329    /// * `path` - the request path, such as `"/app/app.js"`.
330    ///
331    /// # Returns
332    ///
333    /// The MIME type and the file's bytes, or `None` if no asset matches.
334    pub fn get(&self, path: &str) -> Option<(&'static str, Vec<u8>)> {
335        match self {
336            Assets::Embedded => embedded_get(path),
337            #[cfg(feature = "serve")]
338            Assets::Dir(root) => {
339                let relative = if path == "/" {
340                    "index.html"
341                } else {
342                    path.trim_start_matches('/')
343                };
344                // Refuse anything that tries to climb out of the asset directory.
345                if relative.contains("..") {
346                    return None;
347                }
348                let bytes = std::fs::read(root.join(relative)).ok()?;
349                Some((mime_for(relative), bytes))
350            }
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    // The dashboard's whole point is that it works on a node's own hotspot with no route
360    // to the internet, so nothing it serves may fetch from a remote origin. A stylesheet
361    // from a font host is the way this goes wrong: it blocks first paint until the
362    // connection times out, on exactly the deployment the crate advertises. A URL written
363    // in a comment is harmless and does not count; only the forms that load something do.
364    #[test]
365    fn nothing_served_fetches_from_off_the_device() {
366        const LOADS: &[&str] = &[
367            "src=\"http",
368            "src='http",
369            "href=\"http",
370            "href='http",
371            "@import",
372            "url(http",
373            "fetch(\"http",
374            "fetch('http",
375        ];
376        for asset in EMBEDDED {
377            let text = String::from_utf8_lossy(asset.bytes);
378            for form in LOADS {
379                assert!(
380                    !text.contains(form),
381                    "{} loads from off the device with `{form}`",
382                    asset.path
383                );
384            }
385        }
386    }
387
388    #[test]
389    fn embedded_serves_the_shell_for_root() {
390        let (content_type, bytes) = Assets::Embedded.get("/").expect("shell present");
391        assert_eq!(content_type, HTML);
392        assert!(!bytes.is_empty());
393    }
394
395    #[cfg(not(feature = "tier-c"))]
396    #[test]
397    fn embedded_serves_the_app_entry_and_framework() {
398        assert!(Assets::Embedded.get("/zquery.min.js").is_some());
399        assert!(Assets::Embedded.get("/app/app.js").is_some());
400        assert!(Assets::Embedded.get("/global.css").is_some());
401    }
402
403    #[cfg(feature = "tier-c")]
404    #[test]
405    fn tier_c_embeds_only_the_floor_page() {
406        // The floor image is the single self-contained page and nothing else: the rich app
407        // modules must not be baked in, so the firmware stays tiny.
408        let (content_type, _) = Assets::Embedded.get("/").expect("floor page present");
409        assert_eq!(content_type, HTML);
410        assert!(Assets::Embedded.get("/app/app.js").is_none());
411        assert!(Assets::Embedded.get("/zquery.min.js").is_none());
412    }
413
414    #[cfg(not(feature = "tier-c"))]
415    #[test]
416    fn the_full_bundle_also_embeds_the_floor_page() {
417        // The full app links to the floor page (the top bar's "Lite" link), so it must be
418        // embedded beside the shell, not only in the tier-c image.
419        let (content_type, _) = Assets::Embedded
420            .get("/lite.html")
421            .expect("floor page embedded");
422        assert_eq!(content_type, HTML);
423    }
424
425    #[cfg(not(feature = "tier-c"))]
426    #[test]
427    fn embedded_serves_the_lib_modules() {
428        // The feature and helper modules live under app/lib (with the visualizations split
429        // into app/lib/viz), so the embedded bundle must resolve those nested paths.
430        assert!(Assets::Embedded.get("/app/lib/feed.js").is_some());
431        assert!(Assets::Embedded.get("/app/lib/catalog.js").is_some());
432        assert!(Assets::Embedded.get("/app/lib/discovery.js").is_some());
433        assert!(Assets::Embedded.get("/app/lib/viz/index.js").is_some());
434    }
435
436    #[cfg(not(feature = "tier-c"))]
437    #[test]
438    fn embedded_serves_the_pairing_and_crypto_modules() {
439        // app.js imports these unconditionally (pairing/control and its pure-JS crypto); a
440        // missing one 404s and the page never mounts, so the embedded bundle must carry them.
441        for path in [
442            "/app/components/pairing-modal.js",
443            "/app/lib/pair.js",
444            "/app/lib/crypto/bytes.js",
445            "/app/lib/crypto/sha256.js",
446            "/app/lib/crypto/hmac.js",
447            "/app/lib/crypto/hkdf.js",
448        ] {
449            assert!(Assets::Embedded.get(path).is_some(), "missing {path}");
450        }
451    }
452
453    #[cfg(not(feature = "tier-c"))]
454    #[test]
455    fn every_viz_kind_is_known_to_the_page_renderer() {
456        // The Rust `Viz` vocabulary and the page's renderer are one contract across languages:
457        // every kind a profile can choose must be one `viz/index.js` can draw, or a custom
458        // element would silently fall back to a sparkline. This checks the embedded renderer
459        // references each kind, so the two cannot drift apart unnoticed.
460        let (_, bytes) = Assets::Embedded
461            .get("/app/lib/viz/index.js")
462            .expect("viz/index.js is embedded");
463        let js = String::from_utf8(bytes).expect("viz/index.js is utf8");
464        for viz in pamoja_profile::Viz::ALL {
465            let token = format!("'{}'", viz.kind());
466            assert!(
467                js.contains(&token),
468                "the page renderer does not know viz kind {}",
469                viz.kind()
470            );
471        }
472    }
473
474    #[cfg(all(not(feature = "tier-c"), feature = "all-locales"))]
475    #[test]
476    fn the_full_build_embeds_all_six_seed_locales() {
477        assert_eq!(embedded_locales(), ["en", "sw", "ar", "fr", "pt", "hi"]);
478    }
479
480    #[cfg(not(feature = "tier-c"))]
481    #[test]
482    fn english_is_embedded_and_every_listed_locale_is_served() {
483        // English is always the fallback, and whatever a build reports it embeds must actually
484        // resolve - so a Tier B subset and its `GET /locales` list can never disagree.
485        assert_eq!(embedded_locales().first(), Some(&"en"));
486        for locale in embedded_locales() {
487            let path = format!("/app/i18n/{locale}.json");
488            assert!(
489                Assets::Embedded.get(&path).is_some(),
490                "listed but not served: {locale}"
491            );
492        }
493    }
494
495    #[cfg(feature = "tier-c")]
496    #[test]
497    fn tier_c_embeds_no_locale_bundles() {
498        // The floor page bakes in a single English page and serves no locale JSON.
499        assert!(embedded_locales().is_empty());
500        assert!(Assets::Embedded.get("/app/i18n/en.json").is_none());
501    }
502
503    #[cfg(not(feature = "tier-c"))]
504    #[test]
505    fn the_embedded_bundle_fits_the_flash_budget() {
506        // A regression tripwire for the firmware image: the embedded bundle (the app plus the
507        // locales this build kept) stays small enough to fit a constrained flash partition.
508        const FLASH_BUDGET: usize = 600 * 1024;
509        let mut total: usize = EMBEDDED.iter().map(|a| a.bytes.len()).sum();
510        for locale in embedded_locales() {
511            if locale == "en" {
512                continue;
513            }
514            if let Some((_, bytes)) = locale_asset(&format!("/app/i18n/{locale}.json")) {
515                total += bytes.len();
516            }
517        }
518        assert!(
519            total <= FLASH_BUDGET,
520            "embedded bundle is {total} bytes, over the {FLASH_BUDGET}-byte flash budget"
521        );
522    }
523
524    #[test]
525    fn an_unknown_path_resolves_to_nothing() {
526        assert!(Assets::Embedded.get("/secret").is_none());
527    }
528
529    #[test]
530    fn mime_types_follow_the_extension() {
531        assert_eq!(mime_for("index.html"), HTML);
532        assert_eq!(mime_for("app/app.js"), JS);
533        assert_eq!(mime_for("global.css"), CSS);
534    }
535}