open_chess_server/http/
app_files.rs1use std::path::{Component, Path, PathBuf};
11
12use axum::extract::{Path as UrlPath, State};
13use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
14use axum::response::{IntoResponse, Redirect, Response};
15
16use crate::app::Ctx;
17
18fn content_type(path: &Path) -> &'static str {
19 match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
20 "html" => "text/html; charset=utf-8",
21 "js" | "mjs" => "text/javascript; charset=utf-8",
22 "css" => "text/css; charset=utf-8",
23 "wasm" => "application/wasm",
24 "json" => "application/json",
25 "webmanifest" => "application/manifest+json",
26 "svg" => "image/svg+xml",
27 "png" => "image/png",
28 "woff2" => "font/woff2",
29 "txt" => "text/plain; charset=utf-8",
30 "md" => "text/markdown; charset=utf-8",
31 _ => "application/octet-stream",
32 }
33}
34
35pub fn resolve(dir: &Path, tail: &str) -> Option<PathBuf> {
38 let tail = tail.trim_start_matches('/');
39 let rel =
40 if tail.is_empty() || tail.ends_with('/') { format!("{tail}index.html") } else { tail.to_string() };
41 let rel = Path::new(&rel);
42 let mut out = dir.to_path_buf();
43 for c in rel.components() {
44 match c {
45 Component::Normal(part) => {
46 if part.to_str().is_none_or(|s| s.starts_with('.')) {
47 return None;
48 }
49 out.push(part);
50 }
51 _ => return None,
52 }
53 }
54 Some(out)
55}
56
57pub async fn file(State(ctx): State<Ctx>, UrlPath(tail): UrlPath<String>) -> Response {
59 serve(&ctx, &tail).await
60}
61
62#[derive(Debug, PartialEq)]
65enum Theme<'a> {
66 Css(&'a str),
67 Bundled(&'a str),
68 File(&'a str),
69}
70
71fn theme_source(t: &str) -> Theme<'_> {
72 if t.contains('{') {
73 Theme::Css(t)
74 } else if !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
75 Theme::Bundled(t)
76 } else {
77 Theme::File(t)
78 }
79}
80
81pub async fn theme(State(ctx): State<Ctx>) -> Response {
84 let read = |path: PathBuf, t: &str| {
85 let t = t.to_owned();
86 async move {
87 tokio::fs::read_to_string(&path).await.unwrap_or_else(|e| {
88 tracing::warn!("CHESS_APP_THEME {t} ({}): {e}", path.display());
89 String::new()
90 })
91 }
92 };
93 let css = match ctx.cfg.app_theme.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
94 None => String::new(),
95 Some(t) => match theme_source(t) {
96 Theme::Css(css) => css.to_owned(),
97 Theme::File(f) => read(PathBuf::from(f), t).await,
98 Theme::Bundled(name) => {
99 if let Some(dir) = ctx.cfg.app_dir.as_deref().filter(|d| !d.is_empty()) {
100 read(Path::new(dir).join("themes").join(format!("{name}.css")), t).await
101 } else {
102 tracing::warn!("CHESS_APP_THEME {name}: a bundled theme needs the app (CHESS_APP_DIR)");
103 String::new()
104 }
105 }
106 },
107 };
108 let mut h = HeaderMap::new();
109 h.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css; charset=utf-8"));
110 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
111 h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
112 (h, css).into_response()
113}
114
115pub async fn index(State(ctx): State<Ctx>) -> Response {
117 serve(&ctx, "").await
118}
119
120pub async fn bare(State(ctx): State<Ctx>) -> Response {
122 let base = ctx.cfg.base_path().unwrap_or_default();
123 Redirect::permanent(&format!("{base}/app/")).into_response()
124}
125
126async fn serve(ctx: &Ctx, tail: &str) -> Response {
127 let Some(dir) = ctx.cfg.app_dir.as_deref().filter(|d| !d.is_empty()) else {
128 return (StatusCode::NOT_FOUND, "the training app isn't installed on this server (CHESS_APP_DIR)")
129 .into_response();
130 };
131 let Some(path) = resolve(Path::new(dir), tail) else {
132 return StatusCode::NOT_FOUND.into_response();
133 };
134 let Ok(body) = tokio::fs::read(&path).await else {
135 return StatusCode::NOT_FOUND.into_response();
136 };
137 let mut h = HeaderMap::new();
138 h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path)));
139 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
140 let cache = if matches!(name, "index.html" | "sw.js" | "manifest.webmanifest") {
144 "no-cache"
145 } else {
146 "public, max-age=3600"
147 };
148 h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
149 h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
150 if name == "sw.js" {
151 let base = ctx.cfg.base_path().unwrap_or_default();
153 if let Ok(v) = HeaderValue::from_str(&format!("{base}/app/")) {
154 h.insert("service-worker-allowed", v);
155 }
156 }
157 (h, body).into_response()
158}
159
160pub fn wants_html(headers: &HeaderMap) -> bool {
163 headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()).is_some_and(|a| a.contains("text/html"))
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn paths_stay_inside_the_app() {
172 let d = Path::new("/srv/app");
173 assert_eq!(resolve(d, ""), Some(PathBuf::from("/srv/app/index.html")));
174 assert_eq!(resolve(d, "pkg/engine.wasm"), Some(PathBuf::from("/srv/app/pkg/engine.wasm")));
175 assert_eq!(resolve(d, "fonts/"), Some(PathBuf::from("/srv/app/fonts/index.html")));
176 assert_eq!(resolve(d, "../etc/passwd"), None);
177 assert_eq!(resolve(d, "pkg/../../x"), None);
178 assert_eq!(resolve(d, ".git/config"), None);
179 assert_eq!(resolve(d, "/etc/passwd"), Some(PathBuf::from("/srv/app/etc/passwd")));
180 }
181
182 #[test]
183 fn theme_sources() {
184 assert_eq!(theme_source("esports-lab"), Theme::Bundled("esports-lab"));
185 assert_eq!(theme_source(":root { --ink: #000 }"), Theme::Css(":root { --ink: #000 }"));
186 assert_eq!(theme_source("/etc/chess/theme.css"), Theme::File("/etc/chess/theme.css"));
187 assert_eq!(theme_source("theme.css"), Theme::File("theme.css"));
188 assert_eq!(theme_source("../x"), Theme::File("../x"));
190 }
191
192 #[test]
193 fn bundled_themes_exist() {
194 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/chess/themes");
195 let css = std::fs::read_to_string(dir.join("esports-lab.css")).unwrap();
196 assert!(css.contains("--paper"));
197 }
198
199 #[test]
200 fn types() {
201 assert_eq!(content_type(Path::new("a.wasm")), "application/wasm");
202 assert_eq!(content_type(Path::new("manifest.webmanifest")), "application/manifest+json");
203 assert_eq!(content_type(Path::new("app.js")), "text/javascript; charset=utf-8");
204 }
205}