Skip to main content

rustlavel_chart/
plugin.rs

1//! Serving the library, from this application's own origin.
2
3use rustlavel_core::Config;
4use rustlavel_http::plugin::{Plugin, Setup};
5use rustlavel_http::{Request, Response};
6
7/// Chart.js itself, vendored. MIT; the licence travels beside it in
8/// `assets/LICENSE-chart.js.md` and the copyright banner is still at the top
9/// of the file.
10pub const CHART_JS: &str = include_str!("../assets/chart.umd.js");
11
12/// The script that reads `data-chart` and draws it.
13pub const INIT_JS: &str = include_str!("../assets/rustlavel-chart.js");
14
15pub const CHART_JS_PATH: &str = "/vendor/chart/chart.umd.js";
16pub const INIT_JS_PATH: &str = "/vendor/chart/rustlavel-chart.js";
17
18/// The version vendored here, so a page can cache-bust on it and a bug report
19/// can name it.
20pub const CHART_JS_VERSION: &str = "4.5.1";
21
22/// Mounts the two scripts.
23///
24/// ```ignore
25/// App::new()?.plugin(Charts::default())
26/// ```
27///
28/// ```html
29/// <script src="/vendor/chart/chart.umd.js" defer></script>
30/// <script src="/vendor/chart/rustlavel-chart.js" defer></script>
31/// ```
32///
33/// **From this origin, not a CDN.** `default-src 'self'` — the policy the auth
34/// kit ships — refuses `cdn.jsdelivr.net`, and rightly: a CDN is a third party
35/// who can change what your users execute. Vendoring is also what makes the
36/// version above a fact rather than whatever the CDN served this morning.
37pub struct Charts {
38    prefix: String,
39}
40
41impl Default for Charts {
42    fn default() -> Charts {
43        Charts { prefix: "/vendor/chart".to_string() }
44    }
45}
46
47impl Charts {
48    /// Mount somewhere else. The paths in [`CHART_JS_PATH`] and
49    /// [`INIT_JS_PATH`] are the defaults, so a template that hard-codes them
50    /// and a plugin that was moved will disagree — which is why both are
51    /// constants rather than strings written twice.
52    pub fn at(prefix: impl Into<String>) -> Charts {
53        Charts { prefix: prefix.into().trim_end_matches('/').to_string() }
54    }
55}
56
57impl Plugin for Charts {
58    fn name(&self) -> &'static str {
59        "charts"
60    }
61
62    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
63        let immutable = immutable_caching(setup.config);
64
65        setup.router.get(&format!("{}/chart.umd.js", self.prefix), move |_: Request| async move {
66            script(CHART_JS, immutable)
67        });
68        setup.router.get(&format!("{}/rustlavel-chart.js", self.prefix), move |_: Request| async move {
69            script(INIT_JS, immutable)
70        });
71    }
72}
73
74/// A vendored file at a fixed version never changes, so in production it is
75/// worth a year. In development it is not: an edit to the init script that the
76/// browser refuses to re-fetch is an afternoon.
77fn immutable_caching(config: &Config) -> bool {
78    config.is_production()
79}
80
81fn script(body: &'static str, immutable: bool) -> Response {
82    let response = Response::ok()
83        .with_body(body)
84        .with_header("content-type", "text/javascript; charset=utf-8");
85    if immutable {
86        return response.with_header("cache-control", "public, max-age=31536000, immutable");
87    }
88    response.with_header("cache-control", "no-cache")
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn the_vendored_library_is_the_version_the_constant_claims() {
97        let banner: String = CHART_JS.lines().take(6).collect::<Vec<_>>().join("\n");
98        assert!(
99            banner.contains(&format!("Chart.js v{CHART_JS_VERSION}")),
100            "the vendored file is not v{CHART_JS_VERSION}: {banner}"
101        );
102        assert!(banner.contains("MIT"), "the copyright banner was stripped: {banner}");
103    }
104
105    /// MIT requires the notice to travel with the copy.
106    #[test]
107    fn the_licence_travels_with_the_library() {
108        let licence = include_str!("../assets/LICENSE-chart.js.md");
109        assert!(licence.contains("MIT License"), "{licence}");
110        assert!(licence.contains("Chart.js Contributors"));
111    }
112
113    /// The whole point of vendoring: nothing here reaches a third-party origin.
114    #[test]
115    fn nothing_is_fetched_from_a_cdn() {
116        for script in [INIT_JS, CHART_JS] {
117            for host in ["cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com"] {
118                assert!(!script.contains(host), "a vendored script still reaches {host}");
119            }
120        }
121    }
122
123    /// The paths a template writes and the paths the plugin mounts are the
124    /// same string, or the scripts 404 and every chart is a blank rectangle.
125    #[test]
126    fn the_default_mount_matches_the_published_paths() {
127        let charts = Charts::default();
128        assert_eq!(format!("{}/chart.umd.js", charts.prefix), CHART_JS_PATH);
129        assert_eq!(format!("{}/rustlavel-chart.js", charts.prefix), INIT_JS_PATH);
130    }
131
132    #[test]
133    fn a_custom_mount_drops_a_trailing_slash() {
134        assert_eq!(Charts::at("/static/charts/").prefix, "/static/charts");
135    }
136}