1use std::time::Duration;
11
12use anyhow::{Context, Result};
13use clap::{Parser, Subcommand};
14use tokio::sync::watch;
15use tracing::info;
16
17use crate::app::{self, Ctx};
18use crate::config::{AnalyseConfig, Config};
19use crate::db::Db;
20use crate::{analyse, auth, stopped};
21
22#[derive(Parser)]
24struct ServeOnly {
25 #[command(flatten)]
26 cfg: Config,
27}
28
29#[derive(Subcommand)]
31pub enum Cmd {
32 Serve(Config),
34 Analyse {
36 #[command(flatten)]
37 cfg: Config,
38 #[command(flatten)]
39 analyse: AnalyseConfig,
40 },
41 Migrate(Config),
43 Photos {
45 #[command(subcommand)]
46 cmd: PhotosCmd,
47 },
48 Bot {
50 #[command(subcommand)]
51 cmd: BotCmd,
52 },
53 Healthcheck {
55 #[arg(long, env = "CHESS_METRICS_BIND", default_value = "0.0.0.0:9465")]
56 metrics_bind: String,
57 },
58}
59
60#[derive(Subcommand)]
61pub enum BotCmd {
62 Create {
65 #[command(flatten)]
66 cfg: Config,
67 #[arg(long)]
69 name: String,
70 #[arg(long, default_value_t = 365)]
72 days: i64,
73 },
74 List(Config),
76}
77
78#[derive(Subcommand)]
79pub enum PhotosCmd {
80 Export {
84 #[command(flatten)]
85 cfg: Config,
86 #[arg(long)]
88 out: std::path::PathBuf,
89 #[arg(long)]
92 reviewed_only: bool,
93 },
94}
95
96fn tracing_init() {
97 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
98 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
99 tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
100}
101
102fn shutdown_signal() -> watch::Receiver<bool> {
104 let (tx, rx) = watch::channel(false);
105 tokio::spawn(async move {
106 let ctrl_c = tokio::signal::ctrl_c();
107 #[cfg(unix)]
108 {
109 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
110 .expect("SIGTERM handler");
111 tokio::select! {
112 _ = ctrl_c => {}
113 _ = term.recv() => {}
114 }
115 }
116 #[cfg(not(unix))]
117 let _ = ctrl_c.await;
118 info!("shutting down");
119 let _ = tx.send(true);
120 });
121 rx
122}
123
124pub fn configured() -> bool {
127 std::env::var_os("DATABASE_URL").is_some_and(|v| !v.is_empty())
128}
129
130pub fn run(cmd: Option<Cmd>) -> Result<()> {
132 tracing_init();
133 tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(dispatch(cmd))
134}
135
136async fn dispatch(cmd: Option<Cmd>) -> Result<()> {
137 let cmd = match cmd {
138 Some(c) => c,
139 None => {
140 Cmd::Serve(ServeOnly::try_parse_from(["open-chess"]).context("DATABASE_URL must be set")?.cfg)
141 }
142 };
143 match cmd {
144 Cmd::Serve(cfg) => serve(cfg).await,
145 Cmd::Analyse { cfg, analyse: a } => {
146 cfg.validate()?;
147 let db = Db::connect(&cfg)?;
148 db.migrate().await?;
149 let ctx = Ctx::new(db.clone(), cfg.clone());
150 let stop = shutdown_signal();
151 let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind)
152 .await
153 .context("binding CHESS_METRICS_BIND")?;
154 let stop_ops = stop.clone();
155 let metrics = ctx.metrics.clone();
157 tokio::spawn(async move {
158 let _ = axum::serve(ops, app::ops_router(ctx)).with_graceful_shutdown(wait(stop_ops)).await;
159 });
160 analyse::run(db, metrics, a, stop).await
161 }
162 Cmd::Migrate(cfg) => {
163 cfg.validate()?;
164 Db::connect(&cfg)?.migrate().await?;
165 info!("chess schema up to date");
166 Ok(())
167 }
168 Cmd::Bot { cmd: BotCmd::Create { cfg, name, days } } => bot_create(cfg, &name, days).await,
169 Cmd::Bot { cmd: BotCmd::List(cfg) } => bot_list(cfg).await,
170 Cmd::Photos { cmd: PhotosCmd::Export { cfg, out, reviewed_only } } => {
171 photos_export(cfg, &out, reviewed_only).await
172 }
173 Cmd::Healthcheck { metrics_bind } => healthcheck(&metrics_bind),
174 }
175}
176
177async fn wait(mut stop: watch::Receiver<bool>) {
178 stopped(&mut stop).await;
179}
180
181async fn serve(cfg: Config) -> Result<()> {
182 cfg.validate()?;
183 let db = Db::connect(&cfg)?;
184 db.migrate().await?;
185 let ctx = Ctx::new(db.clone(), cfg.clone()).with_reader()?;
186 let stop = shutdown_signal();
187
188 crate::spawn_background(&ctx, &stop);
189
190 let ops = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.context("binding CHESS_METRICS_BIND")?;
191 let ops_ctx = ctx.clone();
192 let stop_ops = stop.clone();
193 tokio::spawn(async move {
194 let _ = axum::serve(ops, app::ops_router(ops_ctx)).with_graceful_shutdown(wait(stop_ops)).await;
195 });
196
197 let listener = tokio::net::TcpListener::bind(&cfg.bind).await.context("binding CHESS_BIND")?;
198 info!(bind = cfg.bind, public = cfg.public_url(), "chess server listening");
199 if cfg.esports_login && cfg.esports().is_none() {
200 tracing::warn!(
201 "ESPORTS_LOGIN is on but ESPORTS_SECRET isn't set (32+ characters): signing in with the site is off"
202 );
203 }
204 axum::serve(listener, app::router(&ctx)?).with_graceful_shutdown(wait(stop)).await?;
205 Ok(())
206}
207
208async fn bot_create(cfg: Config, name: &str, days: i64) -> Result<()> {
209 cfg.validate()?;
210 let db = Db::connect(&cfg)?;
211 db.migrate().await?;
212 let mut c = db.conn().await?;
213 let tx = c.transaction().await?;
214 let player: i64 = tx
215 .query_one(
216 &db.sql("INSERT INTO chess.players (name, key_hash) VALUES ($1, $2) RETURNING id"),
217 &[&name, &auth::hash_key(&auth::random_token(28))],
218 )
219 .await?
220 .get(0);
221 tx.execute(
222 &db.sql("INSERT INTO chess.accounts (player_id, username, kind) VALUES ($1, $2, 'bot')"),
223 &[&player, &name],
224 )
225 .await
226 .context("that username is taken or invalid (letters, digits, _ and -, 2–30 characters)")?;
227 let token = format!("{}{}", auth::TOKEN_PREFIX, auth::random_token(36));
228 let prefix: String = token.chars().take(auth::TOKEN_PREFIX.len() + 6).collect();
229 let scopes: Vec<String> =
230 ["bot:play", "challenge:read", "challenge:write"].iter().map(|s| (*s).to_owned()).collect();
231 let expires = chrono::Utc::now() + chrono::Duration::days(days.clamp(1, 365));
232 tx.execute(
233 &db.sql(
234 "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
235 VALUES ($1, 'chess bot', $2, $3, $4, $5)",
236 ),
237 &[&player, &auth::hash_key(&token), &prefix, &scopes, &expires],
238 )
239 .await?;
240 tx.commit().await?;
241 println!("bot {name} created (player {player}); its token, shown once:\n{token}");
242 Ok(())
243}
244
245async fn bot_list(cfg: Config) -> Result<()> {
246 cfg.validate()?;
247 let db = Db::connect(&cfg)?;
248 let c = db.conn().await?;
249 for r in c
250 .query(
251 &db.sql("SELECT username, player_id, seen_at FROM chess.accounts WHERE kind = 'bot' ORDER BY lower(username)"),
252 &[],
253 )
254 .await?
255 {
256 let seen: Option<chrono::DateTime<chrono::Utc>> = r.get(2);
257 println!(
258 "{:<30} player {:<6} last seen {}",
259 r.get::<_, String>(0),
260 r.get::<_, i64>(1),
261 seen.map_or_else(|| "never".into(), |t| t.to_rfc3339())
262 );
263 }
264 Ok(())
265}
266
267async fn photos_export(cfg: Config, out: &std::path::Path, reviewed_only: bool) -> Result<()> {
268 cfg.validate()?;
269 let db = Db::connect(&cfg)?;
270 let c = db.conn().await?;
271 std::fs::create_dir_all(out.join("photos")).with_context(|| format!("creating {}", out.display()))?;
272 let rows = c
273 .query(
274 &db.sql(
275 "SELECT id, player_id, created_at, image, mime, label, label_source
276 FROM chess.board_photos
277 WHERE label IS NOT NULL AND NOT unreadable AND ($1 = false OR label_source = 'reviewer')
278 ORDER BY id",
279 ),
280 &[&reviewed_only],
281 )
282 .await?;
283 let mut lines = String::new();
284 for r in &rows {
285 let id: i64 = r.get(0);
286 let player: Option<i64> = r.get(1);
287 let at: chrono::DateTime<chrono::Utc> = r.get(2);
288 let ext = match r.get::<_, String>(4).as_str() {
289 "image/png" => "png",
290 "image/webp" => "webp",
291 _ => "jpg",
292 };
293 let name = format!("photos/{id}.{ext}");
294 std::fs::write(out.join(&name), r.get::<_, Vec<u8>>(3))?;
295 let group = format!("player-{}-{}", player.unwrap_or(0), at.format("%Y-%m-%d"));
298 lines.push_str(&serde_json::to_string(&serde_json::json!({
299 "image": name, "placement": r.get::<_, String>(5), "group": group, "source": r.get::<_, Option<String>>(6),
300 }))?);
301 lines.push('\n');
302 }
303 std::fs::write(out.join("labels.jsonl"), lines)?;
304 info!(photos = rows.len(), out = %out.display(), "exported");
305 Ok(())
306}
307
308fn healthcheck(bind: &str) -> Result<()> {
309 use std::io::{Read, Write};
310 let port = bind.rsplit(':').next().unwrap_or("9465");
311 let mut s = std::net::TcpStream::connect(format!("127.0.0.1:{port}")).context("connecting")?;
312 s.set_read_timeout(Some(Duration::from_secs(5)))?;
313 s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
314 let mut buf = String::new();
315 s.read_to_string(&mut buf)?;
316 if buf.starts_with("HTTP/1.0 200") || buf.starts_with("HTTP/1.1 200") {
317 Ok(())
318 } else {
319 anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or(""))
320 }
321}