fabrica

hanna/fabrica

9614 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Admin-dashboard queries: instance statistics, open sign-up invites, and
6//! config-overriding instance settings.
7
8use std::collections::HashMap;
9
10use sqlx::Row;
11
12use crate::{Store, StoreError, new_id, now_ms};
13
14/// Row counts for the admin overview.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
16pub struct AdminCounts {
17 /// Total user accounts.
18 pub users: i64,
19 /// Disabled accounts.
20 pub disabled_users: i64,
21 /// Site administrators.
22 pub admins: i64,
23 /// Total repositories.
24 pub repos: i64,
25 /// Total groups.
26 pub groups: i64,
27 /// Total issues (all states, excludes PRs).
28 pub issues: i64,
29 /// Open issues.
30 pub open_issues: i64,
31 /// Total pull requests.
32 pub pulls: i64,
33 /// Registered SSH/GPG keys.
34 pub keys: i64,
35 /// Active (unrevoked) API tokens.
36 pub tokens: i64,
37 /// Live sessions.
38 pub sessions: i64,
39}
40
41/// An open sign-up invite (a token that permits registration).
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SignupInvite {
44 /// ULID primary key.
45 pub id: String,
46 /// Optional admin note (who/what it's for).
47 pub note: Option<String>,
48 /// Expiry, if any.
49 pub expires_at: Option<i64>,
50 /// When it was redeemed, if it has been.
51 pub used_at: Option<i64>,
52 /// The user who redeemed it.
53 pub used_by: Option<String>,
54 /// Creation time.
55 pub created_at: i64,
56}
57
58/// Fields to create a sign-up invite. The caller supplies the SHA-256 `token_hash`
59/// (the raw token lives only in the emailed/copied URL).
60#[derive(Debug, Clone)]
61pub struct NewSignupInvite {
62 /// SHA-256 of the token.
63 pub token_hash: String,
64 /// Optional note.
65 pub note: Option<String>,
66 /// Expiry, if any.
67 pub expires_at: Option<i64>,
68 /// The admin who created it.
69 pub created_by: String,
70}
71
72impl Store {
73 /// Count the main entities for the admin overview.
74 ///
75 /// # Errors
76 ///
77 /// Returns [`StoreError::Query`] on a database failure.
78 pub async fn admin_counts(&self) -> Result<AdminCounts, StoreError> {
79 let count = |sql: &'static str| async move {
80 let n: i64 = sqlx::query(sql).fetch_one(&self.pool).await?.try_get("n")?;
81 Ok::<i64, StoreError>(n)
82 };
83 // Boolean filters are bound (not literals) to stay portable across
84 // SQLite (INTEGER) and Postgres (BOOLEAN).
85 let count_b = |sql: &'static str, b: bool| async move {
86 let n: i64 = sqlx::query(sql)
87 .bind(b)
88 .fetch_one(&self.pool)
89 .await?
90 .try_get("n")?;
91 Ok::<i64, StoreError>(n)
92 };
93 Ok(AdminCounts {
94 users: count("SELECT COUNT(*) AS n FROM users").await?,
95 disabled_users: count("SELECT COUNT(*) AS n FROM users WHERE disabled_at IS NOT NULL")
96 .await?,
97 admins: count_b("SELECT COUNT(*) AS n FROM users WHERE is_admin = $1", true).await?,
98 repos: count("SELECT COUNT(*) AS n FROM repos").await?,
99 groups: count("SELECT COUNT(*) AS n FROM groups").await?,
100 issues: count_b("SELECT COUNT(*) AS n FROM issues WHERE is_pull = $1", false).await?,
101 open_issues: count_b(
102 "SELECT COUNT(*) AS n FROM issues WHERE state = 'open' AND is_pull = $1",
103 false,
104 )
105 .await?,
106 pulls: count_b("SELECT COUNT(*) AS n FROM issues WHERE is_pull = $1", true).await?,
107 keys: count("SELECT COUNT(*) AS n FROM keys").await?,
108 tokens: count("SELECT COUNT(*) AS n FROM api_tokens WHERE revoked_at IS NULL").await?,
109 sessions: count("SELECT COUNT(*) AS n FROM sessions").await?,
110 })
111 }
112
113 /// The sum of every repository's tracked size in bytes.
114 ///
115 /// # Errors
116 ///
117 /// Returns [`StoreError::Query`] on a database failure.
118 pub async fn total_repo_bytes(&self) -> Result<i64, StoreError> {
119 let n: i64 = sqlx::query("SELECT COALESCE(SUM(size_bytes), 0) AS n FROM repos")
120 .fetch_one(&self.pool)
121 .await?
122 .try_get("n")?;
123 Ok(n)
124 }
125
126 // ---- Sign-up invites ----
127
128 /// Create a sign-up invite.
129 ///
130 /// # Errors
131 ///
132 /// Returns [`StoreError::Query`] on a database failure.
133 pub async fn create_signup_invite(
134 &self,
135 new: NewSignupInvite,
136 ) -> Result<SignupInvite, StoreError> {
137 let invite = SignupInvite {
138 id: new_id(),
139 note: new.note,
140 expires_at: new.expires_at,
141 used_at: None,
142 used_by: None,
143 created_at: now_ms(),
144 };
145 sqlx::query(
146 "INSERT INTO signup_invites \
147 (id, token_hash, note, expires_at, used_at, used_by, created_by, created_at) \
148 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
149 )
150 .bind(&invite.id)
151 .bind(&new.token_hash)
152 .bind(&invite.note)
153 .bind(invite.expires_at)
154 .bind(invite.used_at)
155 .bind(&invite.used_by)
156 .bind(&new.created_by)
157 .bind(invite.created_at)
158 .execute(&self.pool)
159 .await?;
160 Ok(invite)
161 }
162
163 /// List every sign-up invite, newest first.
164 ///
165 /// # Errors
166 ///
167 /// Returns [`StoreError::Query`] on a database failure.
168 pub async fn list_signup_invites(&self) -> Result<Vec<SignupInvite>, StoreError> {
169 let rows = sqlx::query(
170 "SELECT id, note, expires_at, used_at, used_by, created_at \
171 FROM signup_invites ORDER BY created_at DESC",
172 )
173 .fetch_all(&self.pool)
174 .await?;
175 rows.iter()
176 .map(|r| {
177 Ok(SignupInvite {
178 id: r.try_get("id")?,
179 note: r.try_get("note")?,
180 expires_at: r.try_get("expires_at")?,
181 used_at: r.try_get("used_at")?,
182 used_by: r.try_get("used_by")?,
183 created_at: r.try_get("created_at")?,
184 })
185 })
186 .collect::<Result<_, sqlx::Error>>()
187 .map_err(Into::into)
188 }
189
190 /// Whether a token hash names a currently valid (unused, unexpired) invite.
191 ///
192 /// # Errors
193 ///
194 /// Returns [`StoreError::Query`] on a database failure.
195 pub async fn signup_invite_valid(
196 &self,
197 token_hash: &str,
198 ) -> Result<Option<String>, StoreError> {
199 let row = sqlx::query(
200 "SELECT id FROM signup_invites \
201 WHERE token_hash = $1 AND used_at IS NULL \
202 AND (expires_at IS NULL OR expires_at > $2)",
203 )
204 .bind(token_hash)
205 .bind(now_ms())
206 .fetch_optional(&self.pool)
207 .await?;
208 Ok(row.map(|r| r.try_get("id")).transpose()?)
209 }
210
211 /// Mark a sign-up invite redeemed by `user_id`.
212 ///
213 /// # Errors
214 ///
215 /// Returns [`StoreError::Query`] on a database failure.
216 pub async fn redeem_signup_invite(
217 &self,
218 token_hash: &str,
219 user_id: &str,
220 ) -> Result<(), StoreError> {
221 sqlx::query(
222 "UPDATE signup_invites SET used_at = $1, used_by = $2 \
223 WHERE token_hash = $3 AND used_at IS NULL",
224 )
225 .bind(now_ms())
226 .bind(user_id)
227 .bind(token_hash)
228 .execute(&self.pool)
229 .await?;
230 Ok(())
231 }
232
233 /// Delete a sign-up invite.
234 ///
235 /// # Errors
236 ///
237 /// Returns [`StoreError::Query`] on a database failure.
238 pub async fn delete_signup_invite(&self, id: &str) -> Result<bool, StoreError> {
239 let n = sqlx::query("DELETE FROM signup_invites WHERE id = $1")
240 .bind(id)
241 .execute(&self.pool)
242 .await?
243 .rows_affected();
244 Ok(n > 0)
245 }
246
247 // ---- Instance settings (config overrides) ----
248
249 /// Load every instance setting as a `key -> value` map.
250 ///
251 /// # Errors
252 ///
253 /// Returns [`StoreError::Query`] on a database failure.
254 pub async fn all_settings(&self) -> Result<HashMap<String, String>, StoreError> {
255 let rows = sqlx::query("SELECT key, value FROM instance_settings")
256 .fetch_all(&self.pool)
257 .await?;
258 let mut out = HashMap::new();
259 for r in &rows {
260 out.insert(
261 r.try_get::<String, _>("key")?,
262 r.try_get::<String, _>("value")?,
263 );
264 }
265 Ok(out)
266 }
267
268 /// Set (upsert) an instance setting.
269 ///
270 /// # Errors
271 ///
272 /// Returns [`StoreError::Query`] on a database failure.
273 pub async fn set_setting(&self, key: &str, value: &str) -> Result<(), StoreError> {
274 sqlx::query(
275 "INSERT INTO instance_settings (key, value) VALUES ($1, $2) \
276 ON CONFLICT (key) DO UPDATE SET value = $2",
277 )
278 .bind(key)
279 .bind(value)
280 .execute(&self.pool)
281 .await?;
282 Ok(())
283 }
284
285 /// Remove an instance setting (reverting to the config-file value).
286 ///
287 /// # Errors
288 ///
289 /// Returns [`StoreError::Query`] on a database failure.
290 pub async fn delete_setting(&self, key: &str) -> Result<(), StoreError> {
291 sqlx::query("DELETE FROM instance_settings WHERE key = $1")
292 .bind(key)
293 .execute(&self.pool)
294 .await?;
295 Ok(())
296 }
297}