1use 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
35const MAX_GAMES: usize = 50;
37const MAX_PHOTO: usize = 4 * 1024 * 1024;
39
40#[derive(Debug, Clone, Deserialize, serde::Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct OfflineRecord {
45 pub client_id: String,
46 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#[derive(Debug)]
86struct Checked {
87 game: Game,
88 status: Status,
89 winner: Option<Side>,
90 speed: Speed,
91 unrated: Option<&'static str>,
93}
94
95fn 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 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 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
154pub 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#[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
354pub 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 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
421pub 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 image: String,
545 placement: String,
547 #[serde(default)]
548 confidence: Option<f32>,
549 #[serde(default)]
550 model: Option<String>,
551 #[serde(default)]
554 label_source: Option<String>,
555 #[serde(default)]
557 game_id: Option<String>,
558 #[serde(default)]
560 angle: Option<u8>,
561 #[serde(default)]
563 at_ms: Option<i64>,
564 #[serde(default)]
566 distance: Option<u32>,
567}
568
569pub 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
643pub 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
661pub 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
676pub 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
685pub 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 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}