Skip to main content

open_chess_server/http/
app_files.rs

1//! The Chess lab training app (`web/chess`, built with its wasm engine),
2//! served as static files under `/app/` from `CHESS_APP_DIR`.
3//!
4//! Serving it from this server puts the app and the API on one origin, so
5//! signing in, photo reading and the position dictionary need no
6//! cross-origin calls. The app caches itself for offline use with a
7//! service worker; the page and the service worker are always revalidated
8//! so a new release reaches phones on their next visit.
9
10use 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
35/// The file for a URL path inside the app, or `None` if the path tries to
36/// leave the app directory or names something hidden.
37pub 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
57/// `GET /app/{*path}`.
58pub async fn file(State(ctx): State<Ctx>, UrlPath(tail): UrlPath<String>) -> Response {
59    serve(&ctx, &tail).await
60}
61
62/// Where `CHESS_APP_THEME` points: CSS itself, a theme the app bundles
63/// (`themes/{name}.css` in the app, by name), or a .css file.
64#[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
81/// `GET /app/theme.css`: the deployment's look (`CHESS_APP_THEME`: a
82/// bundled theme's name, CSS, or a file of it), empty when there is none.
83pub 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
115/// `GET /app/`.
116pub async fn index(State(ctx): State<Ctx>) -> Response {
117    serve(&ctx, "").await
118}
119
120/// `GET /app`: on to `/app/`, so relative links resolve inside it.
121pub 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    // The page, the service worker and its manifest are checked on every
141    // visit; everything else may be cached for an hour (the service worker
142    // keeps its own copy for offline use and replaces it on each release).
143    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        // The worker controls the whole app directory.
152        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
160/// Whether a request for `/` came from a browser (rather than an API
161/// client), so it can be sent to the app.
162pub 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        // No way out of themes/ by name.
189        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}