// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Admin-dashboard queries: instance statistics, open sign-up invites, and //! config-overriding instance settings. use std::collections::HashMap; use sqlx::Row; use crate::{Store, StoreError, new_id, now_ms}; /// Row counts for the admin overview. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct AdminCounts { /// Total user accounts. pub users: i64, /// Disabled accounts. pub disabled_users: i64, /// Site administrators. pub admins: i64, /// Total repositories. pub repos: i64, /// Total groups. pub groups: i64, /// Total issues (all states, excludes PRs). pub issues: i64, /// Open issues. pub open_issues: i64, /// Total pull requests. pub pulls: i64, /// Registered SSH/GPG keys. pub keys: i64, /// Active (unrevoked) API tokens. pub tokens: i64, /// Live sessions. pub sessions: i64, } /// An open sign-up invite (a token that permits registration). #[derive(Debug, Clone, PartialEq, Eq)] pub struct SignupInvite { /// ULID primary key. pub id: String, /// Optional admin note (who/what it's for). pub note: Option, /// Expiry, if any. pub expires_at: Option, /// When it was redeemed, if it has been. pub used_at: Option, /// The user who redeemed it. pub used_by: Option, /// Creation time. pub created_at: i64, } /// Fields to create a sign-up invite. The caller supplies the SHA-256 `token_hash` /// (the raw token lives only in the emailed/copied URL). #[derive(Debug, Clone)] pub struct NewSignupInvite { /// SHA-256 of the token. pub token_hash: String, /// Optional note. pub note: Option, /// Expiry, if any. pub expires_at: Option, /// The admin who created it. pub created_by: String, } impl Store { /// Count the main entities for the admin overview. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn admin_counts(&self) -> Result { let count = |sql: &'static str| async move { let n: i64 = sqlx::query(sql).fetch_one(&self.pool).await?.try_get("n")?; Ok::(n) }; // Boolean filters are bound (not literals) to stay portable across // SQLite (INTEGER) and Postgres (BOOLEAN). let count_b = |sql: &'static str, b: bool| async move { let n: i64 = sqlx::query(sql) .bind(b) .fetch_one(&self.pool) .await? .try_get("n")?; Ok::(n) }; Ok(AdminCounts { users: count("SELECT COUNT(*) AS n FROM users").await?, disabled_users: count("SELECT COUNT(*) AS n FROM users WHERE disabled_at IS NOT NULL") .await?, admins: count_b("SELECT COUNT(*) AS n FROM users WHERE is_admin = $1", true).await?, repos: count("SELECT COUNT(*) AS n FROM repos").await?, groups: count("SELECT COUNT(*) AS n FROM groups").await?, issues: count_b("SELECT COUNT(*) AS n FROM issues WHERE is_pull = $1", false).await?, open_issues: count_b( "SELECT COUNT(*) AS n FROM issues WHERE state = 'open' AND is_pull = $1", false, ) .await?, pulls: count_b("SELECT COUNT(*) AS n FROM issues WHERE is_pull = $1", true).await?, keys: count("SELECT COUNT(*) AS n FROM keys").await?, tokens: count("SELECT COUNT(*) AS n FROM api_tokens WHERE revoked_at IS NULL").await?, sessions: count("SELECT COUNT(*) AS n FROM sessions").await?, }) } /// The sum of every repository's tracked size in bytes. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn total_repo_bytes(&self) -> Result { let n: i64 = sqlx::query("SELECT COALESCE(SUM(size_bytes), 0) AS n FROM repos") .fetch_one(&self.pool) .await? .try_get("n")?; Ok(n) } // ---- Sign-up invites ---- /// Create a sign-up invite. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn create_signup_invite( &self, new: NewSignupInvite, ) -> Result { let invite = SignupInvite { id: new_id(), note: new.note, expires_at: new.expires_at, used_at: None, used_by: None, created_at: now_ms(), }; sqlx::query( "INSERT INTO signup_invites \ (id, token_hash, note, expires_at, used_at, used_by, created_by, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(&invite.id) .bind(&new.token_hash) .bind(&invite.note) .bind(invite.expires_at) .bind(invite.used_at) .bind(&invite.used_by) .bind(&new.created_by) .bind(invite.created_at) .execute(&self.pool) .await?; Ok(invite) } /// List every sign-up invite, newest first. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_signup_invites(&self) -> Result, StoreError> { let rows = sqlx::query( "SELECT id, note, expires_at, used_at, used_by, created_at \ FROM signup_invites ORDER BY created_at DESC", ) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| { Ok(SignupInvite { id: r.try_get("id")?, note: r.try_get("note")?, expires_at: r.try_get("expires_at")?, used_at: r.try_get("used_at")?, used_by: r.try_get("used_by")?, created_at: r.try_get("created_at")?, }) }) .collect::>() .map_err(Into::into) } /// Whether a token hash names a currently valid (unused, unexpired) invite. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn signup_invite_valid( &self, token_hash: &str, ) -> Result, StoreError> { let row = sqlx::query( "SELECT id FROM signup_invites \ WHERE token_hash = $1 AND used_at IS NULL \ AND (expires_at IS NULL OR expires_at > $2)", ) .bind(token_hash) .bind(now_ms()) .fetch_optional(&self.pool) .await?; Ok(row.map(|r| r.try_get("id")).transpose()?) } /// Mark a sign-up invite redeemed by `user_id`. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn redeem_signup_invite( &self, token_hash: &str, user_id: &str, ) -> Result<(), StoreError> { sqlx::query( "UPDATE signup_invites SET used_at = $1, used_by = $2 \ WHERE token_hash = $3 AND used_at IS NULL", ) .bind(now_ms()) .bind(user_id) .bind(token_hash) .execute(&self.pool) .await?; Ok(()) } /// Delete a sign-up invite. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_signup_invite(&self, id: &str) -> Result { let n = sqlx::query("DELETE FROM signup_invites WHERE id = $1") .bind(id) .execute(&self.pool) .await? .rows_affected(); Ok(n > 0) } // ---- Instance settings (config overrides) ---- /// Load every instance setting as a `key -> value` map. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn all_settings(&self) -> Result, StoreError> { let rows = sqlx::query("SELECT key, value FROM instance_settings") .fetch_all(&self.pool) .await?; let mut out = HashMap::new(); for r in &rows { out.insert( r.try_get::("key")?, r.try_get::("value")?, ); } Ok(out) } /// Set (upsert) an instance setting. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_setting(&self, key: &str, value: &str) -> Result<(), StoreError> { sqlx::query( "INSERT INTO instance_settings (key, value) VALUES ($1, $2) \ ON CONFLICT (key) DO UPDATE SET value = $2", ) .bind(key) .bind(value) .execute(&self.pool) .await?; Ok(()) } /// Remove an instance setting (reverting to the config-file value). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_setting(&self, key: &str) -> Result<(), StoreError> { sqlx::query("DELETE FROM instance_settings WHERE key = $1") .bind(key) .execute(&self.pool) .await?; Ok(()) } }