1use axum::Json;
14use axum::extract::{Path, Query, State};
15use axum::response::{IntoResponse, Response};
16use serde::Deserialize;
17use serde_json::{Value, json};
18use tokio_postgres::Row;
19
20use crate::app::{Ctx, Who};
21use crate::auth::{PARTNER_PREFIX, PARTNER_SCOPES, hash_key, random_token, staff_roles};
22use crate::error::{ApiError, ApiResult};
23
24const PLAYER_SELECT: &str = "SELECT p.id, a.username, p.email, p.email_verified_at IS NOT NULL, p.role, p.roles,
25 (extract(epoch from a.created_at) * 1000)::bigint, (extract(epoch from a.seen_at) * 1000)::bigint,
26 (extract(epoch from p.disabled_at) * 1000)::bigint, p.esports_id IS NOT NULL, a.kind = 'bot',
27 a.marked_at IS NOT NULL, p.password_hash IS NOT NULL,
28 (SELECT count(*) FROM chess.passkeys k WHERE k.player_id = p.id),
29 (SELECT count(*) FROM chess.api_tokens t WHERE t.player_id = p.id AND t.revoked_at IS NULL AND t.expires_at > now())
30 FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id";
31
32fn player_json(r: &Row) -> Value {
33 let (admin, manager) = staff_roles(&r.get::<_, String>(4), &r.get::<_, Vec<String>>(5));
34 json!({
35 "id": r.get::<_, i64>(0),
36 "username": r.get::<_, String>(1),
37 "email": r.get::<_, Option<String>>(2),
38 "emailConfirmed": r.get::<_, bool>(3),
39 "admin": admin,
40 "manager": manager,
41 "createdAt": r.get::<_, Option<i64>>(6),
42 "seenAt": r.get::<_, Option<i64>>(7),
43 "disabledAt": r.get::<_, Option<i64>>(8),
44 "linked": r.get::<_, bool>(9),
45 "bot": r.get::<_, bool>(10),
46 "marked": r.get::<_, bool>(11),
47 "password": r.get::<_, bool>(12),
48 "passkeys": r.get::<_, i64>(13),
49 "tokens": r.get::<_, i64>(14),
50 })
51}
52
53#[derive(Deserialize)]
54pub struct Search {
55 q: Option<String>,
56 only: Option<String>,
58}
59
60pub async fn players(State(ctx): State<Ctx>, who: Who, Query(s): Query<Search>) -> ApiResult<Response> {
61 who.admin()?;
62 let q = s.q.unwrap_or_default().trim().to_lowercase();
63 let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
64 let staff = s.only.as_deref() == Some("staff");
65 let c = ctx.db.conn().await?;
66 let rows = c
67 .query(
68 &ctx.db.sql(&format!(
69 "{PLAYER_SELECT}
70 WHERE ($1 = '%%' OR lower(a.username) LIKE $1 OR lower(coalesce(p.email, '')) LIKE $1)
71 AND (NOT $2 OR p.role = 'admin' OR 'manager' = ANY(p.roles))
72 ORDER BY a.seen_at DESC NULLS LAST, lower(a.username) LIMIT 100"
73 )),
74 &[&like, &staff],
75 )
76 .await?;
77 Ok(Json(json!({ "players": rows.iter().map(player_json).collect::<Vec<_>>() })).into_response())
78}
79
80async fn find(ctx: &Ctx, c: &impl tokio_postgres::GenericClient, username: &str) -> ApiResult<Row> {
81 c.query_opt(&ctx.db.sql(&format!("{PLAYER_SELECT} WHERE lower(a.username) = lower($1)")), &[&username])
82 .await?
83 .ok_or_else(|| ApiError::not_found("player"))
84}
85
86pub async fn player(State(ctx): State<Ctx>, who: Who, Path(username): Path<String>) -> ApiResult<Response> {
87 who.admin()?;
88 let c = ctx.db.conn().await?;
89 let r = find(&ctx, &*c, &username).await?;
90 let id: i64 = r.get(0);
91 let tokens = c
92 .query(
93 &ctx.db.sql(
94 "SELECT id, name, prefix, scopes, (extract(epoch from expires_at) * 1000)::bigint,
95 (extract(epoch from last_used_at) * 1000)::bigint
96 FROM chess.api_tokens WHERE player_id = $1 AND revoked_at IS NULL AND expires_at > now()
97 ORDER BY created_at DESC",
98 ),
99 &[&id],
100 )
101 .await?;
102 let mut v = player_json(&r);
103 v["tokenList"] = tokens
104 .iter()
105 .map(|t| {
106 json!({
107 "id": t.get::<_, i64>(0),
108 "name": t.get::<_, String>(1),
109 "prefix": t.get::<_, String>(2),
110 "scopes": t.get::<_, Vec<String>>(3),
111 "expiresAt": t.get::<_, i64>(4),
112 "lastUsedAt": t.get::<_, Option<i64>>(5),
113 })
114 })
115 .collect();
116 Ok(Json(v).into_response())
117}
118
119#[derive(Deserialize)]
120pub struct Change {
121 admin: Option<bool>,
122 manager: Option<bool>,
123 disabled: Option<bool>,
124}
125
126pub async fn change(
127 State(ctx): State<Ctx>,
128 who: Who,
129 Path(username): Path<String>,
130 Json(ch): Json<Change>,
131) -> ApiResult<Response> {
132 let me = who.admin()?;
133 let mut c = ctx.db.conn().await?;
134 let tx = c.transaction().await?;
135 let r = find(&ctx, &tx, &username).await?;
136 let id: i64 = r.get(0);
137 if id == me.player && (ch.admin == Some(false) || ch.disabled == Some(true)) {
138 return Err(ApiError::bad("you can't take away your own admin rights or disable yourself"));
139 }
140 if (ch.admin == Some(true) || ch.manager == Some(true)) && r.get::<_, i64>(13) == 0 {
143 return Err(ApiError::bad(format!(
144 "{} needs a passkey first: they add one on their account page",
145 r.get::<_, String>(1)
146 )));
147 }
148 if let Some(a) = ch.admin {
149 tx.execute(
150 &ctx.db.sql(
151 "UPDATE chess.players SET role = CASE WHEN $2 THEN 'admin' ELSE 'player' END WHERE id = $1",
152 ),
153 &[&id, &a],
154 )
155 .await?;
156 }
157 if let Some(m) = ch.manager {
158 tx.execute(
159 &ctx.db.sql(
160 "UPDATE chess.players SET roles = CASE WHEN $2 THEN array_append(array_remove(roles, 'manager'), 'manager')
161 ELSE array_remove(roles, 'manager') END WHERE id = $1",
162 ),
163 &[&id, &m],
164 )
165 .await?;
166 }
167 if let Some(d) = ch.disabled {
168 tx.execute(
169 &ctx.db.sql(
170 "UPDATE chess.players SET disabled_at = CASE WHEN $2 THEN coalesce(disabled_at, now()) END WHERE id = $1",
171 ),
172 &[&id, &d],
173 )
174 .await?;
175 if d {
176 tx.execute(
177 &ctx.db.sql("UPDATE chess.sessions SET revoked_at = now() WHERE player_id = $1 AND revoked_at IS NULL"),
178 &[&id],
179 )
180 .await?;
181 }
182 }
183 tx.commit().await?;
184 ctx.auth.forget_all();
185 tracing::info!(
186 player = id,
187 by = me.player,
188 admin = ?ch.admin,
189 manager = ?ch.manager,
190 disabled = ?ch.disabled,
191 "account changed by an admin"
192 );
193 let c = ctx.db.conn().await?;
194 Ok(Json(player_json(&find(&ctx, &*c, &username).await?)).into_response())
195}
196
197#[derive(Deserialize, Default)]
198pub struct Which {
199 id: Option<i64>,
200}
201
202pub async fn revoke_tokens(
203 State(ctx): State<Ctx>,
204 who: Who,
205 Path(username): Path<String>,
206 body: Option<Json<Which>>,
207) -> ApiResult<Response> {
208 let me = who.admin()?;
209 let w = body.map(|b| b.0).unwrap_or_default();
210 let c = ctx.db.conn().await?;
211 let id: i64 = find(&ctx, &*c, &username).await?.get(0);
212 let n = c
213 .execute(
214 &ctx.db.sql(
215 "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'admin ' || $3
216 WHERE player_id = $1 AND revoked_at IS NULL AND ($2::bigint IS NULL OR id = $2)",
217 ),
218 &[&id, &w.id, &me.player.to_string()],
219 )
220 .await?;
221 ctx.auth.forget_all();
222 Ok(Json(json!({ "revoked": n })).into_response())
223}
224
225pub async fn reset_passkeys(
226 State(ctx): State<Ctx>,
227 who: Who,
228 Path(username): Path<String>,
229) -> ApiResult<Response> {
230 let me = who.admin()?;
231 let c = ctx.db.conn().await?;
232 let r = find(&ctx, &*c, &username).await?;
233 let id: i64 = r.get(0);
234 if id == me.player {
235 return Err(ApiError::bad("remove your own passkeys on your account page"));
236 }
237 let n = c.execute(&ctx.db.sql("DELETE FROM chess.passkeys WHERE player_id = $1"), &[&id]).await?;
238 c.execute(&ctx.db.sql("UPDATE chess.sessions SET staff_verified_at = NULL WHERE player_id = $1"), &[&id])
239 .await?;
240 c.execute(
241 &ctx.db.sql("UPDATE chess.api_tokens SET staff_verified_at = NULL WHERE player_id = $1"),
242 &[&id],
243 )
244 .await?;
245 ctx.auth.forget_all();
246 Ok(Json(json!({ "removed": n, "password": r.get::<_, bool>(12), "linked": r.get::<_, bool>(9) }))
247 .into_response())
248}
249
250pub async fn partners(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
253 who.admin()?;
254 let c = ctx.db.conn().await?;
255 let rows = c
256 .query(
257 &ctx.db.sql(
258 "SELECT id, name, prefix, scopes, (extract(epoch from created_at) * 1000)::bigint,
259 (extract(epoch from last_used_at) * 1000)::bigint
260 FROM chess.partners WHERE revoked_at IS NULL ORDER BY lower(name)",
261 ),
262 &[],
263 )
264 .await?;
265 let list: Vec<Value> = rows
266 .iter()
267 .map(|r| {
268 json!({
269 "id": r.get::<_, i64>(0),
270 "name": r.get::<_, String>(1),
271 "prefix": r.get::<_, String>(2),
272 "scopes": r.get::<_, Vec<String>>(3),
273 "createdAt": r.get::<_, i64>(4),
274 "lastUsedAt": r.get::<_, Option<i64>>(5),
275 })
276 })
277 .collect();
278 let scopes: Vec<Value> = PARTNER_SCOPES.iter().map(|(k, d)| json!({"scope": k, "about": d})).collect();
279 Ok(Json(json!({
280 "partners": list,
281 "scopes": scopes,
282 "sharedSecret": ctx.cfg.esports().is_some(),
283 }))
284 .into_response())
285}
286
287#[derive(Deserialize)]
288pub struct NewPartner {
289 name: String,
290 scopes: Vec<String>,
291}
292
293pub async fn make_partner(
295 db: &crate::db::Db,
296 c: &impl tokio_postgres::GenericClient,
297 name: &str,
298 scopes: &[String],
299 by: Option<i64>,
300) -> anyhow::Result<(i64, String)> {
301 let name: String = name.trim().chars().take(60).collect();
302 anyhow::ensure!(!name.is_empty(), "a partner needs a name");
303 let scopes: Vec<String> =
304 scopes.iter().filter(|s| PARTNER_SCOPES.iter().any(|(k, _)| k == s)).cloned().collect();
305 anyhow::ensure!(
306 !scopes.is_empty(),
307 "choose what it may do: {}",
308 PARTNER_SCOPES.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(", ")
309 );
310 let token = format!("{PARTNER_PREFIX}{}", random_token(40));
311 let prefix: String = token.chars().take(PARTNER_PREFIX.len() + 6).collect();
312 let id: i64 = c
313 .query_one(
314 &db.sql(
315 "INSERT INTO chess.partners (name, token_hash, prefix, scopes, created_by)
316 VALUES ($1, $2, $3, $4, $5) RETURNING id",
317 ),
318 &[&name, &hash_key(&token), &prefix, &scopes, &by],
319 )
320 .await?
321 .get(0);
322 Ok((id, token))
323}
324
325pub async fn new_partner(State(ctx): State<Ctx>, who: Who, Json(p): Json<NewPartner>) -> ApiResult<Response> {
326 let me = who.admin()?;
327 let c = ctx.db.conn().await?;
328 let (id, token) = make_partner(&ctx.db, &*c, &p.name, &p.scopes, Some(me.player))
329 .await
330 .map_err(|e| ApiError::bad(format!("{e:#}")))?;
331 tracing::info!(partner = id, name = p.name, by = me.player, "partner made");
332 Ok(Json(json!({ "id": id, "token": token })).into_response())
333}
334
335pub async fn revoke_partner(State(ctx): State<Ctx>, who: Who, Path(id): Path<i64>) -> ApiResult<Response> {
336 let me = who.admin()?;
337 let c = ctx.db.conn().await?;
338 let n = c
339 .execute(
340 &ctx.db.sql("UPDATE chess.partners SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL"),
341 &[&id],
342 )
343 .await?;
344 tracing::info!(partner = id, by = me.player, "partner revoked");
345 Ok(Json(json!({ "revoked": n })).into_response())
346}