1pub mod config;
2
3use axum::http::header;
4use axum::response::{Html, IntoResponse, Response};
5
6pub 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
29pub 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
48pub 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
77pub 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
119pub 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
143pub 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 #[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 #[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 #[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}