Skip to main content

open_chess_server/
app.rs

1//! The shared context, and the HTTP server's routes.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6use axum::Router;
7use axum::extract::{FromRequestParts, Request, State};
8use axum::http::{HeaderValue, Method, StatusCode, header, request::Parts};
9use axum::middleware::{self, Next};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{get, post};
12
13use crate::auth::{Auth, Identity, bearer};
14use crate::config::Config;
15use crate::db::Db;
16use crate::error::ApiError;
17use crate::http;
18use crate::metrics::Metrics;
19
20#[derive(Clone)]
21pub struct Ctx {
22    pub db: Db,
23    pub cfg: Arc<Config>,
24    pub auth: Auth,
25    pub metrics: Metrics,
26    /// Our board reader, when CHESS_VISION_LOCAL_MODEL names one.
27    pub reader: Option<Arc<chess_vision::Reader>>,
28    slots: Arc<Mutex<HashMap<(i64, &'static str), usize>>>,
29}
30
31/// Open streams a player may hold on one replica, by kind.
32const STREAM_LIMITS: &[(&str, usize)] = &[("event", 8), ("game", 40), ("challenge", 20)];
33
34/// One open stream of a player's; frees its slot when dropped.
35pub struct Slot {
36    slots: Arc<Mutex<HashMap<(i64, &'static str), usize>>>,
37    key: (i64, &'static str),
38}
39
40impl Drop for Slot {
41    fn drop(&mut self) {
42        if let Ok(mut m) = self.slots.lock()
43            && let Some(n) = m.get_mut(&self.key)
44        {
45            *n = n.saturating_sub(1);
46            if *n == 0 {
47                m.remove(&self.key);
48            }
49        }
50    }
51}
52
53impl Ctx {
54    pub fn new(db: Db, cfg: Config) -> Ctx {
55        Ctx {
56            auth: Auth::new(db.clone(), cfg.esports_login),
57            db,
58            cfg: Arc::new(cfg),
59            metrics: Metrics::new(),
60            reader: None,
61            slots: Arc::default(),
62        }
63    }
64
65    /// Load the board reader CHESS_VISION_LOCAL_MODEL names, if any.
66    pub fn with_reader(mut self) -> anyhow::Result<Ctx> {
67        if let Some(r) = crate::vision::load_reader(&self.cfg)? {
68            let card = r.card();
69            tracing::info!(
70                model = card.name,
71                version = card.version,
72                licence = card.licence,
73                internal_only = card.internal_only,
74                size = r.config().image_size,
75                "board reader loaded"
76            );
77            self.reader = Some(Arc::new(r));
78        }
79        Ok(self)
80    }
81
82    /// Take a stream slot for `player`, or refuse with 429 (as Lichess does
83    /// when one account opens too many streams).
84    pub fn slot(&self, player: i64, kind: &'static str) -> Result<Slot, ApiError> {
85        let limit = STREAM_LIMITS.iter().find(|(k, _)| *k == kind).map_or(10, |(_, n)| *n);
86        let mut m = self.slots.lock().map_err(|_| ApiError::too_many("try again"))?;
87        let n = m.entry((player, kind)).or_insert(0);
88        if *n >= limit {
89            return Err(ApiError::too_many(format!("Too many open {kind} streams for this account")));
90        }
91        *n += 1;
92        Ok(Slot { slots: Arc::clone(&self.slots), key: (player, kind) })
93    }
94
95    /// Milliseconds each side has for a first move.
96    pub fn first_move_ms(&self) -> i64 {
97        i64::try_from(self.cfg.first_move_secs * 1000).unwrap_or(i64::MAX)
98    }
99}
100
101/// A caller with a valid bearer token.
102pub struct Who(pub Identity);
103
104impl FromRequestParts<Ctx> for Who {
105    type Rejection = ApiError;
106
107    async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> Result<Who, ApiError> {
108        let t = bearer(&parts.headers).ok_or_else(ApiError::unauthorized)?;
109        match ctx.auth.token(t).await? {
110            Some(id) => Ok(Who(id)),
111            None => Err(ApiError::unauthorized()),
112        }
113    }
114}
115
116impl Who {
117    /// The token must carry one of `scopes`.
118    pub fn need(&self, scopes: &[&str]) -> Result<&Identity, ApiError> {
119        if self.0.has_any(scopes) { Ok(&self.0) } else { Err(ApiError::missing_scope(scopes)) }
120    }
121}
122
123/// Lichess answers API calls from any origin (tokens, not cookies, carry
124/// the identity), so browsers' preflights get a yes.
125async fn cors(req: Request, next: Next) -> Response {
126    let preflight = req.method() == Method::OPTIONS;
127    let mut resp = if preflight { StatusCode::NO_CONTENT.into_response() } else { next.run(req).await };
128    let h = resp.headers_mut();
129    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
130    if preflight {
131        h.insert(
132            header::ACCESS_CONTROL_ALLOW_METHODS,
133            HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
134        );
135        h.insert(
136            header::ACCESS_CONTROL_ALLOW_HEADERS,
137            HeaderValue::from_static("Authorization, Content-Type"),
138        );
139        h.insert(header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
140    }
141    resp
142}
143
144pub fn router(ctx: &Ctx) -> anyhow::Result<Router> {
145    use http::{account, app_files, challenges, fairplay, games, lab, oauth, streams};
146    let api = Router::new()
147        // OAuth (PKCE) and tokens
148        .route("/oauth", get(oauth::authorize).post(oauth::decide))
149        .route("/api/token", post(oauth::token).delete(oauth::revoke))
150        .route("/api/token/test", post(oauth::test))
151        // account and users
152        .route("/api/account", get(account::me))
153        .route("/api/account/email", get(account::email))
154        .route("/api/account/playing", get(account::playing))
155        .route("/api/user/{username}", get(account::user))
156        .route("/api/bot/account/upgrade", post(account::upgrade))
157        .route("/api/bot/online", get(account::bots_online))
158        // streams
159        .route("/api/stream/event", get(streams::events))
160        .route("/api/board/game/stream/{id}", get(streams::board_game))
161        .route("/api/bot/game/stream/{id}", get(streams::bot_game))
162        .route("/api/stream/game/{id}", get(streams::moves))
163        // challenges
164        .route("/api/challenge", get(challenges::list))
165        .route("/api/challenge/{id}", post(challenges::create))
166        .route("/api/challenge/{id}/show", get(challenges::show))
167        .route("/api/challenge/{id}/accept", post(challenges::accept))
168        .route("/api/challenge/{id}/decline", post(challenges::decline))
169        .route("/api/challenge/{id}/cancel", post(challenges::cancel))
170        // playing: Board API (people) and Bot API (bot accounts)
171        .merge(play_routes("board"))
172        .merge(play_routes("bot"))
173        // games
174        .route("/game/export/{id}", get(games::export))
175        .route("/api/games/export/{id}", get(games::export))
176        // fair play (our own, for reviewers)
177        .route("/api/fairplay/queue", get(fairplay::queue))
178        .route("/api/fairplay/games/{id}", get(fairplay::game))
179        .route("/api/fairplay/players/{username}", get(fairplay::player))
180        .route("/api/fairplay/reviews", post(fairplay::review))
181        // our additions for the training app
182        .route("/api/board/game/{id}/assist", post(lab::assist))
183        .route("/api/esl/position", get(lab::position).post(lab::position_hint))
184        .route("/api/esl/position/moves", get(lab::position_moves))
185        .route("/api/esl/tablebase", get(lab::tablebase))
186        // Photos are sent as base64: allow up to 10 MB.
187        .route(
188            "/api/esl/recognise",
189            post(lab::recognise).layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)),
190        )
191        .route("/api/esl/photos/{id}/confirm", post(lab::confirm_photo))
192        // the training app (static files)
193        .route("/app", get(app_files::bare))
194        .route("/app/", get(app_files::index))
195        .route("/app/{*path}", get(app_files::file))
196        .route("/", get(http::index))
197        .layer(middleware::from_fn(cors))
198        .with_state(ctx.clone());
199    let base = ctx.cfg.base_path()?;
200    Ok(if base.is_empty() { api } else { Router::new().nest(&base, api) })
201}
202
203/// The same actions under /api/board and /api/bot (the handler tells them
204/// apart by the matched path).
205fn play_routes(api: &str) -> Router<Ctx> {
206    use http::play;
207    let p = |tail: &str| format!("/api/{api}/game/{{id}}/{tail}");
208    Router::new()
209        .route(&p("move/{uci}"), post(play::make_move))
210        .route(&p("resign"), post(play::resign))
211        .route(&p("abort"), post(play::abort))
212        .route(&p("draw/{accept}"), post(play::draw))
213        .route(&p("takeback/{accept}"), post(play::takeback))
214        .route(&p("claim-victory"), post(play::claim_victory))
215        .route(&p("claim-draw"), post(play::claim_draw))
216        .route(&p("chat"), post(play::chat_post).get(play::chat_get))
217}
218
219/// /metrics, /healthz and /readyz, on their own port.
220pub fn ops_router(ctx: Ctx) -> Router {
221    Router::new()
222        .route("/metrics", get(|State(c): State<Ctx>| async move { c.metrics.render() }))
223        .route("/healthz", get(|| async { "ok" }))
224        .route(
225            "/readyz",
226            get(|State(c): State<Ctx>| async move {
227                match c.db.conn().await {
228                    Ok(conn) if conn.simple_query("SELECT 1").await.is_ok() => (StatusCode::OK, "ready"),
229                    _ => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable"),
230                }
231            }),
232        )
233        .with_state(ctx)
234}