Skip to main content

open_chess_server/http/
admin.rs

1//! Running the server, for its admins (with a fresh passkey check): the
2//! accounts here and their roles, and partners (other services' API access).
3//!
4//! * `GET  /api/admin/players?q=` — accounts, by name or email.
5//! * `GET  /api/admin/players/{username}` — one, with its tokens.
6//! * `POST /api/admin/players/{username}` — `{admin?, manager?, disabled?}`.
7//! * `POST /api/admin/players/{username}/tokens/revoke` — `{id?}` (all without).
8//! * `POST /api/admin/players/{username}/passkeys/reset` — remove its passkeys.
9//! * `GET  /api/admin/partners`, `POST /api/admin/partners` — `{name, scopes}`
10//!   makes one and answers its token (shown once).
11//! * `POST /api/admin/partners/{id}/revoke`.
12
13use 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    /// "staff": admins and managers only.
57    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    // A role goes to someone who signs in with a passkey: their rights then
141    // need it, so a password or session alone never carries them.
142    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
250// ─── partners ───
251
252pub 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
293/// A partner and its token (`ocs_…`), shown once.
294pub 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}