1use std::time::Duration;
13
14use anyhow::{Context, Result};
15use clap::{Parser, Subcommand};
16use tokio::sync::watch;
17use tracing::info;
18
19use crate::app::{self, Ctx};
20use crate::config::{AnalyseConfig, Config};
21use crate::db::Db;
22use crate::{analyse, auth, stopped};
23
24#[derive(Parser)]
26struct ServeOnly {
27 #[command(flatten)]
28 cfg: Config,
29}
30
31#[derive(Subcommand)]
33pub enum Cmd {
34 Serve(Config),
36 Analyse {
38 #[command(flatten)]
39 cfg: Config,
40 #[command(flatten)]
41 analyse: AnalyseConfig,
42 },
43 Migrate(Config),
45 Photos {
47 #[command(subcommand)]
48 cmd: PhotosCmd,
49 },
50 Bot {
52 #[command(subcommand)]
53 cmd: BotCmd,
54 },
55 Admin {
57 #[command(subcommand)]
58 cmd: AdminCmd,
59 },
60 Partner {
62 #[command(subcommand)]
63 cmd: PartnerCmd,
64 },
65 Healthcheck {
67 #[arg(long, env = "CHESS_METRICS_BIND", default_value = "0.0.0.0:9465")]
68 metrics_bind: String,
69 },
70}
71
72#[derive(Subcommand)]
73pub enum BotCmd {
74 Create {
77 #[command(flatten)]
78 cfg: Config,
79 #[arg(long)]
81 name: String,
82 #[arg(long, default_value_t = 365)]
84 days: i64,
85 },
86 List(Config),
88}
89
90#[derive(Subcommand)]
91pub enum AdminCmd {
92 Grant {
94 #[command(flatten)]
95 cfg: Config,
96 username: String,
97 #[arg(long)]
98 manager: bool,
99 },
100 Revoke {
102 #[command(flatten)]
103 cfg: Config,
104 username: String,
105 #[arg(long)]
106 manager: bool,
107 },
108 List(Config),
110}
111
112#[derive(Subcommand)]
113pub enum PartnerCmd {
114 Create {
116 #[command(flatten)]
117 cfg: Config,
118 #[arg(long)]
119 name: String,
120 #[arg(long, value_delimiter = ',', default_value = "read")]
123 scopes: Vec<String>,
124 },
125 List(Config),
127 Revoke {
129 #[command(flatten)]
130 cfg: Config,
131 id: i64,
132 },
133}
134
135#[derive(Subcommand)]
136pub enum PhotosCmd {
137 Export {
142 #[command(flatten)]
143 cfg: Config,
144 #[arg(long)]
146 out: std::path::PathBuf,
147 #[arg(long)]
150 reviewed_only: bool,
151 #[arg(long)]
153 public_only: bool,
154 },
155}
156
157fn tracing_init() {
158 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
159 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
160 tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
161}
162
163fn shutdown_signal() -> watch::Receiver<bool> {
165 let (tx, rx) = watch::channel(false);
166 tokio::spawn(async move {
167 let ctrl_c = tokio::signal::ctrl_c();
168 #[cfg(unix)]
169 {
170 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
171 .expect("SIGTERM handler");
172 tokio::select! {
173 _ = ctrl_c => {}
174 _ = term.recv() => {}
175 }
176 }
177 #[cfg(not(unix))]
178 let _ = ctrl_c.await;
179 info!("shutting down");
180 let _ = tx.send(true);
181 });
182 rx
183}
184
185pub fn configured() -> bool {
188 std::env::var_os("DATABASE_URL").is_some_and(|v| !v.is_empty())
189}
190
191pub fn run(cmd: Option<Cmd>) -> Result<()> {
193 tracing_init();
194 tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(dispatch(cmd))
195}
196
197async fn dispatch(cmd: Option<Cmd>) -> Result<()> {
198 let cmd = match cmd {
199 Some(c) => c,
200 None => {
201 Cmd::Serve(ServeOnly::try_parse_from(["open-chess"]).context("DATABASE_URL must be set")?.cfg)
202 }
203 };
204 match cmd {
205 Cmd::Serve(cfg) => serve(cfg).await,
206 Cmd::Analyse { cfg, analyse: a } => {
207 cfg.validate()?;
208 let db = Db::connect(&cfg)?;
209 db.migrate().await?;
210 let ctx = Ctx::new(db.clone(), cfg.clone());
211 let stop = shutdown_signal();
212 let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind)
213 .await
214 .context("binding CHESS_METRICS_BIND")?;
215 let stop_ops = stop.clone();
216 let metrics = ctx.metrics.clone();
218 tokio::spawn(async move {
219 let _ = axum::serve(ops, app::ops_router(ctx)).with_graceful_shutdown(wait(stop_ops)).await;
220 });
221 analyse::run(db, metrics, a, stop).await
222 }
223 Cmd::Migrate(cfg) => {
224 cfg.validate()?;
225 Db::connect(&cfg)?.migrate().await?;
226 info!("chess schema up to date");
227 Ok(())
228 }
229 Cmd::Bot { cmd: BotCmd::Create { cfg, name, days } } => bot_create(cfg, &name, days).await,
230 Cmd::Bot { cmd: BotCmd::List(cfg) } => bot_list(cfg).await,
231 Cmd::Photos { cmd: PhotosCmd::Export { cfg, out, reviewed_only, public_only } } => {
232 photos_export(cfg, &out, reviewed_only, public_only).await
233 }
234 Cmd::Admin { cmd } => admin(cmd).await,
235 Cmd::Partner { cmd } => partner(cmd).await,
236 Cmd::Healthcheck { metrics_bind } => healthcheck(&metrics_bind),
237 }
238}
239
240async fn wait(mut stop: watch::Receiver<bool>) {
241 stopped(&mut stop).await;
242}
243
244async fn serve(cfg: Config) -> Result<()> {
245 cfg.validate()?;
246 let db = Db::connect(&cfg)?;
247 db.migrate().await?;
248 let ctx = Ctx::new(db.clone(), cfg.clone()).with_reader()?;
249 let stop = shutdown_signal();
250
251 crate::spawn_background(&ctx, &stop);
252
253 let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.context("binding CHESS_METRICS_BIND")?;
254 let ops_ctx = ctx.clone();
255 let stop_ops = stop.clone();
256 tokio::spawn(async move {
257 let _ = axum::serve(ops, app::ops_router(ops_ctx)).with_graceful_shutdown(wait(stop_ops)).await;
258 });
259
260 let listener = tokio::net::TcpListener::bind(&cfg.bind).await.context("binding CHESS_BIND")?;
261 info!(bind = cfg.bind, public = cfg.public_url(), "chess server listening");
262 if cfg.esports_login && cfg.esports().is_none() {
263 tracing::warn!(
264 "ESPORTS_LOGIN is on but ESPORTS_SECRET isn't set (32+ characters): signing in with the site is off"
265 );
266 }
267 axum::serve(listener, app::router(&ctx)?).with_graceful_shutdown(wait(stop)).await?;
268 Ok(())
269}
270
271async fn bot_create(cfg: Config, name: &str, days: i64) -> Result<()> {
272 cfg.validate()?;
273 let db = Db::connect(&cfg)?;
274 db.migrate().await?;
275 let mut c = db.conn().await?;
276 let tx = c.transaction().await?;
277 let player: i64 = tx
278 .query_one(
279 &db.sql("INSERT INTO chess.players (name, key_hash) VALUES ($1, $2) RETURNING id"),
280 &[&name, &auth::hash_key(&auth::random_token(28))],
281 )
282 .await?
283 .get(0);
284 tx.execute(
285 &db.sql("INSERT INTO chess.accounts (player_id, username, kind) VALUES ($1, $2, 'bot')"),
286 &[&player, &name],
287 )
288 .await
289 .context("that username is taken or invalid (letters, digits, _ and -, 2–30 characters)")?;
290 let token = format!("{}{}", auth::TOKEN_PREFIX, auth::random_token(36));
291 let prefix: String = token.chars().take(auth::TOKEN_PREFIX.len() + 6).collect();
292 let scopes: Vec<String> =
293 ["bot:play", "challenge:read", "challenge:write"].iter().map(|s| (*s).to_owned()).collect();
294 let expires = chrono::Utc::now() + chrono::Duration::days(days.clamp(1, 365));
295 tx.execute(
296 &db.sql(
297 "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
298 VALUES ($1, 'chess bot', $2, $3, $4, $5)",
299 ),
300 &[&player, &auth::hash_key(&token), &prefix, &scopes, &expires],
301 )
302 .await?;
303 tx.commit().await?;
304 println!("bot {name} created (player {player}); its token, shown once:\n{token}");
305 Ok(())
306}
307
308async fn bot_list(cfg: Config) -> Result<()> {
309 cfg.validate()?;
310 let db = Db::connect(&cfg)?;
311 let c = db.conn().await?;
312 for r in c
313 .query(
314 &db.sql("SELECT username, player_id, seen_at FROM chess.accounts WHERE kind = 'bot' ORDER BY lower(username)"),
315 &[],
316 )
317 .await?
318 {
319 let seen: Option<chrono::DateTime<chrono::Utc>> = r.get(2);
320 println!(
321 "{:<30} player {:<6} last seen {}",
322 r.get::<_, String>(0),
323 r.get::<_, i64>(1),
324 seen.map_or_else(|| "never".into(), |t| t.to_rfc3339())
325 );
326 }
327 Ok(())
328}
329
330async fn photos_export(
331 cfg: Config,
332 out: &std::path::Path,
333 reviewed_only: bool,
334 public_only: bool,
335) -> Result<()> {
336 cfg.validate()?;
337 let db = Db::connect(&cfg)?;
338 let c = db.conn().await?;
339 std::fs::create_dir_all(out.join("photos")).with_context(|| format!("creating {}", out.display()))?;
340 let rows = c
341 .query(
342 &db.sql(
343 "SELECT id, player_id, created_at, image, mime, label, label_source, public
344 FROM chess.board_photos
345 WHERE label IS NOT NULL AND NOT unreadable AND terms IS NOT NULL
346 AND ($1 = false OR label_source IN ('consensus', 'admin', 'reviewer'))
347 AND ($2 = false OR public)
348 ORDER BY id",
349 ),
350 &[&reviewed_only, &public_only],
351 )
352 .await?;
353 let mut lines = String::new();
354 for r in &rows {
355 let id: i64 = r.get(0);
356 let player: Option<i64> = r.get(1);
357 let at: chrono::DateTime<chrono::Utc> = r.get(2);
358 let ext = match r.get::<_, String>(4).as_str() {
359 "image/png" => "png",
360 "image/webp" => "webp",
361 _ => "jpg",
362 };
363 let name = format!("photos/{id}.{ext}");
364 std::fs::write(out.join(&name), r.get::<_, Vec<u8>>(3))?;
365 let group = format!("player-{}-{}", player.unwrap_or(0), at.format("%Y-%m-%d"));
368 lines.push_str(&serde_json::to_string(&serde_json::json!({
369 "image": name, "placement": r.get::<_, String>(5), "group": group, "source": r.get::<_, Option<String>>(6),
370 "licence": if r.get::<_, bool>(7) { "CC0-1.0" } else { PHOTO_LICENCE },
371 }))?);
372 lines.push('\n');
373 }
374 std::fs::write(out.join("labels.jsonl"), lines)?;
375 info!(photos = rows.len(), out = %out.display(), "exported");
376 Ok(())
377}
378
379const PHOTO_LICENCE: &str = "LicenseRef-open-chess-photo-licence-1";
382
383async fn admin(cmd: AdminCmd) -> Result<()> {
384 let (cfg, grant) = match &cmd {
385 AdminCmd::Grant { cfg, .. } => (cfg, Some(true)),
386 AdminCmd::Revoke { cfg, .. } => (cfg, Some(false)),
387 AdminCmd::List(cfg) => (cfg, None),
388 };
389 cfg.validate()?;
390 let db = Db::connect(cfg)?;
391 db.migrate().await?;
392 let c = db.conn().await?;
393 if let (
394 Some(on),
395 AdminCmd::Grant { username, manager, .. } | AdminCmd::Revoke { username, manager, .. },
396 ) = (grant, &cmd)
397 {
398 let sql = match (manager, on) {
399 (false, true) => "UPDATE chess.players SET role = 'admin'",
400 (false, false) => "UPDATE chess.players SET role = 'player'",
401 (true, true) => {
402 "UPDATE chess.players SET roles = array_append(array_remove(roles, 'manager'), 'manager')"
403 }
404 (true, false) => "UPDATE chess.players SET roles = array_remove(roles, 'manager')",
405 };
406 let keys: Option<i64> = c
407 .query_opt(
408 &db.sql(
409 "SELECT (SELECT count(*) FROM chess.passkeys k WHERE k.player_id = a.player_id)
410 FROM chess.accounts a WHERE lower(a.username) = lower($1)",
411 ),
412 &[username],
413 )
414 .await?
415 .map(|r| r.get(0));
416 let keys = keys.with_context(|| format!("no account here is called {username}"))?;
417 anyhow::ensure!(
418 !on || keys > 0,
419 "{username} needs a passkey first (their rights will need it): they add one on their account page, then grant again"
420 );
421 c.execute(
422 &db.sql(&format!(
423 "{sql} WHERE id = (SELECT player_id FROM chess.accounts WHERE lower(username) = lower($1))"
424 )),
425 &[username],
426 )
427 .await?;
428 let what = if *manager { "a manager" } else { "an admin" };
429 println!(
430 "{username} {} {what}.{}",
431 if on { "is now" } else { "is no longer" },
432 if on { " Their rights need a passkey check each session." } else { "" }
433 );
434 return Ok(());
435 }
436 for r in c
437 .query(
438 &db.sql(
439 "SELECT a.username, p.role, p.roles, p.email, (SELECT count(*) FROM chess.passkeys k WHERE k.player_id = p.id)
440 FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
441 WHERE p.role = 'admin' OR 'manager' = ANY(p.roles) ORDER BY lower(a.username)",
442 ),
443 &[],
444 )
445 .await?
446 {
447 let (admin, manager) = auth::staff_roles(&r.get::<_, String>(1), &r.get::<_, Vec<String>>(2));
448 let roles = [(admin, "admin"), (manager, "manager")].iter().filter(|x| x.0).map(|x| x.1).collect::<Vec<_>>();
449 println!(
450 "{:<24} {:<16} {:<32} {} passkey(s)",
451 r.get::<_, String>(0),
452 roles.join(","),
453 r.get::<_, Option<String>>(3).unwrap_or_default(),
454 r.get::<_, i64>(4)
455 );
456 }
457 Ok(())
458}
459
460async fn partner(cmd: PartnerCmd) -> Result<()> {
461 let cfg = match &cmd {
462 PartnerCmd::Create { cfg, .. } | PartnerCmd::Revoke { cfg, .. } | PartnerCmd::List(cfg) => cfg,
463 };
464 cfg.validate()?;
465 let db = Db::connect(cfg)?;
466 db.migrate().await?;
467 let c = db.conn().await?;
468 match &cmd {
469 PartnerCmd::Create { name, scopes, .. } => {
470 let (id, token) = crate::http::admin::make_partner(&db, &*c, name, scopes, None).await?;
471 println!("partner {name} made (id {id}); its token, shown once:\n{token}");
472 }
473 PartnerCmd::Revoke { id, .. } => {
474 let n = c
475 .execute(
476 &db.sql(
477 "UPDATE chess.partners SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL",
478 ),
479 &[id],
480 )
481 .await?;
482 anyhow::ensure!(n == 1, "no partner {id} (or already revoked)");
483 println!("partner {id} revoked");
484 }
485 PartnerCmd::List(_) => {
486 for r in c
487 .query(
488 &db.sql(
489 "SELECT id, name, prefix, scopes, last_used_at FROM chess.partners
490 WHERE revoked_at IS NULL ORDER BY id",
491 ),
492 &[],
493 )
494 .await?
495 {
496 let used: Option<chrono::DateTime<chrono::Utc>> = r.get(4);
497 println!(
498 "{:<4} {:<24} {}… {:<12} last used {}",
499 r.get::<_, i64>(0),
500 r.get::<_, String>(1),
501 r.get::<_, String>(2),
502 r.get::<_, Vec<String>>(3).join(","),
503 used.map_or_else(|| "never".into(), |t| t.to_rfc3339())
504 );
505 }
506 }
507 }
508 Ok(())
509}
510
511fn healthcheck(bind: &str) -> Result<()> {
512 use std::io::{Read, Write};
513 let port = bind.rsplit(':').next().unwrap_or("9465");
514 let mut s = std::net::TcpStream::connect(format!("127.0.0.1:{port}")).context("connecting")?;
515 s.set_read_timeout(Some(Duration::from_secs(5)))?;
516 s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
517 let mut buf = String::new();
518 s.read_to_string(&mut buf)?;
519 if buf.starts_with("HTTP/1.0 200") || buf.starts_with("HTTP/1.1 200") {
520 Ok(())
521 } else {
522 anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or(""))
523 }
524}