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