1use 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 pub reader: Option<Arc<chess_vision::Reader>>,
28 slots: Arc<Mutex<HashMap<(i64, &'static str), usize>>>,
29}
30
31const STREAM_LIMITS: &[(&str, usize)] = &[("event", 8), ("game", 40), ("challenge", 20)];
33
34pub 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.staff_passkey_hours),
57 db,
58 cfg: Arc::new(cfg),
59 metrics: Metrics::new(),
60 reader: None,
61 slots: Arc::default(),
62 }
63 }
64
65 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 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 pub fn first_move_ms(&self) -> i64 {
97 i64::try_from(self.cfg.first_move_secs * 1000).unwrap_or(i64::MAX)
98 }
99}
100
101pub 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
116pub enum Reader {
120 Player(Identity),
121 Partner(Partner),
122}
123
124impl Reader {
125 pub fn player(&self) -> Option<&Identity> {
126 match self {
127 Reader::Player(id) => Some(id),
128 Reader::Partner(_) => None,
129 }
130 }
131}
132
133impl FromRequestParts<Ctx> for Reader {
134 type Rejection = ApiError;
135
136 async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> Result<Reader, ApiError> {
137 let t = bearer(&parts.headers).ok_or_else(ApiError::unauthorized)?;
138 if let Some(id) = ctx.auth.token(t).await? {
139 return Ok(Reader::Player(id));
140 }
141 match ctx.auth.partner(t, ctx.cfg.esports()).await? {
142 Some(p) if p.has(crate::auth::PARTNER_READ) => Ok(Reader::Partner(p)),
143 Some(_) => Err(ApiError::forbidden("this partner token can't read games")),
144 None => Err(ApiError::unauthorized()),
145 }
146 }
147}
148
149pub struct Site(pub Partner);
151
152impl FromRequestParts<Ctx> for Site {
153 type Rejection = ApiError;
154
155 async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> Result<Site, ApiError> {
156 let t = bearer(&parts.headers).ok_or_else(ApiError::unauthorized)?;
157 match ctx.auth.partner(t, ctx.cfg.esports()).await? {
158 Some(p) if p.has(crate::auth::PARTNER_SITE) => Ok(Site(p)),
159 Some(_) => Err(ApiError::forbidden("this partner token can't act as the host site")),
160 None => Err(ApiError::unauthorized()),
161 }
162 }
163}
164
165impl Who {
166 pub fn admin(&self) -> Result<&Identity, ApiError> {
168 if self.0.admin {
169 Ok(&self.0)
170 } else if self.0.pending {
171 Err(ApiError::forbidden("admin rights need a passkey check first"))
172 } else {
173 Err(ApiError::forbidden("admins only"))
174 }
175 }
176
177 pub fn need(&self, scopes: &[&str]) -> Result<&Identity, ApiError> {
179 if self.0.has_any(scopes) { Ok(&self.0) } else { Err(ApiError::missing_scope(scopes)) }
180 }
181}
182
183async fn cors(req: Request, next: Next) -> Response {
186 let preflight = req.method() == Method::OPTIONS;
187 let mut resp = if preflight { StatusCode::NO_CONTENT.into_response() } else { next.run(req).await };
188 let h = resp.headers_mut();
189 h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
190 if preflight {
191 h.insert(
192 header::ACCESS_CONTROL_ALLOW_METHODS,
193 HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
194 );
195 h.insert(
196 header::ACCESS_CONTROL_ALLOW_HEADERS,
197 HeaderValue::from_static("Authorization, Content-Type"),
198 );
199 h.insert(header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
200 }
201 resp
202}
203
204pub fn router(ctx: &Ctx) -> anyhow::Result<Router> {
205 use http::{account, admin, app_files, challenges, fairplay, feed, games, lab, oauth, streams, web};
206 let api = Router::new()
207 .route("/oauth", get(oauth::authorize).post(oauth::decide))
209 .route("/login", get(web::login_page).post(web::login))
210 .route("/signup", get(web::signup_page).post(web::signup))
211 .route("/logout", post(web::logout))
212 .route("/account", get(web::account))
213 .route("/account/tokens", post(web::new_token))
214 .route("/account/tokens/revoke", post(web::revoke_token))
215 .route("/auth/esports", get(web::esports_start))
216 .route("/auth/esports/back", get(web::esports_back))
217 .route("/api/esports/token", post(web::esports_token))
218 .route("/api/esports/revoke", post(web::esports_revoke))
219 .route("/api/esports/players", get(web::esports_players))
220 .route("/api/esports/link", post(web::esports_link))
221 .route("/api/feed", get(feed::feed))
222 .route("/api/token", post(oauth::token).delete(oauth::revoke))
223 .route("/api/token/test", post(oauth::test))
224 .route("/api/account", get(account::me))
226 .route("/api/account/email", get(account::email))
227 .route("/api/account/playing", get(account::playing))
228 .route("/api/user/{username}", get(account::user))
229 .route("/api/player/autocomplete", get(account::autocomplete))
230 .route("/api/bot/account/upgrade", post(account::upgrade))
231 .route("/api/bot/online", get(account::bots_online))
232 .route("/api/stream/event", get(streams::events))
234 .route("/api/board/game/stream/{id}", get(streams::board_game))
235 .route("/api/bot/game/stream/{id}", get(streams::bot_game))
236 .route("/api/stream/game/{id}", get(streams::moves))
237 .route("/api/challenge", get(challenges::list))
239 .route("/api/challenge/ai", post(challenges::ai))
240 .route("/api/challenge/{id}", post(challenges::create))
241 .route("/api/challenge/{id}/show", get(challenges::show))
242 .route("/api/challenge/{id}/accept", post(challenges::accept))
243 .route("/api/challenge/{id}/decline", post(challenges::decline))
244 .route("/api/challenge/{id}/cancel", post(challenges::cancel))
245 .merge(play_routes("board"))
247 .merge(play_routes("bot"))
248 .route("/auth/passkey/options", post(web::passkey_signin_options))
250 .route("/auth/passkey", post(web::passkey_signin))
251 .route("/signup/passkey/options", post(web::signup_passkey_options))
252 .route("/signup/passkey", post(web::signup_passkey))
253 .route("/account/passkeys/options", post(web::account_passkey_options))
254 .route("/account/passkeys", post(web::account_passkey_add))
255 .route("/account/passkeys/remove", post(web::account_passkey_remove))
256 .route("/account/password", post(web::account_password))
257 .route("/account/password/remove", post(web::account_password_remove))
258 .route("/account/staff/options", post(web::account_staff_options))
259 .route("/account/staff", post(web::account_staff))
260 .route("/api/account/passkey/options", post(web::api_staff_options))
261 .route("/api/account/passkey", post(web::api_staff))
262 .route("/api/admin/players", get(admin::players))
263 .route("/api/admin/players/{username}", get(admin::player).post(admin::change))
264 .route("/api/admin/players/{username}/tokens/revoke", post(admin::revoke_tokens))
265 .route("/api/admin/players/{username}/passkeys/reset", post(admin::reset_passkeys))
266 .route("/api/admin/partners", get(admin::partners).post(admin::new_partner))
267 .route("/api/admin/partners/{id}/revoke", post(admin::revoke_partner))
268 .route("/@/{username}", get(http::profile))
269 .route("/api/games/user/{username}", get(games::by_user))
271 .route("/game/export/{id}", get(games::export))
272 .route("/api/games/export/{id}", get(games::export))
273 .route("/api/fairplay/queue", get(fairplay::queue))
275 .route("/api/fairplay/games/{id}", get(fairplay::game))
276 .route("/api/fairplay/players/{username}", get(fairplay::player))
277 .route("/api/fairplay/reviews", post(fairplay::review))
278 .route("/api/board/game/{id}/assist", post(lab::assist))
280 .route("/api/esl/position", get(lab::position).post(lab::position_hint))
281 .route("/api/esl/position/moves", get(lab::position_moves))
282 .route("/api/esl/tablebase", get(lab::tablebase))
283 .route(
285 "/api/esl/recognise",
286 post(lab::recognise).layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)),
287 )
288 .route("/api/esl/photos/{id}/confirm", post(lab::confirm_photo))
289 .route("/api/esl/photo-terms", get(lab::photo_terms).post(lab::set_photo_terms))
290 .route("/api/esl/photos", get(lab::photos))
291 .route("/api/esl/photos/{id}/image", get(lab::photo_image))
292 .route("/api/esl/photos/{id}/label", post(lab::label_photo))
293 .route("/app", get(app_files::bare))
295 .route("/app/", get(app_files::index))
296 .route("/app/theme.css", get(app_files::theme))
297 .route("/app/{*path}", get(app_files::file))
298 .route("/docs", get(http::docs))
299 .route("/docs/", get(http::docs))
300 .route("/docs/{guide}", get(http::docs))
301 .route("/favicon.svg", get(http::favicon))
302 .route("/favicon.ico", get(http::favicon))
303 .route("/", get(http::index))
304 .layer(middleware::from_fn(cors))
305 .with_state(ctx.clone());
306 let base = ctx.cfg.base_path()?;
307 Ok(if base.is_empty() { api } else { Router::new().nest(&base, api) })
308}
309
310fn play_routes(api: &str) -> Router<Ctx> {
313 use http::play;
314 let p = |tail: &str| format!("/api/{api}/game/{{id}}/{tail}");
315 Router::new()
316 .route(&p("move/{uci}"), post(play::make_move))
317 .route(&p("resign"), post(play::resign))
318 .route(&p("abort"), post(play::abort))
319 .route(&p("draw/{accept}"), post(play::draw))
320 .route(&p("takeback/{accept}"), post(play::takeback))
321 .route(&p("claim-victory"), post(play::claim_victory))
322 .route(&p("claim-draw"), post(play::claim_draw))
323 .route(&p("chat"), post(play::chat_post).get(play::chat_get))
324}
325
326pub fn ops_router(ctx: Ctx) -> Router {
328 Router::new()
329 .route("/metrics", get(|State(c): State<Ctx>| async move { c.metrics.render() }))
330 .route("/healthz", get(|| async { "ok" }))
331 .route(
332 "/readyz",
333 get(|State(c): State<Ctx>| async move {
334 match c.db.conn().await {
335 Ok(conn) if conn.simple_query("SELECT 1").await.is_ok() => (StatusCode::OK, "ready"),
336 _ => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable"),
337 }
338 }),
339 )
340 .with_state(ctx)
341}