open_chess_server/http/
mod.rs1pub mod account;
4pub mod admin;
5pub mod app_files;
6pub mod apple;
7pub mod challenges;
8pub mod fairplay;
9pub mod feed;
10pub mod games;
11pub mod lab;
12pub mod mobile;
13pub mod oauth;
14pub mod otb;
15pub mod play;
16pub mod streams;
17pub mod web;
18
19use std::convert::Infallible;
20
21use axum::Json;
22use axum::body::Body;
23use axum::extract::State;
24use axum::http::{HeaderValue, header};
25use axum::response::{IntoResponse, Response};
26use bytes::Bytes;
27use serde::Serialize;
28use serde_json::json;
29use tokio::sync::mpsc;
30
31use crate::app::Ctx;
32
33pub const LOGO_SVG: &str = include_str!("logo.svg");
35
36pub fn logo(size: u32) -> String {
38 LOGO_SVG.replacen("<svg ", &format!("<svg width=\"{size}\" height=\"{size}\" aria-hidden=\"true\" "), 1)
39}
40
41pub async fn favicon() -> Response {
44 (
45 [
46 (header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml")),
47 (header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400")),
48 ],
49 LOGO_SVG,
50 )
51 .into_response()
52}
53
54pub async fn docs(State(ctx): State<Ctx>, guide: Option<axum::extract::Path<String>>) -> Response {
57 let name = guide
58 .map(|g| g.0.trim_end_matches(".md").to_owned())
59 .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'));
60 let to = if ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()) {
61 let base = ctx.cfg.base_path().unwrap_or_default();
62 match name {
63 Some(n) => format!("{base}/app/#docs/{n}"),
64 None => format!("{base}/app/#docs"),
65 }
66 } else {
67 let repo = "https://github.com/OffeneDatenmodellierung/open-chess/blob/main";
68 match name.as_deref() {
69 Some("changelog") => format!("{repo}/CHANGELOG.md"),
71 Some(n) => format!("{repo}/docs/{n}.md"),
72 None => format!("{repo}/docs"),
73 }
74 };
75 axum::response::Redirect::to(&to).into_response()
76}
77
78pub async fn profile(
80 State(ctx): State<Ctx>,
81 axum::extract::Path(username): axum::extract::Path<String>,
82) -> Response {
83 let base = ctx.cfg.base_path().unwrap_or_default();
84 axum::response::Redirect::to(&format!("{base}/app/#@/{}", urlencoding::encode(&username))).into_response()
85}
86
87pub async fn index(State(ctx): State<Ctx>, headers: axum::http::HeaderMap) -> Response {
90 if app_files::wants_html(&headers) && ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()) {
91 let base = ctx.cfg.base_path().unwrap_or_default();
92 return axum::response::Redirect::to(&format!("{base}/app/")).into_response();
93 }
94 Json(json!({
95 "name": "open-chess",
96 "site": ctx.cfg.site_name,
97 "api": "Lichess-compatible: Board, Bot, Challenge, OAuth (PKCE), event and game streams, game export",
98 "url": ctx.cfg.public_url(),
99 "version": env!("CARGO_PKG_VERSION"),
100 "changelog": format!("{}/docs/changelog", ctx.cfg.public_url()),
101 "login": true,
105 "signup": ctx.cfg.signup,
106 "esports": ctx.cfg.esports().is_some(),
107 "app": ctx.cfg.app_dir.as_deref().is_some_and(|d| !d.is_empty()).then(|| format!("{}/app/", ctx.cfg.public_url())),
108 "docs": format!("{}/docs", ctx.cfg.public_url()),
109 }))
110 .into_response()
111}
112
113pub fn ok() -> Response {
114 Json(chess_proto::OK).into_response()
115}
116
117pub fn line<T: Serialize>(v: &T) -> Bytes {
119 let mut b = serde_json::to_vec(v).unwrap_or_default();
120 b.push(b'\n');
121 Bytes::from(b)
122}
123
124pub fn ndjson(rx: mpsc::Receiver<Bytes>) -> Response {
127 let stream = futures_util::stream::unfold(rx, |mut rx| async move {
128 rx.recv().await.map(|b| (Ok::<Bytes, Infallible>(b), rx))
129 });
130 let mut r = Body::from_stream(stream).into_response();
131 let h = r.headers_mut();
132 h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-ndjson"));
133 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
134 h.insert("x-accel-buffering", HeaderValue::from_static("no"));
136 r
137}
138
139pub fn form(body: &[u8]) -> std::collections::HashMap<String, String> {
141 url::form_urlencoded::parse(body).into_owned().collect()
142}
143
144pub fn html_escape(s: &str) -> String {
145 s.replace('&', "&")
146 .replace('<', "<")
147 .replace('>', ">")
148 .replace('"', """)
149 .replace('\'', "'")
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn escapes() {
158 assert_eq!(html_escape("<a href=\"x\">&'"), "<a href="x">&'");
159 }
160
161 #[test]
162 fn logo_is_the_apps_icon() {
163 let app = concat!(env!("CARGO_MANIFEST_DIR"), "/../../web/chess/icon.svg");
166 if let Ok(icon) = std::fs::read_to_string(app) {
167 assert_eq!(icon, LOGO_SVG, "copy web/chess/icon.svg to crates/chess-server/src/http/logo.svg");
168 }
169 assert!(logo(36).starts_with("<svg width=\"36\" height=\"36\""));
170 }
171
172 #[test]
173 fn lines_end_in_newline() {
174 assert_eq!(&line(&json!({"a": 1}))[..], b"{\"a\":1}\n");
175 }
176}