Skip to main content

blit_webserver/
lib.rs

1pub mod config;
2
3use axum::http::header;
4use axum::response::{Html, IntoResponse, Response};
5
6/// Serve the monospace font family list as JSON.
7pub fn fonts_list_response(cors_origin: Option<&str>) -> Response {
8    let families = blit_fonts::list_monospace_font_families();
9    let json = format!(
10        "[{}]",
11        families
12            .iter()
13            .map(|f| format!("\"{}\"", f.replace('"', "\\\"")))
14            .collect::<Vec<_>>()
15            .join(",")
16    );
17    let mut resp = (
18        [
19            (header::CONTENT_TYPE, "application/json"),
20            (header::CACHE_CONTROL, "public, max-age=3600"),
21        ],
22        json,
23    )
24        .into_response();
25    add_cors(&mut resp, cors_origin);
26    resp
27}
28
29/// Serve a font's @font-face CSS by family name, or 404.
30pub fn font_response(name: &str, cors_origin: Option<&str>) -> Response {
31    match blit_fonts::font_face_css(name) {
32        Some(css) => {
33            let mut resp = (
34                [
35                    (header::CONTENT_TYPE, "text/css"),
36                    (header::CACHE_CONTROL, "public, max-age=86400, immutable"),
37                ],
38                css,
39            )
40                .into_response();
41            add_cors(&mut resp, cors_origin);
42            resp
43        }
44        None => (axum::http::StatusCode::NOT_FOUND, "font not found").into_response(),
45    }
46}
47
48/// Serve font metrics (advance ratio) as JSON.
49pub fn font_metrics_response(name: &str, cors_origin: Option<&str>) -> Response {
50    match blit_fonts::font_advance_ratio(name) {
51        Some(ratio) => {
52            let json = format!("{{\"advanceRatio\":{}}}", ratio);
53            let mut resp = (
54                [
55                    (header::CONTENT_TYPE, "application/json"),
56                    (header::CACHE_CONTROL, "public, max-age=86400, immutable"),
57                ],
58                json,
59            )
60                .into_response();
61            add_cors(&mut resp, cors_origin);
62            resp
63        }
64        None => (axum::http::StatusCode::NOT_FOUND, "font not found").into_response(),
65    }
66}
67
68fn add_cors(resp: &mut Response, origin: Option<&str>) {
69    if let Some(origin) = origin
70        && let Ok(val) = origin.parse()
71    {
72        resp.headers_mut()
73            .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, val);
74    }
75}
76
77/// Serve brotli-compressed HTML with ETag support. If the client accepts `br`
78/// encoding, the raw compressed bytes are sent; otherwise they are decompressed.
79/// Returns 304 if the client's `If-None-Match` matches `etag`.
80pub fn html_response(
81    html_br: &'static [u8],
82    etag: &str,
83    if_none_match: Option<&[u8]>,
84    accept_encoding: Option<&str>,
85) -> Response {
86    if let Some(inm) = if_none_match
87        && inm == etag.as_bytes()
88    {
89        return (
90            axum::http::StatusCode::NOT_MODIFIED,
91            [(axum::http::header::ETAG, etag)],
92        )
93            .into_response();
94    }
95    let accepts_br = accept_encoding
96        .map(|ae| ae.split(',').any(|p| p.trim().starts_with("br")))
97        .unwrap_or(false);
98    if accepts_br {
99        (
100            [
101                (header::ETAG, etag.to_owned()),
102                (header::CONTENT_ENCODING, "br".to_owned()),
103                (header::CONTENT_TYPE, "text/html".to_owned()),
104            ],
105            html_br,
106        )
107            .into_response()
108    } else {
109        let mut decompressed = Vec::new();
110        let _ = brotli::BrotliDecompress(&mut std::io::Cursor::new(html_br), &mut decompressed);
111        (
112            [(header::ETAG, etag.to_owned())],
113            Html(String::from_utf8_lossy(&decompressed).into_owned()),
114        )
115            .into_response()
116    }
117}
118
119/// Try to match a font route from a raw request path (any prefix).
120/// Handles `/fonts`, `/vt/fonts`, `/font/Name`, `/vt/font/Name%20With%20Spaces`.
121/// Returns `Some(response)` if the path matched a font route, `None` otherwise.
122pub fn try_font_route(path: &str, cors_origin: Option<&str>) -> Option<Response> {
123    if path == "/fonts" || path.ends_with("/fonts") {
124        return Some(fonts_list_response(cors_origin));
125    }
126    if let Some(raw) = path.rsplit_once("/font-metrics/").map(|(_, n)| n)
127        && !raw.contains('/')
128        && !raw.is_empty()
129    {
130        let name = percent_encoding::percent_decode_str(raw).decode_utf8_lossy();
131        return Some(font_metrics_response(&name, cors_origin));
132    }
133    if let Some(raw) = path.rsplit_once("/font/").map(|(_, n)| n)
134        && !raw.contains('/')
135        && !raw.is_empty()
136    {
137        let name = percent_encoding::percent_decode_str(raw).decode_utf8_lossy();
138        return Some(font_response(&name, cors_origin));
139    }
140    None
141}
142
143/// Compute an ETag string from content bytes.
144pub fn html_etag(data: &[u8]) -> String {
145    use std::hash::{Hash, Hasher};
146    let mut h = std::collections::hash_map::DefaultHasher::new();
147    data.hash(&mut h);
148    format!("\"blit-{:x}\"", h.finish())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use axum::http::StatusCode;
155
156    // ── html_etag ──
157
158    #[test]
159    fn etag_deterministic() {
160        let a = html_etag(b"<html>hello</html>");
161        let b = html_etag(b"<html>hello</html>");
162        assert_eq!(a, b);
163    }
164
165    #[test]
166    fn etag_different_for_different_content() {
167        let a = html_etag(b"aaa");
168        let b = html_etag(b"bbb");
169        assert_ne!(a, b);
170    }
171
172    #[test]
173    fn etag_format() {
174        let tag = html_etag(b"test");
175        assert!(
176            tag.starts_with("\"blit-"),
177            "expected quoted blit- prefix, got {tag}"
178        );
179        assert!(tag.ends_with('"'));
180    }
181
182    // ── html_response ──
183
184    #[tokio::test]
185    async fn html_response_200_without_etag_match() {
186        let etag = html_etag(b"hello");
187        let resp = html_response(b"hello", &etag, None, None);
188        assert_eq!(resp.status(), StatusCode::OK);
189        assert_eq!(resp.headers().get("etag").unwrap().to_str().unwrap(), etag);
190    }
191
192    #[tokio::test]
193    async fn html_response_304_with_matching_etag() {
194        let etag = html_etag(b"hello");
195        let resp = html_response(b"hello", &etag, Some(etag.as_bytes()), None);
196        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
197    }
198
199    #[tokio::test]
200    async fn html_response_200_with_mismatched_etag() {
201        let etag = html_etag(b"hello");
202        let resp = html_response(b"hello", &etag, Some(b"\"wrong\""), None);
203        assert_eq!(resp.status(), StatusCode::OK);
204    }
205
206    // ── try_font_route ──
207
208    #[test]
209    fn font_route_fonts_bare() {
210        assert!(try_font_route("/fonts", None).is_some());
211    }
212
213    #[test]
214    fn font_route_fonts_prefixed() {
215        assert!(try_font_route("/vt/fonts", None).is_some());
216    }
217
218    #[test]
219    fn font_route_font_name() {
220        let resp = try_font_route("/font/Menlo", None);
221        assert!(resp.is_some());
222    }
223
224    #[test]
225    fn font_route_font_metrics() {
226        let resp = try_font_route("/font-metrics/Menlo", None);
227        assert!(resp.is_some());
228    }
229
230    #[test]
231    fn font_route_no_match() {
232        assert!(try_font_route("/api/sessions", None).is_none());
233        assert!(try_font_route("/", None).is_none());
234    }
235
236    #[test]
237    fn font_route_rejects_empty_name() {
238        assert!(try_font_route("/font/", None).is_none());
239        assert!(try_font_route("/font-metrics/", None).is_none());
240    }
241
242    #[test]
243    fn font_route_rejects_nested_path() {
244        assert!(try_font_route("/font/a/b", None).is_none());
245    }
246}