Skip to main content

open_chess_server/http/
mobile.rs

1//! What the iOS app needs beyond the Lichess API (docs/ios.md).
2//!
3//! * `POST /api/games/offline`: games played offline, uploaded later.
4//!   Against the computer they're recorded (and rated, at the level's fixed
5//!   rating, when no help was used) at once; between two people on one
6//!   device they wait for the other player to confirm.
7//! * `POST /api/games/{id}/confirm`: the other player accepts or declines
8//!   such a result.
9//! * `GET /api/sync?since=`: one small answer with everything the app shows
10//!   between syncs, gzipped, for slow and metered links.
11//! * `POST /api/photos`: a board photo with the position the player
12//!   confirmed, under the photo licence.
13//! * `POST /api/push/devices`, `DELETE /api/push/devices/{token}`: where
14//!   to send "your move".
15//! * `DELETE /api/account`: closing the account from the app (the App
16//!   Store requires it).
17
18use std::collections::HashMap;
19
20use axum::Json;
21use axum::extract::{Path, Query, State};
22use axum::http::StatusCode;
23use axum::response::{IntoResponse, Response};
24use base64::Engine as _;
25use chess_core::{Game, Side, Speed, Status};
26use serde::Deserialize;
27use serde_json::{Value, json};
28use tokio_postgres::Transaction;
29
30use crate::app::{Ctx, Who};
31use crate::error::{ApiError, ApiResult};
32use crate::model::{self, Account};
33use crate::play;
34
35/// Games per upload.
36const MAX_GAMES: usize = 50;
37/// A photo's largest size (bytes, decoded).
38const MAX_PHOTO: usize = 4 * 1024 * 1024;
39
40/// One finished game as the app's core records it
41/// (`GameSession::offline_record_json`).
42#[derive(Debug, Clone, Deserialize, serde::Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct OfflineRecord {
45    pub client_id: String,
46    /// "computer" or "passAndPlay".
47    pub mode: String,
48    #[serde(default)]
49    pub bot_level: Option<u8>,
50    pub white: String,
51    pub black: String,
52    pub speed: String,
53    #[serde(default)]
54    pub initial_fen: Option<String>,
55    pub moves: Vec<String>,
56    #[serde(default)]
57    pub move_times_ms: Vec<i64>,
58    pub started_ms: i64,
59    pub status: String,
60    #[serde(default)]
61    pub winner: Option<String>,
62    #[serde(default)]
63    pub assistance: Assistance,
64    #[serde(default)]
65    pub ratable: bool,
66}
67
68#[derive(Debug, Clone, Copy, Default, Deserialize, serde::Serialize)]
69pub struct Assistance {
70    #[serde(default)]
71    pub hints: u32,
72    #[serde(default)]
73    pub takebacks: u32,
74    #[serde(default)]
75    pub analysed: bool,
76}
77
78impl Assistance {
79    fn any(self) -> bool {
80        self.hints > 0 || self.takebacks > 0 || self.analysed
81    }
82}
83
84/// A record checked against the rules: the replayed game and its result.
85#[derive(Debug)]
86struct Checked {
87    game: Game,
88    status: Status,
89    winner: Option<Side>,
90    speed: Speed,
91    /// Why it can't be rated, when it can't.
92    unrated: Option<&'static str>,
93}
94
95/// Replay and check a record. `Err`: not a game we'll keep.
96fn check(r: &OfflineRecord, now_ms: i64) -> Result<Checked, String> {
97    if r.client_id.is_empty() || r.client_id.len() > 64 {
98        return Err("clientId is 1 to 64 characters".into());
99    }
100    if r.moves.len() > 1000 {
101        return Err("too many moves".into());
102    }
103    let fen = r.initial_fen.as_deref().filter(|f| !f.trim().is_empty());
104    let game =
105        Game::from_moves(fen, &r.moves.join(" ")).map_err(|e| format!("the moves don't replay: {e}"))?;
106    let speed = Speed::from_key(&r.speed).ok_or_else(|| format!("unknown speed {}", r.speed))?;
107    let claimed_winner = match r.winner.as_deref() {
108        None | Some("") => None,
109        Some(w) => Some(Side::from_name(w).ok_or_else(|| format!("winner {w}?"))?),
110    };
111    // The board decides when it can (mate, stalemate, repetition…); the
112    // record otherwise (resignation, agreed draw, time).
113    let (status, winner) = if let Some(o) = game.outcome() {
114        (o.status, o.winner)
115    } else {
116        match Status::from_name(&r.status) {
117            Some(Status::Resign | Status::Outoftime) if claimed_winner.is_some() => {
118                let s = Status::from_name(&r.status).expect("matched");
119                let w = claimed_winner.expect("checked");
120                if s == Status::Outoftime && game.cannot_win(w) { (s, None) } else { (s, Some(w)) }
121            }
122            Some(Status::Draw) => (Status::Draw, None),
123            _ => return Err(format!("not a finished game ({})", r.status)),
124        }
125    };
126    let mut unrated = None;
127    if r.assistance.any() {
128        unrated = Some("help was used");
129    } else if fen.is_some() {
130        unrated = Some("it started from a set position");
131    } else if game.ply() < 2 {
132        unrated = Some("fewer than two moves");
133    }
134    // Move times: one per move, in order, not in the future.
135    if !r.move_times_ms.is_empty() {
136        let ordered = r.move_times_ms.windows(2).all(|w| w[0] <= w[1]);
137        let fits = r.move_times_ms.len() == r.moves.len()
138            && r.move_times_ms.first().is_some_and(|&t| t >= r.started_ms)
139            && r.move_times_ms.last().is_some_and(|&t| t <= now_ms + 300_000);
140        if !(ordered && fits) && unrated.is_none() {
141            unrated = Some("the move times don't add up");
142        }
143    } else if unrated.is_none() {
144        unrated = Some("no move times");
145    }
146    Ok(Checked { game, status, winner, speed, unrated })
147}
148
149#[derive(Deserialize)]
150pub struct Upload {
151    games: Vec<Value>,
152}
153
154/// `POST /api/games/offline`.
155pub async fn upload(State(ctx): State<Ctx>, who: Who, Json(body): Json<Upload>) -> ApiResult<Response> {
156    let id = who.need(&["board:play"])?.clone();
157    if body.games.len() > MAX_GAMES {
158        return Err(ApiError::bad(format!("at most {MAX_GAMES} games at a time")));
159    }
160    let mut results = Vec::with_capacity(body.games.len());
161    for g in body.games {
162        let client_id = g["clientId"].as_str().unwrap_or_default().to_owned();
163        let r = match serde_json::from_value::<OfflineRecord>(g) {
164            Ok(r) => upload_one(&ctx, id.player, r).await?,
165            Err(e) => json!({"clientId": client_id, "status": "rejected", "reason": e.to_string()}),
166        };
167        results.push(r);
168    }
169    Ok(Json(json!({ "results": results })).into_response())
170}
171
172async fn upload_one(ctx: &Ctx, player: i64, r: OfflineRecord) -> ApiResult<Value> {
173    let reject = |reason: String| json!({"clientId": r.client_id, "status": "rejected", "reason": reason});
174    let mut c = ctx.db.conn().await?;
175    let tx = c.transaction().await?;
176    let me = model::ensure_account(&ctx.db, &tx, player).await?;
177    if let Some(row) = tx
178        .query_opt(
179            &ctx.db.sql(
180                "SELECT game_id, status FROM chess.offline_games WHERE player_id = $1 AND client_id = $2",
181            ),
182            &[&player, &r.client_id],
183        )
184        .await?
185    {
186        let (game_id, status): (String, String) = (row.get(0), row.get(1));
187        return Ok(
188            json!({"clientId": r.client_id, "gameId": game_id, "status": "duplicate", "state": status}),
189        );
190    }
191    let now = chrono::Utc::now().timestamp_millis();
192    let checked = match check(&r, now) {
193        Ok(c) => c,
194        Err(e) => return Ok(reject(e)),
195    };
196    let my_side = if me.username.eq_ignore_ascii_case(&r.white) {
197        Side::White
198    } else if me.username.eq_ignore_ascii_case(&r.black) {
199        Side::Black
200    } else {
201        return Ok(reject(format!("you ({}) aren't white or black", me.username)));
202    };
203    let other_name = if my_side == Side::White { &r.black } else { &r.white };
204    let game_id = model::new_id();
205    let result = match r.mode.as_str() {
206        "computer" => {
207            let level = r.bot_level.unwrap_or(0);
208            if !(1..=8).contains(&level) || crate::ai::level_of(other_name) != Some(level) {
209                return Ok(reject(format!(
210                    "the computer is ai-level-1 to 8 (got {other_name}, level {level})"
211                )));
212            }
213            let bot = crate::ai::ensure(ctx, level).await?;
214            let rated = checked.unrated.is_none();
215            let (white, black) = if my_side == Side::White { (&me, &bot) } else { (&bot, &me) };
216            let row = record(ctx, &tx, &game_id, white, black, &r, &checked, rated).await?;
217            remember(ctx, &tx, player, &r, &game_id, None, "recorded", rated).await?;
218            let diff = if my_side == Side::White { row.white_rating_diff } else { row.black_rating_diff };
219            json!({"clientId": r.client_id, "gameId": game_id, "status": if rated { "rated" } else { "unrated" },
220                   "reason": checked.unrated, "ratingDiff": diff})
221        }
222        "passAndPlay" => {
223            let Some(other) = model::account_by_name(&ctx.db, &tx, other_name).await? else {
224                return Ok(reject(format!("no player called {other_name}")));
225            };
226            if other.player_id == player || other.bot {
227                return Ok(reject(format!("{other_name} can't confirm this game")));
228            }
229            remember(
230                ctx,
231                &tx,
232                player,
233                &r,
234                &game_id,
235                Some(other.player_id),
236                "pending",
237                checked.unrated.is_none(),
238            )
239            .await?;
240            crate::push::notify(
241                ctx,
242                other.player_id,
243                "A result to confirm".into(),
244                format!("{} recorded a game against you. Confirm it in the app.", me.username),
245                Some(game_id.clone()),
246                None,
247            );
248            json!({"clientId": r.client_id, "gameId": game_id, "status": "pending", "reason": checked.unrated})
249        }
250        m => return Ok(reject(format!("mode {m}?"))),
251    };
252    tx.commit().await?;
253    Ok(result)
254}
255
256#[allow(clippy::too_many_arguments)]
257async fn remember(
258    ctx: &Ctx,
259    tx: &Transaction<'_>,
260    player: i64,
261    r: &OfflineRecord,
262    game_id: &str,
263    opponent: Option<i64>,
264    status: &str,
265    rated: bool,
266) -> anyhow::Result<()> {
267    tx.execute(
268        &ctx.db.sql(
269            "INSERT INTO chess.offline_games (player_id, client_id, game_id, opponent_id, record, status, rated, decided_at)
270             VALUES ($1, $2, $3, $4, $5, $6, $7, CASE WHEN $6 = 'pending' THEN NULL ELSE now() END)",
271        ),
272        &[&player, &r.client_id, &game_id, &opponent, &serde_json::to_value(r)?, &status, &rated],
273    )
274    .await?;
275    Ok(())
276}
277
278/// Record a checked offline game as a finished game here: rated as any
279/// other (play::finish), queued for fair-play screening, and in the feed
280/// from now (its feed time is the upload, its finish time the last move).
281#[allow(clippy::too_many_arguments)]
282async fn record(
283    ctx: &Ctx,
284    tx: &Transaction<'_>,
285    id: &str,
286    white: &Account,
287    black: &Account,
288    r: &OfflineRecord,
289    c: &Checked,
290    rated: bool,
291) -> anyhow::Result<model::GameRow> {
292    let perf = c.speed.key();
293    let (wr, _, _) = model::rating(&ctx.db, tx, white.player_id, perf).await?;
294    let (br, _, _) = model::rating(&ctx.db, tx, black.player_id, perf).await?;
295    let times: Vec<i64> = r.move_times_ms.clone();
296    let move_ms: Vec<i64> = times
297        .iter()
298        .scan(r.started_ms, |prev, &t| {
299            let d = (t - *prev).max(0);
300            *prev = t;
301            Some(d)
302        })
303        .collect();
304    let clock_ms: Vec<i64> = vec![model::NO_CLOCK_MS; move_ms.len()];
305    let last = times.last().copied().unwrap_or(r.started_ms);
306    let fen = r.initial_fen.as_deref().filter(|f| !f.trim().is_empty());
307    #[allow(clippy::cast_precision_loss)]
308    let (started, ended) = (r.started_ms as f64 / 1000.0, last as f64 / 1000.0);
309    tx.execute(
310        &ctx.db.sql(
311            "INSERT INTO chess.games (id, white_id, black_id, variant, initial_fen, rated, source, speed,
312                    status, moves, ply, fen, white_rating, black_rating, white_provisional, black_provisional,
313                    move_ms, clock_ms, created_at, turn_started_at, last_move_at)
314             VALUES ($1, $2, $3, $4, $5, $6, 'offline', $7, 20, $8, $9, $10, $11, $12, $13, $14, $15, $16,
315                     to_timestamp($17), to_timestamp($18), to_timestamp($18))",
316        ),
317        &[
318            &id,
319            &white.player_id,
320            &black.player_id,
321            &(if fen.is_some() { "fromPosition" } else { "standard" }),
322            &c.game.initial_fen(),
323            &rated,
324            &perf,
325            &r.moves.join(" "),
326            &i32::try_from(c.game.ply()).unwrap_or(i32::MAX),
327            &c.game.fen(),
328            &wr.int(),
329            &br.int(),
330            &wr.provisional(),
331            &br.provisional(),
332            &move_ms,
333            &clock_ms,
334            &started,
335            &ended,
336        ],
337    )
338    .await?;
339    let row = model::game(&ctx.db, tx, id).await?.ok_or_else(|| anyhow::anyhow!("offline game vanished"))?;
340    play::finish(ctx, tx, &row, c.status, c.winner).await?;
341    tx.execute(
342        &ctx.db.sql("UPDATE chess.games SET finished_at = to_timestamp($2) WHERE id = $1"),
343        &[&id, &ended],
344    )
345    .await?;
346    model::game(&ctx.db, tx, id).await?.ok_or_else(|| anyhow::anyhow!("offline game vanished"))
347}
348
349#[derive(Deserialize)]
350pub struct Confirm {
351    accept: bool,
352}
353
354/// `POST /api/games/{id}/confirm`: the other player of a game played on
355/// one device accepts (it's recorded, and rated if it can be) or declines.
356pub async fn confirm(
357    State(ctx): State<Ctx>,
358    who: Who,
359    Path(game_id): Path<String>,
360    Json(body): Json<Confirm>,
361) -> ApiResult<Response> {
362    let id = who.need(&["board:play"])?.clone();
363    // A game recorded live on a real board?
364    if let Some(answer) = crate::http::otb::confirm(&ctx, id.player, &game_id, body.accept).await? {
365        return Ok(Json(answer).into_response());
366    }
367    let mut c = ctx.db.conn().await?;
368    let tx = c.transaction().await?;
369    let row = tx
370        .query_opt(
371            &ctx.db.sql(
372                "SELECT player_id, record, status, rated, opponent_id FROM chess.offline_games
373                  WHERE game_id = $1 FOR UPDATE",
374            ),
375            &[&game_id],
376        )
377        .await?
378        .ok_or_else(|| ApiError::not_found("game to confirm"))?;
379    let (uploader, stored, status, rated, opponent): (i64, Value, String, bool, Option<i64>) =
380        (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4));
381    if opponent != Some(id.player) {
382        return Err(ApiError::not_found("game to confirm"));
383    }
384    if status != "pending" {
385        return Err(ApiError::conflict(format!("already {status}")));
386    }
387    if !body.accept {
388        tx.execute(
389            &ctx.db.sql(
390                "UPDATE chess.offline_games SET status = 'declined', decided_at = now() WHERE game_id = $1",
391            ),
392            &[&game_id],
393        )
394        .await?;
395        tx.commit().await?;
396        return Ok(Json(json!({"gameId": game_id, "status": "declined"})).into_response());
397    }
398    let r: OfflineRecord =
399        serde_json::from_value(stored).map_err(|e| anyhow::anyhow!("stored record: {e}"))?;
400    let checked = check(&r, chrono::Utc::now().timestamp_millis()).map_err(ApiError::bad)?;
401    let me = model::ensure_account(&ctx.db, &tx, id.player).await?;
402    let them = model::account(&ctx.db, &tx, uploader).await?.ok_or_else(|| ApiError::not_found("player"))?;
403    let (white, black) =
404        if them.username.eq_ignore_ascii_case(&r.white) { (&them, &me) } else { (&me, &them) };
405    let rated = rated && checked.unrated.is_none();
406    let row = record(&ctx, &tx, &game_id, white, black, &r, &checked, rated).await?;
407    tx.execute(
408        &ctx.db
409            .sql("UPDATE chess.offline_games SET status = 'recorded', decided_at = now() WHERE game_id = $1"),
410        &[&game_id],
411    )
412    .await?;
413    tx.commit().await?;
414    let diff = if row.white.player_id == id.player { row.white_rating_diff } else { row.black_rating_diff };
415    Ok(Json(
416        json!({"gameId": game_id, "status": if rated { "rated" } else { "unrated" }, "ratingDiff": diff}),
417    )
418    .into_response())
419}
420
421/// `GET /api/sync?since=<Unix ms>`: ratings, games in progress, results to
422/// confirm, challenges, and games finished since `since`.
423pub async fn sync(
424    State(ctx): State<Ctx>,
425    who: Who,
426    Query(q): Query<HashMap<String, String>>,
427) -> ApiResult<Response> {
428    let player = who.0.player;
429    let since: i64 = q.get("since").and_then(|s| s.parse().ok()).unwrap_or(0);
430    let c = ctx.db.conn().await?;
431    let me = model::ensure_account(&ctx.db, &*c, player).await?;
432    let mut ratings = serde_json::Map::new();
433    for r in c
434        .query(
435            &ctx.db.sql(
436                "SELECT perf, rating, deviation, volatility, games FROM chess.ratings WHERE player_id = $1",
437            ),
438            &[&player],
439        )
440        .await?
441    {
442        let deviation: f64 = r.get(2);
443        ratings.insert(
444            r.get(0),
445            json!({"rating": r.get::<_, f64>(1), "deviation": deviation, "volatility": r.get::<_, f64>(3),
446                   "provisional": deviation > chess_core::Rating::PROVISIONAL_DEVIATION, "games": r.get::<_, i32>(4)}),
447        );
448    }
449    let playing: Vec<Value> = model::ongoing_games(&ctx.db, &*c, player)
450        .await?
451        .iter()
452        .filter_map(|g| {
453            let side = g.side_of(player)?;
454            let opp = g.player(side.opposite());
455            let (orating, _, _) = g.rating(side.opposite());
456            Some(json!({
457                "gameId": g.id, "color": side.name(), "fen": g.fen, "lastMove": g.last_move(),
458                "isMyTurn": g.status.is_ongoing() && g.turn() == side, "speed": g.perf_key(), "rated": g.rated,
459                "daysPerTurn": g.days_per_turn,
460                "secondsLeft": if g.has_clock() { Some(g.live_ms(side) / 1000) } else { g.days_left_ms().map(|m| m / 1000) },
461                "opponent": {"username": opp.username, "rating": orating},
462            }))
463        })
464        .collect();
465    let mut confirmations: Vec<Value> = c
466        .query(
467            &ctx.db.sql(
468                "SELECT o.game_id, o.record, a.username, (extract(epoch from o.created_at) * 1000)::bigint
469                   FROM chess.offline_games o JOIN chess.accounts a ON a.player_id = o.player_id
470                  WHERE o.opponent_id = $1 AND o.status = 'pending' ORDER BY o.created_at",
471            ),
472            &[&player],
473        )
474        .await?
475        .iter()
476        .map(|r| {
477            let rec: Value = r.get(1);
478            json!({"id": r.get::<_, String>(0), "white": rec["white"], "black": rec["black"], "winner": rec["winner"],
479                   "status": rec["status"], "speed": rec["speed"], "moves": rec["moves"].as_array().map_or(0, Vec::len),
480                   "uploadedBy": r.get::<_, String>(2), "createdAt": r.get::<_, i64>(3), "kind": "offline"})
481        })
482        .collect();
483    confirmations.extend(crate::http::otb::waiting_for(&ctx, &*c, player).await?);
484    let challenges: Vec<Value> = c
485        .query(
486            &ctx.db.sql(
487                "SELECT ch.id, a.username, ch.rated, ch.clock_limit, ch.clock_increment, ch.days
488                   FROM chess.challenges ch JOIN chess.accounts a ON a.player_id = ch.challenger_id
489                  WHERE ch.dest_id = $1 AND ch.status = 'created' AND ch.expires_at > now()
490                  ORDER BY ch.created_at",
491            ),
492            &[&player],
493        )
494        .await?
495        .iter()
496        .map(|r| {
497            let (limit, inc, days): (Option<i32>, Option<i32>, Option<i32>) = (r.get(3), r.get(4), r.get(5));
498            let speed = match (limit, inc) {
499                (Some(l), Some(i)) => Speed::from_clock(l.unsigned_abs(), i.unsigned_abs()),
500                _ => Speed::Correspondence,
501            };
502            json!({"id": r.get::<_, String>(0), "from": r.get::<_, String>(1), "rated": r.get::<_, bool>(2),
503                   "speed": speed.key(), "daysPerTurn": days})
504        })
505        .collect();
506    #[allow(clippy::cast_precision_loss)]
507    let since_s = since as f64 / 1000.0;
508    let finished: Vec<Value> = c
509        .query(
510            &ctx.db.sql(&format!(
511                "{} WHERE (g.white_id = $1 OR g.black_id = $1) AND g.feed_at > to_timestamp($2)
512                  ORDER BY g.feed_at LIMIT 100",
513                model::GAME_SELECT
514            )),
515            &[&player, &since_s],
516        )
517        .await?
518        .iter()
519        .map(model::GameRow::from_row)
520        .filter_map(|g| {
521            let side = g.side_of(player)?;
522            let diff = if side == Side::White { g.white_rating_diff } else { g.black_rating_diff };
523            Some(json!({"gameId": g.id, "status": g.status.name(), "winner": g.winner.map(Side::name),
524                        "speed": g.perf_key(), "rated": g.rated, "ratingDiff": diff, "source": g.source}))
525        })
526        .collect();
527    Ok(Json(json!({
528        "now": chrono::Utc::now().timestamp_millis(),
529        "username": me.username,
530        "ratings": ratings,
531        "playing": playing,
532        "confirmations": confirmations,
533        "challenges": challenges,
534        "finished": finished,
535    }))
536    .into_response())
537}
538
539#[derive(Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct Photo {
542    client_id: String,
543    /// Base64 JPEG.
544    image: String,
545    /// The position the player confirmed (FEN placement, White's side).
546    placement: String,
547    #[serde(default)]
548    confidence: Option<f32>,
549    #[serde(default)]
550    model: Option<String>,
551    /// Who says what it shows: "player" (they confirmed it; the default)
552    /// or "board" (a connected board, during the game `gameId`).
553    #[serde(default)]
554    label_source: Option<String>,
555    /// The real-board game it was taken in.
556    #[serde(default)]
557    game_id: Option<String>,
558    /// Which camera, when there were several.
559    #[serde(default)]
560    angle: Option<u8>,
561    /// When it was taken (Unix ms).
562    #[serde(default)]
563    at_ms: Option<i64>,
564    /// Squares the board reader got wrong.
565    #[serde(default)]
566    distance: Option<u32>,
567}
568
569/// `POST /api/photos`: a photo with its confirmed position, kept as
570/// training data under the photo licence the player accepted.
571pub async fn photo(State(ctx): State<Ctx>, who: Who, Json(p): Json<Photo>) -> ApiResult<Response> {
572    let player = who.0.player;
573    if p.client_id.is_empty() || p.client_id.len() > 64 {
574        return Err(ApiError::bad("clientId is 1 to 64 characters"));
575    }
576    let Some(public) = crate::http::lab::accepted_terms(&ctx, player).await? else {
577        return Err(ApiError::forbidden("accept the photo licence first (/api/esl/photo-terms)"));
578    };
579    let placement = p.placement.split_whitespace().next().unwrap_or_default().to_owned();
580    if chess_analysis::photo::parse_placement(&placement).is_none() {
581        return Err(ApiError::bad("placement isn't a FEN placement"));
582    }
583    let bytes = base64::engine::general_purpose::STANDARD
584        .decode(p.image.trim())
585        .map_err(|_| ApiError::bad("image isn't base64"))?;
586    if bytes.len() > MAX_PHOTO || !bytes.starts_with(&[0xff, 0xd8]) {
587        return Err(ApiError::bad("image is a JPEG of at most 4 MB"));
588    }
589    let source = p.label_source.as_deref().unwrap_or("player");
590    if !matches!(source, "player" | "board") {
591        return Err(ApiError::bad("labelSource is player or board"));
592    }
593    let c = ctx.db.conn().await?;
594    if let Some(g) = &p.game_id {
595        let recorded: bool = c
596            .query_one(
597                &ctx.db.sql(
598                    "SELECT EXISTS (SELECT 1 FROM chess.otb_games WHERE game_id = $1 AND recorder_id = $2)",
599                ),
600                &[g, &player],
601            )
602            .await?
603            .get(0);
604        if !recorded {
605            return Err(ApiError::bad("gameId isn't a real-board game you recorded"));
606        }
607    } else if source == "board" {
608        return Err(ApiError::bad("a board's label comes with the gameId it was played in"));
609    }
610    let reading = json!({"placement": placement, "confidence": p.confidence, "model": p.model, "from": "ios",
611                         "angle": p.angle, "atMs": p.at_ms, "distance": p.distance});
612    let row = c
613        .query_opt(
614            &ctx.db.sql(
615                "INSERT INTO chess.board_photos (player_id, image, mime, reason, reading, label, label_source,
616                        labelled_by, labelled_at, terms, public, client_id, game_id)
617                 VALUES ($1, $2, 'image/jpeg', 'contributed', $3, $4, $8, $1, now(), $5, $6, $7, $9)
618                 ON CONFLICT (player_id, client_id) WHERE client_id IS NOT NULL DO NOTHING RETURNING id",
619            ),
620            &[&player, &bytes, &reading, &placement, &crate::http::lab::PHOTO_TERMS, &public, &p.client_id, &source, &p.game_id],
621        )
622        .await?;
623    let id: i64 = match row {
624        Some(r) => r.get(0),
625        None => c
626            .query_one(
627                &ctx.db.sql("SELECT id FROM chess.board_photos WHERE player_id = $1 AND client_id = $2"),
628                &[&player, &p.client_id],
629            )
630            .await?
631            .get(0),
632    };
633    Ok(Json(json!({"id": id})).into_response())
634}
635
636#[derive(Deserialize)]
637pub struct Device {
638    token: String,
639    #[serde(default)]
640    sandbox: bool,
641}
642
643/// `POST /api/push/devices`.
644pub async fn add_device(State(ctx): State<Ctx>, who: Who, Json(d): Json<Device>) -> ApiResult<Response> {
645    let token = d.token.trim().to_ascii_lowercase();
646    if !(32..=200).contains(&token.len()) || !token.bytes().all(|b| b.is_ascii_hexdigit()) {
647        return Err(ApiError::bad("token is the device token in hex"));
648    }
649    let c = ctx.db.conn().await?;
650    c.execute(
651        &ctx.db.sql(
652            "INSERT INTO chess.push_devices (token, player_id, sandbox) VALUES ($1, $2, $3)
653             ON CONFLICT (token) DO UPDATE SET player_id = excluded.player_id, sandbox = excluded.sandbox",
654        ),
655        &[&token, &who.0.player, &d.sandbox],
656    )
657    .await?;
658    Ok(crate::http::ok())
659}
660
661/// `DELETE /api/push/devices/{token}`.
662pub async fn remove_device(
663    State(ctx): State<Ctx>,
664    who: Who,
665    Path(token): Path<String>,
666) -> ApiResult<Response> {
667    let c = ctx.db.conn().await?;
668    c.execute(
669        &ctx.db.sql("DELETE FROM chess.push_devices WHERE token = $1 AND player_id = $2"),
670        &[&token.to_ascii_lowercase(), &who.0.player],
671    )
672    .await?;
673    Ok(crate::http::ok())
674}
675
676/// `DELETE /api/account`: close the account. Games in progress are
677/// resigned; finished games stay (the other players' history) under a
678/// placeholder name; tokens, sessions, devices and personal details go.
679/// Photos already given under the photo licence stay, without the name.
680pub async fn delete_account(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
681    close_account(&ctx, who.0.player).await?;
682    Ok(StatusCode::NO_CONTENT.into_response())
683}
684
685/// Close an account: its games in progress resigned, its name, sign-in
686/// details, tokens, sessions and devices gone; Apple told of a linked
687/// Apple ID.
688pub async fn close_account(ctx: &Ctx, player: i64) -> ApiResult<()> {
689    let ctx = ctx.clone();
690    let ongoing = {
691        let c = ctx.db.conn().await?;
692        model::ongoing_games(&ctx.db, &*c, player).await?
693    };
694    for g in ongoing {
695        let api = if g.side_of(player).is_some_and(|s| g.player(s).bot) {
696            play::Api::Bot
697        } else {
698            play::Api::Board
699        };
700        if let Err(e) = play::resign(&ctx, &g.id, player, api).await {
701            tracing::warn!(game = g.id, "closing account: {e:?}");
702        }
703    }
704    let mut c = ctx.db.conn().await?;
705    let apple: Option<(String, Option<String>)> = c
706        .query_opt(
707            &ctx.db.sql("SELECT client_id, refresh_token FROM chess.apple_ids WHERE player_id = $1"),
708            &[&player],
709        )
710        .await?
711        .map(|r| (r.get(0), r.get(1)));
712    let tx = c.transaction().await?;
713    for q in [
714        "UPDATE chess.accounts SET username = 'deleted-' || player_id WHERE player_id = $1",
715        "DELETE FROM chess.apple_ids WHERE player_id = $1",
716        "UPDATE chess.players SET name = 'deleted', email = NULL, key_hash = NULL, password_hash = NULL,
717                esports_id = NULL, disabled_at = now() WHERE id = $1",
718        "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'account closed'
719          WHERE player_id = $1 AND revoked_at IS NULL",
720        "UPDATE chess.sessions SET revoked_at = now() WHERE player_id = $1 AND revoked_at IS NULL",
721        "DELETE FROM chess.push_devices WHERE player_id = $1",
722        "DELETE FROM chess.passkeys WHERE player_id = $1",
723        "UPDATE chess.offline_games SET status = 'declined', decided_at = now()
724          WHERE (player_id = $1 OR opponent_id = $1) AND status = 'pending'",
725        "UPDATE chess.challenges SET status = 'canceled' WHERE (challenger_id = $1 OR dest_id = $1) AND status = 'created'",
726        "UPDATE chess.board_photos SET player_id = NULL WHERE player_id = $1",
727    ] {
728        tx.execute(&ctx.db.sql(q), &[&player]).await?;
729    }
730    tx.commit().await?;
731    ctx.auth.forget_player(player);
732    // Apple asks to be told when an account linked to an Apple ID closes.
733    if let Some((client, refresh)) = apple {
734        crate::http::apple::revoke(&ctx, client, refresh).await;
735    }
736    tracing::info!(player, "account closed");
737    Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    fn rec(moves: &[&str], status: &str, winner: Option<&str>) -> OfflineRecord {
745        OfflineRecord {
746            client_id: "g1".into(),
747            mode: "computer".into(),
748            bot_level: Some(4),
749            white: "ann".into(),
750            black: "ai-level-4".into(),
751            speed: "rapid".into(),
752            initial_fen: None,
753            moves: moves.iter().map(|m| (*m).to_owned()).collect(),
754            move_times_ms: (1..=moves.len() as i64).map(|i| 1_000 + i * 1_000).collect(),
755            started_ms: 1_000,
756            status: status.into(),
757            winner: winner.map(str::to_owned),
758            assistance: Assistance::default(),
759            ratable: true,
760        }
761    }
762
763    #[test]
764    fn the_board_decides_when_it_can() {
765        let c = check(&rec(&["f2f3", "e7e5", "g2g4", "d8h4"], "resign", Some("white")), 10_000_000).unwrap();
766        assert_eq!((c.status, c.winner), (Status::Mate, Some(Side::Black)), "mate, whatever the record says");
767        assert!(c.unrated.is_none());
768        let r = check(&rec(&["e2e4", "e7e5"], "resign", Some("black")), 10_000_000).unwrap();
769        assert_eq!((r.status, r.winner), (Status::Resign, Some(Side::Black)));
770    }
771
772    #[test]
773    fn rejects_and_unrates() {
774        assert!(check(&rec(&["e2e4", "e7e5"], "started", None), 10_000_000).is_err(), "not over");
775        assert!(check(&rec(&["e2e5"], "resign", Some("black")), 10_000_000).is_err(), "illegal");
776        let mut helped = rec(&["e2e4", "e7e5"], "draw", None);
777        helped.assistance.hints = 1;
778        assert_eq!(check(&helped, 10_000_000).unwrap().unrated, Some("help was used"));
779        let mut future = rec(&["e2e4", "e7e5"], "draw", None);
780        future.move_times_ms = vec![2_000, 99_000_000];
781        assert_eq!(check(&future, 10_000_000).unwrap().unrated, Some("the move times don't add up"));
782        let short = rec(&["e2e4"], "resign", Some("white"));
783        assert_eq!(check(&short, 10_000_000).unwrap().unrated, Some("fewer than two moves"));
784    }
785
786    #[test]
787    fn reads_the_cores_record() {
788        let json = r#"{"clientId":"g1","mode":"computer","botLevel":4,"botRating":1400,"white":"ann",
789            "black":"ai-level-4","speed":"rapid","moves":["f2f3","e7e5","g2g4","d8h4"],
790            "moveTimesMs":[1000,2000,3000,4000],"startedMs":500,"status":"mate","winner":"black",
791            "assistance":{"hints":0,"takebacks":0,"analysed":false},"ratable":true}"#;
792        let r: OfflineRecord = serde_json::from_str(json).unwrap();
793        assert_eq!(r.bot_level, Some(4));
794        assert!(check(&r, 10_000).unwrap().unrated.is_none());
795    }
796}