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, Partner, 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    /// Apple push notifications for the iOS app, when configured.
29    pub apns: Option<Arc<crate::push::Apns>>,
30    /// Sign in with Apple, when configured.
31    pub apple: Option<Arc<crate::apple::Apple>>,
32    slots: Arc<Mutex<HashMap<(i64, &'static str), usize>>>,
33}
34
35/// Open streams a player may hold on one replica, by kind.
36const STREAM_LIMITS: &[(&str, usize)] = &[("event", 8), ("game", 40), ("challenge", 20)];
37
38/// One open stream of a player's; frees its slot when dropped.
39pub struct Slot {
40    slots: Arc<Mutex<HashMap<(i64, &'static str), usize>>>,
41    key: (i64, &'static str),
42}
43
44impl Drop for Slot {
45    fn drop(&mut self) {
46        if let Ok(mut m) = self.slots.lock()
47            && let Some(n) = m.get_mut(&self.key)
48        {
49            *n = n.saturating_sub(1);
50            if *n == 0 {
51                m.remove(&self.key);
52            }
53        }
54    }
55}
56
57impl Ctx {
58    pub fn new(db: Db, cfg: Config) -> Ctx {
59        Ctx {
60            auth: Auth::new(db.clone(), cfg.staff_passkey_hours),
61            db,
62            cfg: Arc::new(cfg),
63            metrics: Metrics::new(),
64            reader: None,
65            apns: None,
66            apple: None,
67            slots: Arc::default(),
68        }
69    }
70
71    /// Load the board reader CHESS_VISION_LOCAL_MODEL names, if any.
72    pub fn with_reader(mut self) -> anyhow::Result<Ctx> {
73        if let Some(r) = crate::vision::load_reader(&self.cfg)? {
74            let card = r.card();
75            tracing::info!(
76                model = card.name,
77                version = card.version,
78                licence = card.licence,
79                internal_only = card.internal_only,
80                size = r.config().image_size,
81                "board reader loaded"
82            );
83            self.reader = Some(Arc::new(r));
84        }
85        self.apns = crate::push::Apns::from_config(&self.cfg)?;
86        if self.apns.is_some() {
87            tracing::info!(topic = self.cfg.apns_topic, "push notifications on");
88        }
89        self.apple = crate::apple::Apple::from_config(&self.cfg)?;
90        if self.apple.is_some() {
91            tracing::info!("Sign in with Apple on");
92        }
93        Ok(self)
94    }
95
96    /// Take a stream slot for `player`, or refuse with 429 (as Lichess does
97    /// when one account opens too many streams).
98    pub fn slot(&self, player: i64, kind: &'static str) -> Result<Slot, ApiError> {
99        let limit = STREAM_LIMITS.iter().find(|(k, _)| *k == kind).map_or(10, |(_, n)| *n);
100        let mut m = self.slots.lock().map_err(|_| ApiError::too_many("try again"))?;
101        let n = m.entry((player, kind)).or_insert(0);
102        if *n >= limit {
103            return Err(ApiError::too_many(format!("Too many open {kind} streams for this account")));
104        }
105        *n += 1;
106        Ok(Slot { slots: Arc::clone(&self.slots), key: (player, kind) })
107    }
108
109    /// Milliseconds each side has for a first move.
110    pub fn first_move_ms(&self) -> i64 {
111        i64::try_from(self.cfg.first_move_secs * 1000).unwrap_or(i64::MAX)
112    }
113}
114
115/// A caller with a valid bearer token.
116pub struct Who(pub Identity);
117
118impl FromRequestParts<Ctx> for Who {
119    type Rejection = ApiError;
120
121    async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> Result<Who, ApiError> {
122        let t = bearer(&parts.headers).ok_or_else(ApiError::unauthorized)?;
123        match ctx.auth.token(t).await? {
124            Some(id) => Ok(Who(id)),
125            None => Err(ApiError::unauthorized()),
126        }
127    }
128}
129
130/// Someone allowed to read games and players: a signed-in player (any of
131/// their tokens) or a partner with the `read` scope. Nothing here is open
132/// without one.
133pub enum Reader {
134    Player(Identity),
135    Partner(Partner),
136}
137
138impl Reader {
139    pub fn player(&self) -> Option<&Identity> {
140        match self {
141            Reader::Player(id) => Some(id),
142            Reader::Partner(_) => None,
143        }
144    }
145}
146
147impl FromRequestParts<Ctx> for Reader {
148    type Rejection = ApiError;
149
150    async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> Result<Reader, ApiError> {
151        let t = bearer(&parts.headers).ok_or_else(ApiError::unauthorized)?;
152        if let Some(id) = ctx.auth.token(t).await? {
153            return Ok(Reader::Player(id));
154        }
155        match ctx.auth.partner(t, ctx.cfg.esports()).await? {
156            Some(p) if p.has(crate::auth::PARTNER_READ) => Ok(Reader::Partner(p)),
157            Some(_) => Err(ApiError::forbidden("this partner token can't read games")),
158            None => Err(ApiError::unauthorized()),
159        }
160    }
161}
162
163/// The host site (a partner with the `site` scope, or its shared secret).
164pub struct Site(pub Partner);
165
166impl FromRequestParts<Ctx> for Site {
167    type Rejection = ApiError;
168
169    async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> Result<Site, ApiError> {
170        let t = bearer(&parts.headers).ok_or_else(ApiError::unauthorized)?;
171        match ctx.auth.partner(t, ctx.cfg.esports()).await? {
172            Some(p) if p.has(crate::auth::PARTNER_SITE) => Ok(Site(p)),
173            Some(_) => Err(ApiError::forbidden("this partner token can't act as the host site")),
174            None => Err(ApiError::unauthorized()),
175        }
176    }
177}
178
179impl Who {
180    /// An admin, with a fresh passkey check.
181    pub fn admin(&self) -> Result<&Identity, ApiError> {
182        if self.0.admin {
183            Ok(&self.0)
184        } else if self.0.pending {
185            Err(ApiError::forbidden("admin rights need a passkey check first"))
186        } else {
187            Err(ApiError::forbidden("admins only"))
188        }
189    }
190
191    /// The token must carry one of `scopes`.
192    pub fn need(&self, scopes: &[&str]) -> Result<&Identity, ApiError> {
193        if self.0.has_any(scopes) { Ok(&self.0) } else { Err(ApiError::missing_scope(scopes)) }
194    }
195}
196
197/// Lichess answers API calls from any origin (tokens, not cookies, carry
198/// the identity), so browsers' preflights get a yes.
199async fn cors(req: Request, next: Next) -> Response {
200    let preflight = req.method() == Method::OPTIONS;
201    let mut resp = if preflight { StatusCode::NO_CONTENT.into_response() } else { next.run(req).await };
202    let h = resp.headers_mut();
203    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
204    if preflight {
205        h.insert(
206            header::ACCESS_CONTROL_ALLOW_METHODS,
207            HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
208        );
209        h.insert(
210            header::ACCESS_CONTROL_ALLOW_HEADERS,
211            HeaderValue::from_static("Authorization, Content-Type"),
212        );
213        h.insert(header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
214    }
215    resp
216}
217
218pub fn router(ctx: &Ctx) -> anyhow::Result<Router> {
219    use http::{
220        account, admin, app_files, apple, challenges, fairplay, feed, games, lab, mobile, oauth, otb,
221        streams, web,
222    };
223    let api = Router::new()
224        // OAuth (PKCE) and tokens
225        .route("/oauth", get(oauth::authorize).post(oauth::decide))
226        .route("/login", get(web::login_page).post(web::login))
227        .route("/signup", get(web::signup_page).post(web::signup))
228        .route("/logout", post(web::logout))
229        .route("/account", get(web::account))
230        .route("/account/tokens", post(web::new_token))
231        .route("/account/tokens/revoke", post(web::revoke_token))
232        .route("/auth/esports", get(web::esports_start))
233        .route("/auth/esports/back", get(web::esports_back))
234        .route("/api/esports/token", post(web::esports_token))
235        .route("/api/esports/revoke", post(web::esports_revoke))
236        .route("/api/esports/players", get(web::esports_players))
237        .route("/api/esports/link", post(web::esports_link))
238        .route("/api/feed", get(feed::feed))
239        .route("/api/token", post(oauth::token).delete(oauth::revoke))
240        .route("/api/token/test", post(oauth::test))
241        .route("/api/auth/apple", post(apple::sign_in).delete(apple::unlink))
242        // account and users
243        .route("/api/account", get(account::me).delete(mobile::delete_account))
244        .route(
245            "/api/games/offline",
246            post(mobile::upload).layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)),
247        )
248        .route("/api/games/{id}/confirm", post(mobile::confirm))
249        .route("/api/sync", get(mobile::sync).layer(tower_http::compression::CompressionLayer::new()))
250        .route(
251            "/api/photos",
252            post(mobile::photo).layer(axum::extract::DefaultBodyLimit::max(8 * 1024 * 1024)),
253        )
254        .route("/api/otb", post(otb::start))
255        .route("/api/otb/{id}/move", post(otb::make_move))
256        .route("/api/otb/{id}/undo", post(otb::undo))
257        .route("/api/otb/{id}/end", post(otb::end))
258        .route("/api/broadcast/{id}/events", get(otb::events))
259        .route("/broadcast/{id}", get(otb::page))
260        .route("/api/push/devices", post(mobile::add_device))
261        .route("/api/push/devices/{token}", axum::routing::delete(mobile::remove_device))
262        .route("/api/account/email", get(account::email))
263        .route("/api/account/playing", get(account::playing))
264        .route("/api/user/{username}", get(account::user))
265        .route("/api/player/autocomplete", get(account::autocomplete))
266        .route("/api/bot/account/upgrade", post(account::upgrade))
267        .route("/api/bot/online", get(account::bots_online))
268        // streams
269        .route("/api/stream/event", get(streams::events))
270        .route("/api/board/game/stream/{id}", get(streams::board_game))
271        .route("/api/bot/game/stream/{id}", get(streams::bot_game))
272        .route("/api/stream/game/{id}", get(streams::moves))
273        // challenges
274        .route("/api/challenge", get(challenges::list))
275        .route("/api/challenge/ai", post(challenges::ai))
276        .route("/api/challenge/{id}", post(challenges::create))
277        .route("/api/challenge/{id}/show", get(challenges::show))
278        .route("/api/challenge/{id}/accept", post(challenges::accept))
279        .route("/api/challenge/{id}/decline", post(challenges::decline))
280        .route("/api/challenge/{id}/cancel", post(challenges::cancel))
281        // playing: Board API (people) and Bot API (bot accounts)
282        .merge(play_routes("board"))
283        .merge(play_routes("bot"))
284        // accounts: passkeys (pages and the staff check), admins
285        .route("/auth/passkey/options", post(web::passkey_signin_options))
286        .route("/auth/passkey", post(web::passkey_signin))
287        .route("/signup/passkey/options", post(web::signup_passkey_options))
288        .route("/signup/passkey", post(web::signup_passkey))
289        .route("/account/passkeys/options", post(web::account_passkey_options))
290        .route("/account/passkeys", post(web::account_passkey_add))
291        .route("/account/passkeys/remove", post(web::account_passkey_remove))
292        .route("/account/password", post(web::account_password))
293        .route("/account/password/remove", post(web::account_password_remove))
294        .route("/auth/apple", get(apple::start))
295        .route("/auth/apple/callback", post(apple::callback))
296        .route("/auth/apple/name", post(apple::choose_name))
297        .route("/auth/apple/notifications", post(apple::notification))
298        .route("/account/apple/remove", post(apple::account_remove))
299        .route("/account/staff/options", post(web::account_staff_options))
300        .route("/account/staff", post(web::account_staff))
301        .route("/api/account/passkey/options", post(web::api_staff_options))
302        .route("/api/account/passkey", post(web::api_staff))
303        .route("/api/admin/players", get(admin::players))
304        .route("/api/admin/players/{username}", get(admin::player).post(admin::change))
305        .route("/api/admin/players/{username}/tokens/revoke", post(admin::revoke_tokens))
306        .route("/api/admin/players/{username}/passkeys/reset", post(admin::reset_passkeys))
307        .route("/api/admin/partners", get(admin::partners).post(admin::new_partner))
308        .route("/api/admin/partners/{id}/revoke", post(admin::revoke_partner))
309        .route("/@/{username}", get(http::profile))
310        // games
311        .route("/api/games/user/{username}", get(games::by_user))
312        .route("/game/export/{id}", get(games::export))
313        .route("/api/games/export/{id}", get(games::export))
314        // fair play (our own, for reviewers)
315        .route("/api/fairplay/queue", get(fairplay::queue))
316        .route("/api/fairplay/games/{id}", get(fairplay::game))
317        .route("/api/fairplay/players/{username}", get(fairplay::player))
318        .route("/api/fairplay/reviews", post(fairplay::review))
319        // our additions for the training app
320        .route("/api/board/game/{id}/assist", post(lab::assist))
321        .route("/api/esl/position", get(lab::position).post(lab::position_hint))
322        .route("/api/esl/position/moves", get(lab::position_moves))
323        .route("/api/esl/tablebase", get(lab::tablebase))
324        // Photos are sent as base64: allow up to 10 MB.
325        .route(
326            "/api/esl/recognise",
327            post(lab::recognise).layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)),
328        )
329        .route("/api/esl/photos/{id}/confirm", post(lab::confirm_photo))
330        .route("/api/esl/photo-terms", get(lab::photo_terms).post(lab::set_photo_terms))
331        .route("/api/esl/photos", get(lab::photos))
332        .route("/api/esl/photos/{id}/image", get(lab::photo_image))
333        .route("/api/esl/photos/{id}/label", post(lab::label_photo))
334        // the training app (static files)
335        .route("/app", get(app_files::bare))
336        .route("/app/", get(app_files::index))
337        .route("/app/theme.css", get(app_files::theme))
338        .route("/app/{*path}", get(app_files::file))
339        .route("/docs", get(http::docs))
340        .route("/docs/", get(http::docs))
341        .route("/docs/{guide}", get(http::docs))
342        .route("/favicon.svg", get(http::favicon))
343        .route("/favicon.ico", get(http::favicon))
344        .route("/", get(http::index))
345        .layer(middleware::from_fn(cors))
346        .with_state(ctx.clone());
347    let base = ctx.cfg.base_path()?;
348    Ok(if base.is_empty() { api } else { Router::new().nest(&base, api) })
349}
350
351/// The same actions under /api/board and /api/bot (the handler tells them
352/// apart by the matched path).
353fn play_routes(api: &str) -> Router<Ctx> {
354    use http::play;
355    let p = |tail: &str| format!("/api/{api}/game/{{id}}/{tail}");
356    Router::new()
357        .route(&p("move/{uci}"), post(play::make_move))
358        .route(&p("resign"), post(play::resign))
359        .route(&p("abort"), post(play::abort))
360        .route(&p("draw/{accept}"), post(play::draw))
361        .route(&p("takeback/{accept}"), post(play::takeback))
362        .route(&p("claim-victory"), post(play::claim_victory))
363        .route(&p("claim-draw"), post(play::claim_draw))
364        .route(&p("chat"), post(play::chat_post).get(play::chat_get))
365}
366
367/// /metrics, /healthz and /readyz, on their own port.
368pub fn ops_router(ctx: Ctx) -> Router {
369    Router::new()
370        .route("/metrics", get(|State(c): State<Ctx>| async move { c.metrics.render() }))
371        .route("/healthz", get(|| async { "ok" }))
372        .route(
373            "/readyz",
374            get(|State(c): State<Ctx>| async move {
375                match c.db.conn().await {
376                    Ok(conn) if conn.simple_query("SELECT 1").await.is_ok() => (StatusCode::OK, "ready"),
377                    _ => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable"),
378                }
379            }),
380        )
381        .with_state(ctx)
382}