1use std::collections::HashMap;
5
6use anyhow::Result;
7use chess_core::{Game, Side};
8use chess_proto::ChallengeEvent;
9use tokio_postgres::Transaction;
10
11use crate::app::Ctx;
12use crate::auth::Identity;
13use crate::db::{Note, notify};
14use crate::error::{ApiError, ApiResult};
15use crate::model::{self, Account, ChallengeRow};
16use crate::play;
17
18pub const RULES: &[&str] = &["noAbort", "noRematch", "noGiveTime", "noClaimWin", "noEarlyDraw"];
19
20pub fn valid_limit(secs: u32) -> bool {
23 matches!(secs, 0 | 15 | 30 | 45 | 60 | 90) || (secs.is_multiple_of(60) && secs <= 10_800)
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Request {
29 pub rated: bool,
30 pub clock: Option<(u32, u32)>,
31 pub color: String,
32 pub variant: String,
33 pub fen: Option<String>,
34 pub rules: Vec<String>,
35 pub keep_alive: bool,
36 pub accept_by_token: Option<String>,
37}
38
39fn truthy(v: &str) -> bool {
40 matches!(v, "true" | "1" | "on" | "yes")
41}
42
43impl Request {
44 pub fn parse(form: &HashMap<String, String>) -> ApiResult<Request> {
45 let get = |k: &str| form.get(k).map(|s| s.trim()).filter(|s| !s.is_empty());
46 if get("days").is_some() {
47 return Err(ApiError::bad("Correspondence games aren't supported on this server"));
48 }
49 let clock = match (get("clock.limit"), get("clock.increment")) {
50 (None, None) => None,
51 (Some(l), Some(i)) => {
52 let l: u32 = l.parse().map_err(|_| ApiError::bad("clock.limit is a number of seconds"))?;
53 let i: u32 =
54 i.parse().map_err(|_| ApiError::bad("clock.increment is a number of seconds"))?;
55 if !valid_limit(l) {
56 return Err(ApiError::bad(
57 "clock.limit: 0, 15, 30, 45, 60, 90 or whole minutes up to 10800",
58 ));
59 }
60 if i > 60 {
61 return Err(ApiError::bad("clock.increment is at most 60"));
62 }
63 if l == 0 && i == 0 {
64 return Err(ApiError::bad("a clock needs time or an increment"));
65 }
66 Some((l, i))
67 }
68 _ => return Err(ApiError::bad("send both clock.limit and clock.increment, or neither")),
69 };
70 let color = get("color").unwrap_or("random").to_owned();
71 if !matches!(color.as_str(), "white" | "black" | "random") {
72 return Err(ApiError::bad("color is white, black or random"));
73 }
74 let fen = get("fen").map(str::to_owned);
75 let variant = match get("variant").unwrap_or(if fen.is_some() { "fromPosition" } else { "standard" })
76 {
77 "standard" if fen.is_none() => "standard",
78 "standard" | "fromPosition" => "fromPosition",
79 other => {
80 return Err(ApiError::bad(format!("The {other} variant isn't supported on this server")));
81 }
82 }
83 .to_owned();
84 if variant == "fromPosition" {
85 let f = fen.as_deref().ok_or_else(|| ApiError::bad("fromPosition needs a fen"))?;
86 Game::new(Some(f)).map_err(|e| ApiError::bad(e.to_string()))?;
87 }
88 let rated = get("rated").is_some_and(truthy);
89 if rated && variant == "fromPosition" {
90 return Err(ApiError::bad("Games from a position can't be rated"));
91 }
92 if rated && clock.is_none() {
93 return Err(ApiError::bad("Unlimited games can't be rated"));
94 }
95 let rules: Vec<String> = get("rules")
96 .map(|r| r.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect())
97 .unwrap_or_default();
98 if let Some(bad) = rules.iter().find(|r| !RULES.contains(&r.as_str())) {
99 return Err(ApiError::bad(format!("Unknown rule {bad}")));
100 }
101 Ok(Request {
102 rated,
103 clock,
104 color,
105 variant,
106 fen,
107 rules,
108 keep_alive: get("keepAliveStream").is_some_and(truthy),
109 accept_by_token: get("acceptByToken").map(str::to_owned),
110 })
111 }
112
113 fn final_color(&self) -> Side {
114 match self.color.as_str() {
115 "white" => Side::White,
116 "black" => Side::Black,
117 _ => {
118 if rand::random::<bool>() {
119 Side::White
120 } else {
121 Side::Black
122 }
123 }
124 }
125 }
126}
127
128async fn announce(ctx: &Ctx, tx: &Transaction<'_>, ch: &ChallengeRow, kind: &str, to: &[i64]) -> Result<()> {
129 let ratings = model::challenge_ratings(&ctx.db, tx, ch).await?;
130 for &p in to {
131 let json = ch.json(ctx.cfg.public_url(), Some(p), ratings);
132 let mut ev = serde_json::json!({"type": kind, "challenge": json});
133 if kind == ChallengeEvent::CREATED {
134 ev["compat"] = serde_json::json!({"bot": true, "board": true});
135 }
136 notify(tx, &ctx.db, &Note::User { player: p, event: ev }).await?;
137 }
138 Ok(())
139}
140
141fn can_play(ctx: &Ctx, a: &Account) -> ApiResult<()> {
142 if ctx.cfg.block_marked && a.marked {
143 return Err(ApiError::forbidden("This account is marked for a fair-play violation"));
144 }
145 Ok(())
146}
147
148pub async fn create(
151 ctx: &Ctx,
152 who: &Identity,
153 dest_name: &str,
154 req: &Request,
155) -> ApiResult<(ChallengeRow, (i32, i32))> {
156 create_with(ctx, who, dest_name, req, false).await
157}
158
159pub async fn create_accepted(
161 ctx: &Ctx,
162 who: &Identity,
163 dest_name: &str,
164 req: &Request,
165) -> ApiResult<(ChallengeRow, (i32, i32))> {
166 create_with(ctx, who, dest_name, req, true).await
167}
168
169async fn create_with(
170 ctx: &Ctx,
171 who: &Identity,
172 dest_name: &str,
173 req: &Request,
174 accept_now: bool,
175) -> ApiResult<(ChallengeRow, (i32, i32))> {
176 let mut c = ctx.db.conn().await?;
177 let tx = c.transaction().await?;
178 let me = model::ensure_account(&ctx.db, &tx, who.player).await?;
179 let dest =
180 model::account_by_name(&ctx.db, &tx, dest_name).await?.ok_or_else(|| ApiError::not_found("user"))?;
181 if dest.player_id == me.player_id {
182 return Err(ApiError::bad("You can't challenge yourself"));
183 }
184 can_play(ctx, &me)?;
185 can_play(ctx, &dest)?;
186 let acceptor = accept_now
189 || match &req.accept_by_token {
190 Some(t) => {
191 let id = ctx
192 .auth
193 .token(t)
194 .await?
195 .ok_or_else(|| ApiError::bad("acceptByToken isn't a valid token"))?;
196 if id.player != dest.player_id {
197 return Err(ApiError::bad("acceptByToken belongs to someone else"));
198 }
199 if !id.has_any(&["challenge:write", "board:play", "bot:play"]) {
200 return Err(ApiError::bad("acceptByToken lacks the challenge:write scope"));
201 }
202 true
203 }
204 None => false,
205 };
206 let id = model::new_id();
207 let ttl_secs = if req.clock.is_some() { ctx.cfg.challenge_ttl().as_secs_f64() } else { 7.0 * 86_400.0 };
208 tx.execute(
209 &ctx.db.sql(
210 "INSERT INTO chess.challenges (id, challenger_id, dest_id, variant, initial_fen, rated, clock_limit,
211 clock_increment, color, final_color, rules, expires_at)
212 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now() + make_interval(secs => $12))",
213 ),
214 &[
215 &id,
216 &me.player_id,
217 &dest.player_id,
218 &req.variant,
219 &req.fen,
220 &req.rated,
221 &req.clock.map(|c| c.0 as i32),
222 &req.clock.map(|c| c.1 as i32),
223 &req.color,
224 &req.final_color().name(),
225 &req.rules,
226 &ttl_secs,
227 ],
228 )
229 .await?;
230 ctx.metrics.challenges.with_label_values(&["created"]).inc();
231 let mut ch =
232 model::challenge(&ctx.db, &tx, &id, true).await?.ok_or_else(|| ApiError::not_found("challenge"))?;
233 if acceptor {
234 accept_locked(ctx, &tx, &mut ch).await?;
235 } else {
236 announce(ctx, &tx, &ch, ChallengeEvent::CREATED, &[dest.player_id, me.player_id]).await?;
237 }
238 let ratings = model::challenge_ratings(&ctx.db, &tx, &ch).await?;
239 tx.commit().await?;
240 Ok((ch, ratings))
241}
242
243async fn accept_locked(ctx: &Ctx, tx: &Transaction<'_>, ch: &mut ChallengeRow) -> ApiResult<()> {
244 tx.execute(&ctx.db.sql("UPDATE chess.challenges SET status = 'accepted' WHERE id = $1"), &[&ch.id])
245 .await?;
246 "accepted".clone_into(&mut ch.status);
247 play::start_game(ctx, tx, ch).await?;
248 ctx.metrics.challenges.with_label_values(&["accepted"]).inc();
249 Ok(())
250}
251
252async fn lock_open(ctx: &Ctx, tx: &Transaction<'_>, id: &str) -> ApiResult<ChallengeRow> {
253 let ch =
254 model::challenge(&ctx.db, tx, id, true).await?.ok_or_else(|| ApiError::not_found("challenge"))?;
255 let lapsed: bool = tx
256 .query_one(&ctx.db.sql("SELECT expires_at < now() FROM chess.challenges WHERE id = $1"), &[&id])
257 .await?
258 .get(0);
259 if ch.status != "created" || lapsed {
260 return Err(ApiError::not_found("challenge"));
261 }
262 Ok(ch)
263}
264
265pub async fn accept(ctx: &Ctx, who: &Identity, id: &str) -> ApiResult<()> {
266 let mut c = ctx.db.conn().await?;
267 let tx = c.transaction().await?;
268 let me = model::ensure_account(&ctx.db, &tx, who.player).await?;
269 let mut ch = lock_open(ctx, &tx, id).await?;
270 if ch.dest.player_id != me.player_id {
271 return Err(ApiError::not_found("challenge"));
272 }
273 can_play(ctx, &me)?;
274 can_play(ctx, &ch.challenger)?;
275 accept_locked(ctx, &tx, &mut ch).await?;
276 tx.commit().await?;
277 Ok(())
278}
279
280pub async fn decline(ctx: &Ctx, who: &Identity, id: &str, reason: Option<&str>) -> ApiResult<()> {
281 let mut c = ctx.db.conn().await?;
282 let tx = c.transaction().await?;
283 let mut ch = lock_open(ctx, &tx, id).await?;
284 if ch.dest.player_id != who.player {
285 return Err(ApiError::not_found("challenge"));
286 }
287 let key = reason
288 .map(str::to_lowercase)
289 .filter(|r| chess_proto::DECLINE_REASONS.iter().any(|(k, _)| k == r))
290 .unwrap_or_else(|| "generic".into());
291 tx.execute(
292 &ctx.db.sql("UPDATE chess.challenges SET status = 'declined', decline_reason = $2 WHERE id = $1"),
293 &[&ch.id, &key],
294 )
295 .await?;
296 "declined".clone_into(&mut ch.status);
297 ch.decline_reason = Some(key);
298 announce(ctx, &tx, &ch, ChallengeEvent::DECLINED, &[ch.challenger.player_id]).await?;
299 ctx.metrics.challenges.with_label_values(&["declined"]).inc();
300 tx.commit().await?;
301 Ok(())
302}
303
304pub async fn cancel(ctx: &Ctx, who: &Identity, id: &str) -> ApiResult<()> {
305 let mut c = ctx.db.conn().await?;
306 let tx = c.transaction().await?;
307 let mut ch = lock_open(ctx, &tx, id).await?;
308 if ch.challenger.player_id != who.player {
309 return Err(ApiError::not_found("challenge"));
310 }
311 tx.execute(&ctx.db.sql("UPDATE chess.challenges SET status = 'canceled' WHERE id = $1"), &[&ch.id])
312 .await?;
313 "canceled".clone_into(&mut ch.status);
314 announce(ctx, &tx, &ch, ChallengeEvent::CANCELED, &[ch.dest.player_id, ch.challenger.player_id]).await?;
315 ctx.metrics.challenges.with_label_values(&["canceled"]).inc();
316 tx.commit().await?;
317 Ok(())
318}
319
320pub async fn keep_alive(ctx: &Ctx, id: &str) -> Result<()> {
322 let c = ctx.db.conn().await?;
323 c.execute(
324 &ctx.db.sql(
325 "UPDATE chess.challenges SET expires_at = now() + make_interval(secs => $2)
326 WHERE id = $1 AND status = 'created'",
327 ),
328 &[&id, &ctx.cfg.challenge_ttl().as_secs_f64()],
329 )
330 .await?;
331 Ok(())
332}
333
334pub async fn status(ctx: &Ctx, id: &str) -> Result<Option<String>> {
336 let c = ctx.db.conn().await?;
337 Ok(c.query_opt(&ctx.db.sql("SELECT status FROM chess.challenges WHERE id = $1"), &[&id])
338 .await?
339 .map(|r| r.get(0)))
340}
341
342pub async fn list(
344 ctx: &Ctx,
345 player: i64,
346) -> Result<(Vec<chess_proto::Challenge>, Vec<chess_proto::Challenge>)> {
347 let c = ctx.db.conn().await?;
348 let rows = c
349 .query(
350 &ctx.db.sql(&format!(
351 "{} WHERE c.status = 'created' AND c.expires_at > now() AND (c.dest_id = $1 OR c.challenger_id = $1)
352 ORDER BY c.created_at",
353 model::CHALLENGE_SELECT
354 )),
355 &[&player],
356 )
357 .await?;
358 let (mut incoming, mut outgoing) = (Vec::new(), Vec::new());
359 for r in rows {
360 let ch = ChallengeRow::from_row(&r);
361 let ratings = model::challenge_ratings(&ctx.db, &*c, &ch).await?;
362 let j = ch.json(ctx.cfg.public_url(), Some(player), ratings);
363 if ch.dest.player_id == player { incoming.push(j) } else { outgoing.push(j) }
364 }
365 Ok((incoming, outgoing))
366}
367
368pub async fn expire(ctx: &Ctx) -> Result<usize> {
370 let mut c = ctx.db.conn().await?;
371 let tx = c.transaction().await?;
372 let ids: Vec<String> = tx
373 .query(
374 &ctx.db.sql(
375 "UPDATE chess.challenges SET status = 'canceled'
376 WHERE id IN (SELECT id FROM chess.challenges WHERE status = 'created' AND expires_at < now()
377 LIMIT 100 FOR UPDATE SKIP LOCKED)
378 RETURNING id",
379 ),
380 &[],
381 )
382 .await?
383 .iter()
384 .map(|r| r.get(0))
385 .collect();
386 for id in &ids {
387 if let Some(ch) = model::challenge(&ctx.db, &tx, id, false).await? {
388 announce(ctx, &tx, &ch, ChallengeEvent::CANCELED, &[ch.dest.player_id, ch.challenger.player_id])
389 .await?;
390 ctx.metrics.challenges.with_label_values(&["lapsed"]).inc();
391 }
392 }
393 tx.commit().await?;
394 Ok(ids.len())
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 fn form(pairs: &[(&str, &str)]) -> HashMap<String, String> {
402 pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
403 }
404
405 #[test]
406 fn limits() {
407 for ok in [0, 15, 30, 45, 60, 90, 120, 180, 600, 10_800] {
408 assert!(valid_limit(ok), "{ok}");
409 }
410 for bad in [10, 75, 100, 10_860] {
411 assert!(!valid_limit(bad), "{bad}");
412 }
413 }
414
415 #[test]
416 fn parses_the_sites_challenge() {
417 let r = Request::parse(&form(&[
419 ("rated", "false"),
420 ("clock.limit", "180"),
421 ("clock.increment", "2"),
422 ("color", "random"),
423 ("variant", "standard"),
424 ("keepAliveStream", "false"),
425 ("acceptByToken", "esl_tok"),
426 ]))
427 .unwrap();
428 assert_eq!(r.clock, Some((180, 2)));
429 assert!(!r.rated && !r.keep_alive);
430 assert_eq!(r.accept_by_token.as_deref(), Some("esl_tok"));
431 assert_eq!(r.variant, "standard");
432 }
433
434 #[test]
435 fn rejects_what_we_dont_play() {
436 assert!(Request::parse(&form(&[("days", "3")])).is_err());
437 assert!(Request::parse(&form(&[("variant", "chess960")])).is_err());
438 assert!(Request::parse(&form(&[("clock.limit", "180")])).is_err());
439 assert!(Request::parse(&form(&[("clock.limit", "100"), ("clock.increment", "0")])).is_err());
440 assert!(Request::parse(&form(&[("clock.limit", "0"), ("clock.increment", "0")])).is_err());
441 assert!(Request::parse(&form(&[("rated", "true")])).is_err(), "unlimited can't be rated");
442 assert!(Request::parse(&form(&[("rules", "noAbort,bogus")])).is_err());
443 assert!(Request::parse(&form(&[("fen", "nonsense")])).is_err());
444 }
445
446 #[test]
447 fn from_position() {
448 let r = Request::parse(&form(&[("fen", "4k3/8/8/8/8/8/8/R3K3 w - - 0 1")])).unwrap();
449 assert_eq!(r.variant, "fromPosition");
450 assert!(r.clock.is_none());
451 let r = Request::parse(&form(&[("rules", "noAbort, noClaimWin")])).unwrap();
452 assert_eq!(r.rules, ["noAbort", "noClaimWin"]);
453 }
454}