Skip to main content

open_chess_server/http/
mod.rs

1//! HTTP handlers, grouped as Lichess's API docs group them.
2
3pub mod account;
4pub mod admin;
5pub mod app_files;
6pub mod challenges;
7pub mod fairplay;
8pub mod feed;
9pub mod games;
10pub mod lab;
11pub mod oauth;
12pub mod play;
13pub mod streams;
14pub mod web;
15
16use std::convert::Infallible;
17
18use axum::Json;
19use axum::body::Body;
20use axum::extract::State;
21use axum::http::{HeaderValue, header};
22use axum::response::{IntoResponse, Response};
23use bytes::Bytes;
24use serde::Serialize;
25use serde_json::json;
26use tokio::sync::mpsc;
27
28use crate::app::Ctx;
29
30/// The open-chess logo (the Chess lab's icon, `web/chess/icon.svg`).
31pub const LOGO_SVG: &str = include_str!("logo.svg");
32
33/// The logo at `size` pixels, inline in a page.
34pub fn logo(size: u32) -> String {
35    LOGO_SVG.replacen("<svg ", &format!("<svg width=\"{size}\" height=\"{size}\" aria-hidden=\"true\" "), 1)
36}
37
38/// `GET /favicon.svg` and `/favicon.ico`: the logo (browsers take an SVG
39/// whatever the name).
40pub async fn favicon() -> Response {
41    (
42        [
43            (header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml")),
44            (header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400")),
45        ],
46        LOGO_SVG,
47    )
48        .into_response()
49}
50
51/// `GET /docs` and `/docs/{guide}`: the guides, shown in the Chess lab
52/// (`/app/#docs/…`) when it's installed, otherwise on GitHub.
53pub async fn docs(State(ctx): State<Ctx>, guide: Option<axum::extract::Path<String>>) -> Response {
54    let name = guide
55        .map(|g| g.0.trim_end_matches(".md").to_owned())
56        .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'));
57    let to = if ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()) {
58        let base = ctx.cfg.base_path().unwrap_or_default();
59        match name {
60            Some(n) => format!("{base}/app/#docs/{n}"),
61            None => format!("{base}/app/#docs"),
62        }
63    } else {
64        let repo = "https://github.com/OffeneDatenmodellierung/open-chess/blob/main/docs";
65        match name {
66            Some(n) => format!("{repo}/{n}.md"),
67            None => repo.to_owned(),
68        }
69    };
70    axum::response::Redirect::to(&to).into_response()
71}
72
73/// `GET /@/{username}`: a player's profile, in the Chess lab.
74pub async fn profile(
75    State(ctx): State<Ctx>,
76    axum::extract::Path(username): axum::extract::Path<String>,
77) -> Response {
78    let base = ctx.cfg.base_path().unwrap_or_default();
79    axum::response::Redirect::to(&format!("{base}/app/#@/{}", urlencoding::encode(&username))).into_response()
80}
81
82/// `GET /`: what this is. Browsers go to the training app when it's
83/// installed.
84pub async fn index(State(ctx): State<Ctx>, headers: axum::http::HeaderMap) -> Response {
85    if app_files::wants_html(&headers) && ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()) {
86        let base = ctx.cfg.base_path().unwrap_or_default();
87        return axum::response::Redirect::to(&format!("{base}/app/")).into_response();
88    }
89    Json(json!({
90        "name": "open-chess",
91        "site": ctx.cfg.site_name,
92        "api": "Lichess-compatible: Board, Bot, Challenge, OAuth (PKCE), event and game streams, game export",
93        "url": ctx.cfg.public_url(),
94        "version": env!("CARGO_PKG_VERSION"),
95        // Browser sign-in (the OAuth page, the Account page): always, with
96        // this server's own accounts; "signup" whether anyone may make one;
97        // "esports" whether the host site's players sign in with it.
98        "login": true,
99        "signup": ctx.cfg.signup,
100        "esports": ctx.cfg.esports().is_some(),
101        "app": ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()).then(|| format!("{}/app/", ctx.cfg.public_url())),
102        "docs": format!("{}/docs", ctx.cfg.public_url()),
103    }))
104    .into_response()
105}
106
107pub fn ok() -> Response {
108    Json(chess_proto::OK).into_response()
109}
110
111/// One NDJSON line.
112pub fn line<T: Serialize>(v: &T) -> Bytes {
113    let mut b = serde_json::to_vec(v).unwrap_or_default();
114    b.push(b'\n');
115    Bytes::from(b)
116}
117
118/// The response half of an NDJSON stream; a task writes lines into the
119/// sender and stops when sending fails (the client went away).
120pub fn ndjson(rx: mpsc::Receiver<Bytes>) -> Response {
121    let stream = futures_util::stream::unfold(rx, |mut rx| async move {
122        rx.recv().await.map(|b| (Ok::<Bytes, Infallible>(b), rx))
123    });
124    let mut r = Body::from_stream(stream).into_response();
125    let h = r.headers_mut();
126    h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-ndjson"));
127    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
128    // Ask proxies (nginx ingress) not to buffer the stream.
129    h.insert("x-accel-buffering", HeaderValue::from_static("no"));
130    r
131}
132
133/// A urlencoded body (missing or empty is fine: no fields).
134pub fn form(body: &[u8]) -> std::collections::HashMap<String, String> {
135    url::form_urlencoded::parse(body).into_owned().collect()
136}
137
138pub fn html_escape(s: &str) -> String {
139    s.replace('&', "&amp;")
140        .replace('<', "&lt;")
141        .replace('>', "&gt;")
142        .replace('"', "&quot;")
143        .replace('\'', "&#39;")
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn escapes() {
152        assert_eq!(html_escape("<a href=\"x\">&'"), "&lt;a href=&quot;x&quot;&gt;&amp;&#39;");
153    }
154
155    #[test]
156    fn logo_is_the_apps_icon() {
157        // In the repository (not in a published crate) the copy here must
158        // match the Chess lab's icon.
159        let app = concat!(env!("CARGO_MANIFEST_DIR"), "/../../web/chess/icon.svg");
160        if let Ok(icon) = std::fs::read_to_string(app) {
161            assert_eq!(icon, LOGO_SVG, "copy web/chess/icon.svg to crates/chess-server/src/http/logo.svg");
162        }
163        assert!(logo(36).starts_with("<svg width=\"36\" height=\"36\""));
164    }
165
166    #[test]
167    fn lines_end_in_newline() {
168        assert_eq!(&line(&json!({"a": 1}))[..], b"{\"a\":1}\n");
169    }
170}