open_chess_server/http/
mod.rs1pub 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
30pub const LOGO_SVG: &str = include_str!("logo.svg");
32
33pub fn logo(size: u32) -> String {
35 LOGO_SVG.replacen("<svg ", &format!("<svg width=\"{size}\" height=\"{size}\" aria-hidden=\"true\" "), 1)
36}
37
38pub 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
51pub 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
73pub 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
82pub 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 "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
111pub 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
118pub 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 h.insert("x-accel-buffering", HeaderValue::from_static("no"));
130 r
131}
132
133pub 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('&', "&")
140 .replace('<', "<")
141 .replace('>', ">")
142 .replace('"', """)
143 .replace('\'', "'")
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn escapes() {
152 assert_eq!(html_escape("<a href=\"x\">&'"), "<a href="x">&'");
153 }
154
155 #[test]
156 fn logo_is_the_apps_icon() {
157 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}