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 app_files;
5pub mod challenges;
6pub mod fairplay;
7pub mod feed;
8pub mod games;
9pub mod lab;
10pub mod oauth;
11pub mod play;
12pub mod streams;
13pub mod web;
14
15use std::convert::Infallible;
16
17use axum::Json;
18use axum::body::Body;
19use axum::extract::State;
20use axum::http::{HeaderValue, header};
21use axum::response::{IntoResponse, Response};
22use bytes::Bytes;
23use serde::Serialize;
24use serde_json::json;
25use tokio::sync::mpsc;
26
27use crate::app::Ctx;
28
29/// `GET /`: what this is. Browsers go to the training app when it's
30/// installed.
31pub async fn index(State(ctx): State<Ctx>, headers: axum::http::HeaderMap) -> Response {
32    if app_files::wants_html(&headers) && ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()) {
33        let base = ctx.cfg.base_path().unwrap_or_default();
34        return axum::response::Redirect::to(&format!("{base}/app/")).into_response();
35    }
36    Json(json!({
37        "name": "open-chess",
38        "site": ctx.cfg.site_name,
39        "api": "Lichess-compatible: Board, Bot, Challenge, OAuth (PKCE), event and game streams, game export",
40        "url": ctx.cfg.public_url(),
41        "version": env!("CARGO_PKG_VERSION"),
42        // Browser sign-in (the OAuth page, the Account page): always, with
43        // this server's own accounts; "signup" whether anyone may make one;
44        // "esports" whether the host site's players sign in with it.
45        "login": true,
46        "signup": ctx.cfg.signup,
47        "esports": ctx.cfg.esports().is_some(),
48        "app": ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()).then(|| format!("{}/app/", ctx.cfg.public_url())),
49    }))
50    .into_response()
51}
52
53pub fn ok() -> Response {
54    Json(chess_proto::OK).into_response()
55}
56
57/// One NDJSON line.
58pub fn line<T: Serialize>(v: &T) -> Bytes {
59    let mut b = serde_json::to_vec(v).unwrap_or_default();
60    b.push(b'\n');
61    Bytes::from(b)
62}
63
64/// The response half of an NDJSON stream; a task writes lines into the
65/// sender and stops when sending fails (the client went away).
66pub fn ndjson(rx: mpsc::Receiver<Bytes>) -> Response {
67    let stream = futures_util::stream::unfold(rx, |mut rx| async move {
68        rx.recv().await.map(|b| (Ok::<Bytes, Infallible>(b), rx))
69    });
70    let mut r = Body::from_stream(stream).into_response();
71    let h = r.headers_mut();
72    h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-ndjson"));
73    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
74    // Ask proxies (nginx ingress) not to buffer the stream.
75    h.insert("x-accel-buffering", HeaderValue::from_static("no"));
76    r
77}
78
79/// A urlencoded body (missing or empty is fine: no fields).
80pub fn form(body: &[u8]) -> std::collections::HashMap<String, String> {
81    url::form_urlencoded::parse(body).into_owned().collect()
82}
83
84pub fn html_escape(s: &str) -> String {
85    s.replace('&', "&amp;")
86        .replace('<', "&lt;")
87        .replace('>', "&gt;")
88        .replace('"', "&quot;")
89        .replace('\'', "&#39;")
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn escapes() {
98        assert_eq!(html_escape("<a href=\"x\">&'"), "&lt;a href=&quot;x&quot;&gt;&amp;&#39;");
99    }
100
101    #[test]
102    fn lines_end_in_newline() {
103        assert_eq!(&line(&json!({"a": 1}))[..], b"{\"a\":1}\n");
104    }
105}