Skip to main content

open_chess_server/http/
account.rs

1//! Account, user and bot endpoints.
2
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::response::{IntoResponse, Response};
6use bytes::Bytes;
7use serde::Deserialize;
8use serde_json::json;
9
10use crate::app::{Ctx, Reader, Who};
11use crate::error::{ApiError, ApiResult};
12use crate::http::{line, ndjson, ok};
13use crate::model;
14
15/// `GET /api/account`: Lichess's, plus what this server adds about you:
16/// `roles` here (admin, manager), whether your passkey check is done
17/// (`staff`: "checked", "pending" or absent), how many passkeys you have and
18/// whether an Apple ID is linked (`apple`).
19pub async fn me(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
20    let c = ctx.db.conn().await?;
21    let a = model::ensure_account(&ctx.db, &*c, who.0.player).await?;
22    let mut v = serde_json::to_value(model::user_json(&ctx.db, &*c, &a, ctx.cfg.public_url()).await?)
23        .map_err(|e| ApiError::from(anyhow::Error::from(e)))?;
24    let r = c
25        .query_one(
26            &ctx.db.sql(
27                "SELECT role, roles, (SELECT count(*) FROM chess.passkeys WHERE player_id = $1),
28                        EXISTS (SELECT 1 FROM chess.apple_ids WHERE player_id = $1)
29                   FROM chess.players WHERE id = $1",
30            ),
31            &[&who.0.player],
32        )
33        .await?;
34    let (admin, manager) = crate::auth::staff_roles(&r.get::<_, String>(0), &r.get::<_, Vec<String>>(1));
35    let roles: Vec<&str> =
36        [(admin, "admin"), (manager, "manager")].iter().filter(|x| x.0).map(|x| x.1).collect();
37    v["roles"] = json!(roles);
38    v["passkeys"] = json!(r.get::<_, i64>(2));
39    v["apple"] = json!(r.get::<_, bool>(3));
40    if who.0.admin || who.0.manager {
41        v["staff"] = json!("checked");
42    } else if who.0.pending {
43        v["staff"] = json!("pending");
44    }
45    v["accountUrl"] = json!(format!("{}/account", ctx.cfg.public_url()));
46    Ok(Json(v).into_response())
47}
48
49pub async fn email(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
50    let id = who.need(&["email:read"])?;
51    let c = ctx.db.conn().await?;
52    let email: Option<String> = c
53        .query_opt(&ctx.db.sql("SELECT email FROM chess.players WHERE id = $1"), &[&id.player])
54        .await?
55        .and_then(|r| r.get(0));
56    Ok(Json(json!({"email": email.unwrap_or_default()})).into_response())
57}
58
59#[derive(Deserialize)]
60pub struct Nb {
61    nb: Option<usize>,
62}
63
64pub async fn playing(State(ctx): State<Ctx>, who: Who, Query(q): Query<Nb>) -> ApiResult<Response> {
65    let c = ctx.db.conn().await?;
66    let games = model::ongoing_games(&ctx.db, &*c, who.0.player).await?;
67    let now: Vec<chess_proto::GameEventInfo> = games
68        .iter()
69        .filter_map(|g| g.event_info(who.0.player))
70        .take(q.nb.unwrap_or(9).clamp(1, 50))
71        .collect();
72    let my_turn = now.iter().filter(|g| g.is_my_turn).count();
73    Ok(Json(chess_proto::NowPlaying { now_playing: now, nb_my_turn: my_turn as i64 }).into_response())
74}
75
76pub async fn user(
77    State(ctx): State<Ctx>,
78    _reader: Reader,
79    Path(username): Path<String>,
80) -> ApiResult<Response> {
81    let c = ctx.db.conn().await?;
82    let a =
83        model::account_by_name(&ctx.db, &*c, &username).await?.ok_or_else(|| ApiError::not_found("user"))?;
84    Ok(Json(model::user_json(&ctx.db, &*c, &a, ctx.cfg.public_url()).await?).into_response())
85}
86
87/// Make this account a bot. Lichess's rules: irreversible, and only for an
88/// account that hasn't played a game.
89pub async fn upgrade(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
90    let id = who.need(&["bot:play"])?;
91    let c = ctx.db.conn().await?;
92    let a = model::ensure_account(&ctx.db, &*c, id.player).await?;
93    if a.bot {
94        return Err(ApiError::bad("This account is already a bot account"));
95    }
96    let played: bool = c
97        .query_one(
98            &ctx.db.sql("SELECT EXISTS (SELECT 1 FROM chess.games WHERE white_id = $1 OR black_id = $1)"),
99            &[&id.player],
100        )
101        .await?
102        .get(0);
103    if played {
104        return Err(ApiError::bad("This account has played games and can't be a bot account"));
105    }
106    c.execute(&ctx.db.sql("UPDATE chess.accounts SET kind = 'bot' WHERE player_id = $1"), &[&id.player])
107        .await?;
108    Ok(ok())
109}
110
111/// Bots seen in the last ten minutes, as NDJSON users.
112pub async fn bots_online(
113    State(ctx): State<Ctx>,
114    _reader: Reader,
115    Query(q): Query<Nb>,
116) -> ApiResult<Response> {
117    let c = ctx.db.conn().await?;
118    let rows = c
119        .query(
120            &ctx.db.sql(
121                "SELECT player_id FROM chess.accounts
122                  WHERE kind = 'bot' AND seen_at > now() - interval '10 minutes'
123                  ORDER BY seen_at DESC LIMIT $1",
124            ),
125            &[&(q.nb.unwrap_or(50).clamp(1, 300) as i64)],
126        )
127        .await?;
128    let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(rows.len().max(1));
129    for r in rows {
130        if let Some(a) = model::account(&ctx.db, &*c, r.get(0)).await? {
131            let u = model::user_json(&ctx.db, &*c, &a, ctx.cfg.public_url()).await?;
132            let _ = tx.try_send(line(&u));
133        }
134    }
135    drop(tx);
136    Ok(ndjson(rx))
137}
138
139#[derive(Debug, Deserialize)]
140pub struct Autocomplete {
141    term: Option<String>,
142    object: Option<bool>,
143    nb: Option<i64>,
144}
145
146/// Lichess's `GET /api/player/autocomplete`: players whose name starts
147/// with (then contains) `term`, for a type-ahead list. With `object=true`,
148/// `{"result": [{id, name, online}]}`; otherwise a JSON array of names. The
149/// computer's accounts (`ai-level-N`) aren't offered: it's played with
150/// `POST /api/challenge/ai`.
151pub async fn autocomplete(
152    State(ctx): State<Ctx>,
153    _reader: Reader,
154    Query(q): Query<Autocomplete>,
155) -> ApiResult<Response> {
156    let term = q.term.unwrap_or_default().trim().to_lowercase();
157    if term.is_empty() {
158        return Ok(
159            Json(if q.object.unwrap_or(false) { json!({ "result": [] }) } else { json!([]) }).into_response()
160        );
161    }
162    let like = format!("%{}%", term.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
163    let c = ctx.db.conn().await?;
164    let rows = c
165        .query(
166            &ctx.db.sql(
167                "SELECT username, seen_at > now() - interval '10 minutes' AS online
168                   FROM chess.accounts
169                  WHERE lower(username) LIKE $1 AND username !~ '^ai-level-[0-9]$'
170                  ORDER BY (lower(username) LIKE $2) DESC, lower(username)
171                  LIMIT $3",
172            ),
173            &[&like, &format!("{}%", &like[1..]), &q.nb.unwrap_or(12).clamp(1, 50)],
174        )
175        .await?;
176    let names: Vec<(String, bool)> = rows
177        .iter()
178        .map(|r| (r.get("username"), r.get::<_, Option<bool>>("online").unwrap_or(false)))
179        .collect();
180    Ok(Json(if q.object.unwrap_or(false) {
181        json!({ "result": names.iter().map(|(n, on)| json!({ "id": n.to_lowercase(), "name": n, "online": on })).collect::<Vec<_>>() })
182    } else {
183        json!(names.iter().map(|(n, _)| n).collect::<Vec<_>>())
184    })
185    .into_response())
186}