Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +35 −0
| @@ -973,6 +973,41 @@ body.drawer-open .drawer-backdrop { | |||
| 973 | 973 | margin-left: auto; | |
| 974 | 974 | } | |
| 975 | 975 | ||
| 976 | + | /* Admin dashboard: stat grid and management lists. */ | |
| 977 | + | .stat-grid { | |
| 978 | + | display: grid; | |
| 979 | + | grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); | |
| 980 | + | gap: 0.75rem; | |
| 981 | + | } | |
| 982 | + | .stat-card { | |
| 983 | + | padding: 1rem; | |
| 984 | + | background: var(--fb-bg-raised); | |
| 985 | + | border: 1px solid var(--fb-border); | |
| 986 | + | border-radius: var(--fb-radius); | |
| 987 | + | } | |
| 988 | + | .stat-value { | |
| 989 | + | font-size: 1.6rem; | |
| 990 | + | font-weight: 600; | |
| 991 | + | } | |
| 992 | + | .stat-label { | |
| 993 | + | font-size: 0.85em; | |
| 994 | + | } | |
| 995 | + | .admin-actions { | |
| 996 | + | flex-wrap: wrap; | |
| 997 | + | gap: 0.35rem; | |
| 998 | + | } | |
| 999 | + | .admin-form-row { | |
| 1000 | + | display: flex; | |
| 1001 | + | gap: 0.5rem; | |
| 1002 | + | flex-wrap: wrap; | |
| 1003 | + | margin-bottom: 0.5rem; | |
| 1004 | + | } | |
| 1005 | + | .admin-form-row input, | |
| 1006 | + | .admin-form-row select { | |
| 1007 | + | flex: 1; | |
| 1008 | + | min-width: 9rem; | |
| 1009 | + | } | |
| 1010 | + | ||
| 976 | 1011 | /* Settings: a left tab rail and the active tab's content. */ | |
| 977 | 1012 | .settings-layout { | |
| 978 | 1013 | display: flex; | |
crates/store/src/admin.rs +297 −0
| @@ -0,0 +1,297 @@ | |||
| 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 | + | ||
| 8 | + | use std::collections::HashMap; | |
| 9 | + | ||
| 10 | + | use sqlx::Row; | |
| 11 | + | ||
| 12 | + | use crate::{Store, StoreError, new_id, now_ms}; | |
| 13 | + | ||
| 14 | + | /// Row counts for the admin overview. | |
| 15 | + | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 16 | + | pub 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)] | |
| 43 | + | pub 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)] | |
| 61 | + | pub 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 | + | ||
| 72 | + | impl 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 | + | } | |
crates/store/src/groups.rs +16 −0
| @@ -128,6 +128,22 @@ impl Store { | |||
| 128 | 128 | .map_err(Into::into) | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | + | /// List every group across all owners, ordered by path. For the admin view. | |
| 132 | + | /// | |
| 133 | + | /// # Errors | |
| 134 | + | /// | |
| 135 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 136 | + | pub async fn list_groups(&self) -> Result<Vec<Group>, StoreError> { | |
| 137 | + | let sql = format!("SELECT {GROUP_COLUMNS} FROM groups ORDER BY owner_id ASC, path ASC"); | |
| 138 | + | let rows = sqlx::query(AssertSqlSafe(sql)) | |
| 139 | + | .fetch_all(&self.pool) | |
| 140 | + | .await?; | |
| 141 | + | rows.iter() | |
| 142 | + | .map(map_group) | |
| 143 | + | .collect::<Result<_, _>>() | |
| 144 | + | .map_err(Into::into) | |
| 145 | + | } | |
| 146 | + | ||
| 131 | 147 | /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in | |
| 132 | 148 | /// the group have their `group_id` set null by the schema, so they survive as | |
| 133 | 149 | /// ungrouped repos. Returns `true` if a row was deleted. | |
crates/store/src/lib.rs +2 −0
| @@ -25,6 +25,7 @@ | |||
| 25 | 25 | //! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching | |
| 26 | 26 | //! set is embedded at build time and selected at runtime by [`Store::migrate`]. | |
| 27 | 27 | ||
| 28 | + | mod admin; | |
| 28 | 29 | mod groups; | |
| 29 | 30 | mod invites; | |
| 30 | 31 | mod issues; | |
| @@ -43,6 +44,7 @@ use sqlx::Row; | |||
| 43 | 44 | use sqlx::any::{AnyPoolOptions, AnyRow}; | |
| 44 | 45 | use sqlx::migrate::Migrator; | |
| 45 | 46 | ||
| 47 | + | pub use crate::admin::{AdminCounts, NewSignupInvite, SignupInvite}; | |
| 46 | 48 | pub use crate::invites::NewInvite; | |
| 47 | 49 | pub use crate::issues::StateCounts; | |
| 48 | 50 | pub use crate::keys::NewKey; | |
crates/store/src/users.rs +16 −0
| @@ -571,6 +571,22 @@ impl Store { | |||
| 571 | 571 | Ok(updated > 0) | |
| 572 | 572 | } | |
| 573 | 573 | ||
| 574 | + | /// Grant or revoke a user's site-administrator flag. | |
| 575 | + | /// | |
| 576 | + | /// # Errors | |
| 577 | + | /// | |
| 578 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 579 | + | pub async fn set_admin(&self, user_id: &str, is_admin: bool) -> Result<bool, StoreError> { | |
| 580 | + | let updated = sqlx::query("UPDATE users SET is_admin = $1, updated_at = $2 WHERE id = $3") | |
| 581 | + | .bind(is_admin) | |
| 582 | + | .bind(now_ms()) | |
| 583 | + | .bind(user_id) | |
| 584 | + | .execute(&self.pool) | |
| 585 | + | .await? | |
| 586 | + | .rows_affected(); | |
| 587 | + | Ok(updated > 0) | |
| 588 | + | } | |
| 589 | + | ||
| 574 | 590 | /// Delete a user by id, cascading to their repositories, keys, tokens, and | |
| 575 | 591 | /// sessions (via `ON DELETE CASCADE`). The caller is responsible for any | |
| 576 | 592 | /// policy that should *prevent* the delete (e.g. refusing when repos exist | |
crates/web/src/admin.rs +827 −0
| @@ -0,0 +1,827 @@ | |||
| 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 | + | //! The admin dashboard: instance overview, and management of users, repositories, | |
| 6 | + | //! groups, sign-up invites, and config overrides. Every route is gated by the | |
| 7 | + | //! [`RequireAdmin`] extractor (non-admins get a 404). | |
| 8 | + | ||
| 9 | + | use std::path::{Path as FsPath, PathBuf}; | |
| 10 | + | ||
| 11 | + | use axum::Form; | |
| 12 | + | use axum::extract::{Path, State}; | |
| 13 | + | use axum::http::{HeaderMap, Uri}; | |
| 14 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 15 | + | use axum_extra::extract::cookie::CookieJar; | |
| 16 | + | use maud::{Markup, html}; | |
| 17 | + | use model::User; | |
| 18 | + | use serde::Deserialize; | |
| 19 | + | ||
| 20 | + | use crate::error::{AppError, AppResult}; | |
| 21 | + | use crate::layout::page; | |
| 22 | + | use crate::pages::build_chrome; | |
| 23 | + | use crate::session::{RequireAdmin, verify_csrf}; | |
| 24 | + | use crate::{AppState, setting_keys}; | |
| 25 | + | ||
| 26 | + | /// The admin dashboard shell: a left tab rail and the active tab's content. | |
| 27 | + | #[allow(clippy::needless_pass_by_value)] // `content` is embedded once. | |
| 28 | + | fn admin_shell(active: &str, content: Markup) -> Markup { | |
| 29 | + | let tab = |href: &str, key: &str, label: &str| { | |
| 30 | + | html! { a href=(href) aria-current=[(active == key).then_some("page")] { (label) } } | |
| 31 | + | }; | |
| 32 | + | html! { | |
| 33 | + | div class="settings-layout" { | |
| 34 | + | aside class="settings-nav" { | |
| 35 | + | h1 { "Admin" } | |
| 36 | + | nav { | |
| 37 | + | (tab("/admin", "overview", "Overview")) | |
| 38 | + | (tab("/admin/users", "users", "Users")) | |
| 39 | + | (tab("/admin/repos", "repos", "Repositories")) | |
| 40 | + | (tab("/admin/groups", "groups", "Groups")) | |
| 41 | + | (tab("/admin/invites", "invites", "Invites")) | |
| 42 | + | (tab("/admin/settings", "settings", "Settings")) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | div class="settings-content" { (content) } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Render an admin page with the shell and chrome. | |
| 51 | + | fn render( | |
| 52 | + | state: &AppState, | |
| 53 | + | jar: CookieJar, | |
| 54 | + | user: User, | |
| 55 | + | uri: &Uri, | |
| 56 | + | active: &str, | |
| 57 | + | content: Markup, | |
| 58 | + | ) -> Response { | |
| 59 | + | let (jar, chrome) = build_chrome(state, jar, Some(user), uri.path().to_string()); | |
| 60 | + | (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // ---- Overview ---- | |
| 64 | + | ||
| 65 | + | /// `GET /admin` — instance statistics. | |
| 66 | + | pub async fn overview( | |
| 67 | + | State(state): State<AppState>, | |
| 68 | + | RequireAdmin(user): RequireAdmin, | |
| 69 | + | jar: CookieJar, | |
| 70 | + | uri: Uri, | |
| 71 | + | ) -> AppResult<Response> { | |
| 72 | + | let counts = state.store.admin_counts().await?; | |
| 73 | + | let tracked = state.store.total_repo_bytes().await.unwrap_or(0); | |
| 74 | + | // Disk usage of the repo tree (best-effort) and the SQLite DB file, if any. | |
| 75 | + | let repo_dir = state.config.storage.repo_dir.clone(); | |
| 76 | + | let disk = tokio::task::spawn_blocking(move || dir_size(&repo_dir)) | |
| 77 | + | .await | |
| 78 | + | .unwrap_or(0); | |
| 79 | + | let db_bytes = sqlite_db_size(&state.config.database.url); | |
| 80 | + | ||
| 81 | + | let stat = |label: &str, value: String| { | |
| 82 | + | html! { div class="stat-card" { div class="stat-value" { (value) } div class="stat-label muted" { (label) } } } | |
| 83 | + | }; | |
| 84 | + | let content = html! { | |
| 85 | + | h2 { "Overview" } | |
| 86 | + | div class="stat-grid" { | |
| 87 | + | (stat("Users", counts.users.to_string())) | |
| 88 | + | (stat("Administrators", counts.admins.to_string())) | |
| 89 | + | (stat("Disabled", counts.disabled_users.to_string())) | |
| 90 | + | (stat("Repositories", counts.repos.to_string())) | |
| 91 | + | (stat("Groups", counts.groups.to_string())) | |
| 92 | + | (stat("Issues (open)", format!("{} ({})", counts.issues, counts.open_issues))) | |
| 93 | + | (stat("Pull requests", counts.pulls.to_string())) | |
| 94 | + | (stat("SSH/GPG keys", counts.keys.to_string())) | |
| 95 | + | (stat("Active API tokens", counts.tokens.to_string())) | |
| 96 | + | (stat("Live sessions", counts.sessions.to_string())) | |
| 97 | + | (stat("Repository storage", fmt_bytes(disk.max(u64::try_from(tracked).unwrap_or(0))))) | |
| 98 | + | @if let Some(db) = db_bytes { (stat("Database size", fmt_bytes(db))) } | |
| 99 | + | } | |
| 100 | + | }; | |
| 101 | + | Ok(render(&state, jar, user, &uri, "overview", content)) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // ---- Users ---- | |
| 105 | + | ||
| 106 | + | /// `GET /admin/users` — the user list and a create form. | |
| 107 | + | pub async fn users( | |
| 108 | + | State(state): State<AppState>, | |
| 109 | + | RequireAdmin(user): RequireAdmin, | |
| 110 | + | jar: CookieJar, | |
| 111 | + | uri: Uri, | |
| 112 | + | ) -> AppResult<Response> { | |
| 113 | + | let all = state.store.list_users().await?; | |
| 114 | + | let (_, chrome) = build_chrome( | |
| 115 | + | &state, | |
| 116 | + | jar.clone(), | |
| 117 | + | Some(user.clone()), | |
| 118 | + | uri.path().to_string(), | |
| 119 | + | ); | |
| 120 | + | let csrf = chrome.csrf.clone(); | |
| 121 | + | let content = html! { | |
| 122 | + | h2 { "Users" } | |
| 123 | + | section class="listing" { | |
| 124 | + | div class="card" { | |
| 125 | + | ul class="entry-list admin-list" { | |
| 126 | + | @for u in &all { | |
| 127 | + | li class="entry-row" { | |
| 128 | + | div class="entry-row-body" { | |
| 129 | + | span class="entry-row-title" { | |
| 130 | + | a href={ "/" (u.username) } { (u.username) } | |
| 131 | + | @if u.is_admin { " " span class="badge" { "admin" } } | |
| 132 | + | @if u.disabled_at.is_some() { " " span class="badge badge-private" { "disabled" } } | |
| 133 | + | } | |
| 134 | + | div class="entry-row-meta muted" { (u.email) } | |
| 135 | + | } | |
| 136 | + | div class="entry-row-actions admin-actions" { | |
| 137 | + | (post_button(&format!("/admin/users/{}/verify", u.id), &csrf, "Verify", "btn")) | |
| 138 | + | (toggle_button(&format!("/admin/users/{}/admin", u.id), &csrf, "admin", !u.is_admin, | |
| 139 | + | if u.is_admin { "Revoke admin" } else { "Make admin" })) | |
| 140 | + | (toggle_button(&format!("/admin/users/{}/disable", u.id), &csrf, "disabled", u.disabled_at.is_none(), | |
| 141 | + | if u.disabled_at.is_some() { "Enable" } else { "Disable" })) | |
| 142 | + | @if u.id != user.id { | |
| 143 | + | (post_button(&format!("/admin/users/{}/delete", u.id), &csrf, "Delete", "btn btn-danger")) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | section class="listing" { | |
| 152 | + | h3 { "Create a user" } | |
| 153 | + | div class="card" { | |
| 154 | + | form method="post" action="/admin/users" { | |
| 155 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 156 | + | div class="admin-form-row" { | |
| 157 | + | input type="text" name="username" placeholder="username" required; | |
| 158 | + | input type="email" name="email" placeholder="email" required; | |
| 159 | + | input type="password" name="password" placeholder="password (optional)"; | |
| 160 | + | } | |
| 161 | + | label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" } | |
| 162 | + | button class="btn btn-primary inline-btn" type="submit" { "Create user" } | |
| 163 | + | } | |
| 164 | + | p class="muted field-hint" { "Leaving the password blank creates an account that must be activated by the user (they'll need an invite/reset)." } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | section class="listing" { | |
| 168 | + | h3 { "Reset a password" } | |
| 169 | + | div class="card" { | |
| 170 | + | form method="post" action="/admin/users/password" { | |
| 171 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 172 | + | div class="admin-form-row" { | |
| 173 | + | select name="user_id" aria-label="User" { | |
| 174 | + | @for u in &all { option value=(u.id) { (u.username) } } | |
| 175 | + | } | |
| 176 | + | input type="password" name="password" placeholder="new password" required minlength="8"; | |
| 177 | + | } | |
| 178 | + | button class="btn inline-btn" type="submit" { "Set password" } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | }; | |
| 183 | + | Ok(render(&state, jar, user, &uri, "users", content)) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// Create-user form. | |
| 187 | + | #[derive(Debug, Deserialize)] | |
| 188 | + | pub struct CreateUserForm { | |
| 189 | + | #[serde(default)] | |
| 190 | + | username: String, | |
| 191 | + | #[serde(default)] | |
| 192 | + | email: String, | |
| 193 | + | #[serde(default)] | |
| 194 | + | password: String, | |
| 195 | + | #[serde(default)] | |
| 196 | + | admin: Option<String>, | |
| 197 | + | #[serde(rename = "_csrf")] | |
| 198 | + | csrf: String, | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// `POST /admin/users` — create a user. | |
| 202 | + | pub async fn user_create( | |
| 203 | + | State(state): State<AppState>, | |
| 204 | + | RequireAdmin(_): RequireAdmin, | |
| 205 | + | jar: CookieJar, | |
| 206 | + | headers: HeaderMap, | |
| 207 | + | Form(form): Form<CreateUserForm>, | |
| 208 | + | ) -> Response { | |
| 209 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 210 | + | return AppError::Forbidden.into_response(); | |
| 211 | + | } | |
| 212 | + | let hash = if form.password.trim().is_empty() { | |
| 213 | + | None | |
| 214 | + | } else { | |
| 215 | + | match hash_password(&state, form.password.trim()) { | |
| 216 | + | Ok(h) => Some(h), | |
| 217 | + | Err(e) => return e.into_response(), | |
| 218 | + | } | |
| 219 | + | }; | |
| 220 | + | match state | |
| 221 | + | .store | |
| 222 | + | .create_user(store::NewUser { | |
| 223 | + | username: form.username.trim().to_string(), | |
| 224 | + | email: form.email.trim().to_string(), | |
| 225 | + | display_name: None, | |
| 226 | + | password_hash: hash, | |
| 227 | + | is_admin: form.admin.is_some(), | |
| 228 | + | must_change_password: false, | |
| 229 | + | }) | |
| 230 | + | .await | |
| 231 | + | { | |
| 232 | + | Ok(_) => Redirect::to("/admin/users").into_response(), | |
| 233 | + | Err(store::StoreError::Conflict { .. }) => { | |
| 234 | + | AppError::BadRequest("That username or email is already taken.".to_string()) | |
| 235 | + | .into_response() | |
| 236 | + | } | |
| 237 | + | Err(store::StoreError::Name(_)) => { | |
| 238 | + | AppError::BadRequest("Invalid username.".to_string()).into_response() | |
| 239 | + | } | |
| 240 | + | Err(err) => AppError::from(err).into_response(), | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// A `{disabled|admin}=true/false` toggle form. | |
| 245 | + | #[derive(Debug, Deserialize)] | |
| 246 | + | pub struct ToggleForm { | |
| 247 | + | #[serde(default)] | |
| 248 | + | disabled: String, | |
| 249 | + | #[serde(default)] | |
| 250 | + | admin: String, | |
| 251 | + | #[serde(rename = "_csrf")] | |
| 252 | + | csrf: String, | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// A single-field password form. | |
| 256 | + | #[derive(Debug, Deserialize)] | |
| 257 | + | pub struct PasswordForm { | |
| 258 | + | #[serde(default)] | |
| 259 | + | user_id: String, | |
| 260 | + | #[serde(default)] | |
| 261 | + | password: String, | |
| 262 | + | #[serde(rename = "_csrf")] | |
| 263 | + | csrf: String, | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// A bare CSRF form for parameter-less actions. | |
| 267 | + | #[derive(Debug, Deserialize)] | |
| 268 | + | pub struct BareForm { | |
| 269 | + | #[serde(rename = "_csrf")] | |
| 270 | + | csrf: String, | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// `POST /admin/users/{id}/verify`. | |
| 274 | + | pub async fn user_verify( | |
| 275 | + | State(state): State<AppState>, | |
| 276 | + | RequireAdmin(_): RequireAdmin, | |
| 277 | + | jar: CookieJar, | |
| 278 | + | headers: HeaderMap, | |
| 279 | + | Path(id): Path<String>, | |
| 280 | + | Form(form): Form<BareForm>, | |
| 281 | + | ) -> Response { | |
| 282 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 283 | + | return AppError::Forbidden.into_response(); | |
| 284 | + | } | |
| 285 | + | let _ = state.store.verify_primary_email(&id).await; | |
| 286 | + | Redirect::to("/admin/users").into_response() | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | /// `POST /admin/users/{id}/disable`. | |
| 290 | + | pub async fn user_disable( | |
| 291 | + | State(state): State<AppState>, | |
| 292 | + | RequireAdmin(_): RequireAdmin, | |
| 293 | + | jar: CookieJar, | |
| 294 | + | headers: HeaderMap, | |
| 295 | + | Path(id): Path<String>, | |
| 296 | + | Form(form): Form<ToggleForm>, | |
| 297 | + | ) -> Response { | |
| 298 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 299 | + | return AppError::Forbidden.into_response(); | |
| 300 | + | } | |
| 301 | + | let _ = state.store.set_disabled(&id, form.disabled == "true").await; | |
| 302 | + | Redirect::to("/admin/users").into_response() | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// `POST /admin/users/{id}/admin`. | |
| 306 | + | pub async fn user_toggle_admin( | |
| 307 | + | State(state): State<AppState>, | |
| 308 | + | RequireAdmin(_): RequireAdmin, | |
| 309 | + | jar: CookieJar, | |
| 310 | + | headers: HeaderMap, | |
| 311 | + | Path(id): Path<String>, | |
| 312 | + | Form(form): Form<ToggleForm>, | |
| 313 | + | ) -> Response { | |
| 314 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 315 | + | return AppError::Forbidden.into_response(); | |
| 316 | + | } | |
| 317 | + | let _ = state.store.set_admin(&id, form.admin == "true").await; | |
| 318 | + | Redirect::to("/admin/users").into_response() | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// `POST /admin/users/{id}/delete`. | |
| 322 | + | pub async fn user_delete( | |
| 323 | + | State(state): State<AppState>, | |
| 324 | + | RequireAdmin(admin): RequireAdmin, | |
| 325 | + | jar: CookieJar, | |
| 326 | + | headers: HeaderMap, | |
| 327 | + | Path(id): Path<String>, | |
| 328 | + | Form(form): Form<BareForm>, | |
| 329 | + | ) -> Response { | |
| 330 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 331 | + | return AppError::Forbidden.into_response(); | |
| 332 | + | } | |
| 333 | + | if id == admin.id { | |
| 334 | + | return AppError::BadRequest("You cannot delete your own account here.".to_string()) | |
| 335 | + | .into_response(); | |
| 336 | + | } | |
| 337 | + | let _ = state.store.delete_user(&id).await; | |
| 338 | + | Redirect::to("/admin/users").into_response() | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | /// `POST /admin/users/password` — set a user's password. | |
| 342 | + | pub async fn user_set_password( | |
| 343 | + | State(state): State<AppState>, | |
| 344 | + | RequireAdmin(_): RequireAdmin, | |
| 345 | + | jar: CookieJar, | |
| 346 | + | headers: HeaderMap, | |
| 347 | + | Form(form): Form<PasswordForm>, | |
| 348 | + | ) -> Response { | |
| 349 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 350 | + | return AppError::Forbidden.into_response(); | |
| 351 | + | } | |
| 352 | + | let hash = match hash_password(&state, form.password.trim()) { | |
| 353 | + | Ok(h) => h, | |
| 354 | + | Err(e) => return e.into_response(), | |
| 355 | + | }; | |
| 356 | + | let _ = state.store.set_password(&form.user_id, Some(&hash)).await; | |
| 357 | + | Redirect::to("/admin/users").into_response() | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // ---- Repositories ---- | |
| 361 | + | ||
| 362 | + | /// `GET /admin/repos`. | |
| 363 | + | pub async fn repos( | |
| 364 | + | State(state): State<AppState>, | |
| 365 | + | RequireAdmin(user): RequireAdmin, | |
| 366 | + | jar: CookieJar, | |
| 367 | + | uri: Uri, | |
| 368 | + | ) -> AppResult<Response> { | |
| 369 | + | let repos = state.store.list_repos().await?; | |
| 370 | + | // Resolve owner names for links. | |
| 371 | + | let mut owners = std::collections::HashMap::new(); | |
| 372 | + | for r in &repos { | |
| 373 | + | if !owners.contains_key(&r.owner_id) | |
| 374 | + | && let Ok(Some(u)) = state.store.user_by_id(&r.owner_id).await | |
| 375 | + | { | |
| 376 | + | owners.insert(r.owner_id.clone(), u.username); | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | let (_, chrome) = build_chrome( | |
| 380 | + | &state, | |
| 381 | + | jar.clone(), | |
| 382 | + | Some(user.clone()), | |
| 383 | + | uri.path().to_string(), | |
| 384 | + | ); | |
| 385 | + | let csrf = chrome.csrf.clone(); | |
| 386 | + | let content = html! { | |
| 387 | + | h2 { "Repositories" } | |
| 388 | + | section class="listing" { | |
| 389 | + | div class="card" { | |
| 390 | + | @if repos.is_empty() { p class="muted" { "No repositories." } } | |
| 391 | + | ul class="entry-list admin-list" { | |
| 392 | + | @for r in &repos { | |
| 393 | + | @let owner = owners.get(&r.owner_id).cloned().unwrap_or_else(|| r.owner_id.clone()); | |
| 394 | + | li class="entry-row" { | |
| 395 | + | div class="entry-row-body" { | |
| 396 | + | span class="entry-row-title" { a href={ "/" (owner) "/" (r.path) } { (owner) "/" (r.path) } } | |
| 397 | + | div class="entry-row-meta muted" { (r.visibility.as_str()) " · " (r.object_format) } | |
| 398 | + | } | |
| 399 | + | div class="entry-row-actions admin-actions" { | |
| 400 | + | a class="btn" href={ "/" (owner) "/" (r.path) "/-/settings" } { "Manage" } | |
| 401 | + | (post_button(&format!("/admin/repos/{}/delete", r.id), &csrf, "Delete", "btn btn-danger")) | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | }; | |
| 409 | + | Ok(render(&state, jar, user, &uri, "repos", content)) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | /// `POST /admin/repos/{id}/delete`. | |
| 413 | + | pub async fn repo_delete( | |
| 414 | + | State(state): State<AppState>, | |
| 415 | + | RequireAdmin(_): RequireAdmin, | |
| 416 | + | jar: CookieJar, | |
| 417 | + | headers: HeaderMap, | |
| 418 | + | Path(id): Path<String>, | |
| 419 | + | Form(form): Form<BareForm>, | |
| 420 | + | ) -> Response { | |
| 421 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 422 | + | return AppError::Forbidden.into_response(); | |
| 423 | + | } | |
| 424 | + | let _ = state.store.delete_repo(&id).await; | |
| 425 | + | Redirect::to("/admin/repos").into_response() | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // ---- Groups ---- | |
| 429 | + | ||
| 430 | + | /// `GET /admin/groups`. | |
| 431 | + | pub async fn groups( | |
| 432 | + | State(state): State<AppState>, | |
| 433 | + | RequireAdmin(user): RequireAdmin, | |
| 434 | + | jar: CookieJar, | |
| 435 | + | uri: Uri, | |
| 436 | + | ) -> AppResult<Response> { | |
| 437 | + | let groups = state.store.list_groups().await?; | |
| 438 | + | let mut owners = std::collections::HashMap::new(); | |
| 439 | + | for g in &groups { | |
| 440 | + | if !owners.contains_key(&g.owner_id) | |
| 441 | + | && let Ok(Some(u)) = state.store.user_by_id(&g.owner_id).await | |
| 442 | + | { | |
| 443 | + | owners.insert(g.owner_id.clone(), u.username); | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | let (_, chrome) = build_chrome( | |
| 447 | + | &state, | |
| 448 | + | jar.clone(), | |
| 449 | + | Some(user.clone()), | |
| 450 | + | uri.path().to_string(), | |
| 451 | + | ); | |
| 452 | + | let csrf = chrome.csrf.clone(); | |
| 453 | + | let content = html! { | |
| 454 | + | h2 { "Groups" } | |
| 455 | + | section class="listing" { | |
| 456 | + | div class="card" { | |
| 457 | + | @if groups.is_empty() { p class="muted" { "No groups." } } | |
| 458 | + | ul class="entry-list admin-list" { | |
| 459 | + | @for g in &groups { | |
| 460 | + | @let owner = owners.get(&g.owner_id).cloned().unwrap_or_else(|| g.owner_id.clone()); | |
| 461 | + | li class="entry-row" { | |
| 462 | + | div class="entry-row-body" { | |
| 463 | + | span class="entry-row-title" { a href={ "/" (owner) "/" (g.path) } { (owner) "/" (g.path) } } | |
| 464 | + | } | |
| 465 | + | div class="entry-row-actions admin-actions" { | |
| 466 | + | (post_button(&format!("/admin/groups/{}/delete", g.id), &csrf, "Delete", "btn btn-danger")) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | p class="muted field-hint" { "Deleting a group ungroups its repositories (they are not deleted)." } | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | }; | |
| 475 | + | Ok(render(&state, jar, user, &uri, "groups", content)) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | /// `POST /admin/groups/{id}/delete`. | |
| 479 | + | pub async fn group_delete( | |
| 480 | + | State(state): State<AppState>, | |
| 481 | + | RequireAdmin(_): RequireAdmin, | |
| 482 | + | jar: CookieJar, | |
| 483 | + | headers: HeaderMap, | |
| 484 | + | Path(id): Path<String>, | |
| 485 | + | Form(form): Form<BareForm>, | |
| 486 | + | ) -> Response { | |
| 487 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 488 | + | return AppError::Forbidden.into_response(); | |
| 489 | + | } | |
| 490 | + | let _ = state.store.delete_group(&id).await; | |
| 491 | + | Redirect::to("/admin/groups").into_response() | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | // ---- Invites ---- | |
| 495 | + | ||
| 496 | + | /// `GET /admin/invites`. | |
| 497 | + | pub async fn invites( | |
| 498 | + | State(state): State<AppState>, | |
| 499 | + | RequireAdmin(user): RequireAdmin, | |
| 500 | + | jar: CookieJar, | |
| 501 | + | uri: Uri, | |
| 502 | + | ) -> AppResult<Response> { | |
| 503 | + | let invites = state.store.list_signup_invites().await?; | |
| 504 | + | let base = state.config.instance.url.trim_end_matches('/').to_string(); | |
| 505 | + | let (_, chrome) = build_chrome( | |
| 506 | + | &state, | |
| 507 | + | jar.clone(), | |
| 508 | + | Some(user.clone()), | |
| 509 | + | uri.path().to_string(), | |
| 510 | + | ); | |
| 511 | + | let csrf = chrome.csrf.clone(); | |
| 512 | + | let content = html! { | |
| 513 | + | h2 { "Sign-up invites" } | |
| 514 | + | p class="muted field-hint" { | |
| 515 | + | "Invite links let people register even when self-registration is disabled. The link is shown once at creation." | |
| 516 | + | } | |
| 517 | + | section class="listing" { | |
| 518 | + | div class="card" { | |
| 519 | + | @if invites.is_empty() { p class="muted" { "No invites." } } | |
| 520 | + | ul class="entry-list admin-list" { | |
| 521 | + | @for iv in &invites { | |
| 522 | + | li class="entry-row" { | |
| 523 | + | div class="entry-row-body" { | |
| 524 | + | span class="entry-row-title" { (iv.note.clone().unwrap_or_else(|| "(no note)".to_string())) } | |
| 525 | + | div class="entry-row-meta muted" { | |
| 526 | + | @if iv.used_at.is_some() { "used" } @else { "unused" } | |
| 527 | + | @if let Some(exp) = iv.expires_at { " · expires " (crate::repo::fmt_date(exp)) } | |
| 528 | + | } | |
| 529 | + | } | |
| 530 | + | div class="entry-row-actions admin-actions" { | |
| 531 | + | (post_button(&format!("/admin/invites/{}/delete", iv.id), &csrf, "Delete", "btn btn-danger")) | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | form method="post" action="/admin/invites" { | |
| 537 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 538 | + | div class="admin-form-row" { | |
| 539 | + | input type="text" name="note" placeholder="note (optional, e.g. who it's for)"; | |
| 540 | + | } | |
| 541 | + | button class="btn btn-primary inline-btn" type="submit" { "Create invite" } | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | @if let Some(link) = jar.get("fabrica_flash_invite").map(|c| c.value().to_string()) { | |
| 546 | + | div class="card notice notice-success" { | |
| 547 | + | p { strong { "Invite link created." } " Copy it now:" } | |
| 548 | + | pre class="token-secret" { code { (base) "/register/" (link) } } | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | }; | |
| 552 | + | // Clear the flash cookie after showing it. | |
| 553 | + | let jar = jar.remove(axum_extra::extract::cookie::Cookie::from( | |
| 554 | + | "fabrica_flash_invite", | |
| 555 | + | )); | |
| 556 | + | Ok(render(&state, jar, user, &uri, "invites", content)) | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | /// Create-invite form. | |
| 560 | + | #[derive(Debug, Deserialize)] | |
| 561 | + | pub struct CreateInviteForm { | |
| 562 | + | #[serde(default)] | |
| 563 | + | note: String, | |
| 564 | + | #[serde(rename = "_csrf")] | |
| 565 | + | csrf: String, | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | /// `POST /admin/invites` — create a sign-up invite and show its link once. | |
| 569 | + | pub async fn invite_create( | |
| 570 | + | State(state): State<AppState>, | |
| 571 | + | RequireAdmin(admin): RequireAdmin, | |
| 572 | + | jar: CookieJar, | |
| 573 | + | headers: HeaderMap, | |
| 574 | + | Form(form): Form<CreateInviteForm>, | |
| 575 | + | ) -> Response { | |
| 576 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 577 | + | return AppError::Forbidden.into_response(); | |
| 578 | + | } | |
| 579 | + | let token = auth::new_session_token(); | |
| 580 | + | let note = form.note.trim(); | |
| 581 | + | let created = state | |
| 582 | + | .store | |
| 583 | + | .create_signup_invite(store::NewSignupInvite { | |
| 584 | + | token_hash: token.id, | |
| 585 | + | note: (!note.is_empty()).then(|| note.to_string()), | |
| 586 | + | expires_at: None, | |
| 587 | + | created_by: admin.id, | |
| 588 | + | }) | |
| 589 | + | .await; | |
| 590 | + | if created.is_err() { | |
| 591 | + | return AppError::internal("could not create invite").into_response(); | |
| 592 | + | } | |
| 593 | + | // Stash the raw token in a short-lived cookie so the list page can show it once. | |
| 594 | + | let flash = axum_extra::extract::cookie::Cookie::build(("fabrica_flash_invite", token.cookie)) | |
| 595 | + | .http_only(true) | |
| 596 | + | .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 597 | + | .secure(state.config.auth.cookie_secure) | |
| 598 | + | .path("/admin/invites") | |
| 599 | + | .build(); | |
| 600 | + | (jar.add(flash), Redirect::to("/admin/invites")).into_response() | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | /// `POST /admin/invites/{id}/delete`. | |
| 604 | + | pub async fn invite_delete( | |
| 605 | + | State(state): State<AppState>, | |
| 606 | + | RequireAdmin(_): RequireAdmin, | |
| 607 | + | jar: CookieJar, | |
| 608 | + | headers: HeaderMap, | |
| 609 | + | Path(id): Path<String>, | |
| 610 | + | Form(form): Form<BareForm>, | |
| 611 | + | ) -> Response { | |
| 612 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 613 | + | return AppError::Forbidden.into_response(); | |
| 614 | + | } | |
| 615 | + | let _ = state.store.delete_signup_invite(&id).await; | |
| 616 | + | Redirect::to("/admin/invites").into_response() | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | // ---- Settings ---- | |
| 620 | + | ||
| 621 | + | /// `GET /admin/settings` — override config values. | |
| 622 | + | pub async fn settings( | |
| 623 | + | State(state): State<AppState>, | |
| 624 | + | RequireAdmin(user): RequireAdmin, | |
| 625 | + | jar: CookieJar, | |
| 626 | + | uri: Uri, | |
| 627 | + | ) -> AppResult<Response> { | |
| 628 | + | let (_, chrome) = build_chrome( | |
| 629 | + | &state, | |
| 630 | + | jar.clone(), | |
| 631 | + | Some(user.clone()), | |
| 632 | + | uri.path().to_string(), | |
| 633 | + | ); | |
| 634 | + | let csrf = chrome.csrf.clone(); | |
| 635 | + | let name = state.instance_name(); | |
| 636 | + | let desc = state.setting_str( | |
| 637 | + | setting_keys::DESCRIPTION, | |
| 638 | + | &state.config.instance.description, | |
| 639 | + | ); | |
| 640 | + | let reg = state.allow_registration(); | |
| 641 | + | let anon = state.allow_anonymous(); | |
| 642 | + | let vis = state.default_visibility_token(); | |
| 643 | + | let vis_opt = |v: &str, label: &str| html! { option value=(v) selected[v == vis] { (label) } }; | |
| 644 | + | let content = html! { | |
| 645 | + | h2 { "Instance settings" } | |
| 646 | + | p class="muted field-hint" { | |
| 647 | + | "These override the config file. Leaving a field at its default keeps the config value; each row can be reset." | |
| 648 | + | } | |
| 649 | + | div class="card" { | |
| 650 | + | form method="post" action="/admin/settings" { | |
| 651 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 652 | + | label for="s-name" { "Instance name" } | |
| 653 | + | input type="text" id="s-name" name="name" value=(name); | |
| 654 | + | label for="s-desc" { "Description" } | |
| 655 | + | input type="text" id="s-desc" name="description" value=(desc); | |
| 656 | + | label for="s-vis" { "Default repository visibility" } | |
| 657 | + | select id="s-vis" name="default_visibility" { | |
| 658 | + | (vis_opt("public", "Public")) | |
| 659 | + | (vis_opt("internal", "Internal")) | |
| 660 | + | (vis_opt("private", "Private")) | |
| 661 | + | } | |
| 662 | + | label class="checkbox" { input type="checkbox" name="allow_registration" value="1" checked[reg]; " Allow self-registration" } | |
| 663 | + | label class="checkbox" { input type="checkbox" name="allow_anonymous" value="1" checked[anon]; " Allow anonymous browse/clone" } | |
| 664 | + | button class="btn btn-primary inline-btn" type="submit" { "Save settings" } | |
| 665 | + | } | |
| 666 | + | } | |
| 667 | + | }; | |
| 668 | + | Ok(render(&state, jar, user, &uri, "settings", content)) | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | /// Settings form. | |
| 672 | + | #[derive(Debug, Deserialize)] | |
| 673 | + | pub struct SettingsForm { | |
| 674 | + | #[serde(default)] | |
| 675 | + | name: String, | |
| 676 | + | #[serde(default)] | |
| 677 | + | description: String, | |
| 678 | + | #[serde(default)] | |
| 679 | + | default_visibility: String, | |
| 680 | + | #[serde(default)] | |
| 681 | + | allow_registration: Option<String>, | |
| 682 | + | #[serde(default)] | |
| 683 | + | allow_anonymous: Option<String>, | |
| 684 | + | #[serde(rename = "_csrf")] | |
| 685 | + | csrf: String, | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | /// `POST /admin/settings` — persist overrides (a blank name/description clears it). | |
| 689 | + | pub async fn settings_save( | |
| 690 | + | State(state): State<AppState>, | |
| 691 | + | RequireAdmin(_): RequireAdmin, | |
| 692 | + | jar: CookieJar, | |
| 693 | + | headers: HeaderMap, | |
| 694 | + | Form(form): Form<SettingsForm>, | |
| 695 | + | ) -> Response { | |
| 696 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 697 | + | return AppError::Forbidden.into_response(); | |
| 698 | + | } | |
| 699 | + | // A blank text field reverts to the config value (delete the override). | |
| 700 | + | for (key, value) in [ | |
| 701 | + | (setting_keys::NAME, form.name.trim()), | |
| 702 | + | (setting_keys::DESCRIPTION, form.description.trim()), | |
| 703 | + | ] { | |
| 704 | + | if value.is_empty() { | |
| 705 | + | let _ = state.store.delete_setting(key).await; | |
| 706 | + | } else { | |
| 707 | + | let _ = state.store.set_setting(key, value).await; | |
| 708 | + | } | |
| 709 | + | } | |
| 710 | + | if matches!( | |
| 711 | + | form.default_visibility.as_str(), | |
| 712 | + | "public" | "internal" | "private" | |
| 713 | + | ) { | |
| 714 | + | let _ = state | |
| 715 | + | .store | |
| 716 | + | .set_setting(setting_keys::DEFAULT_VISIBILITY, &form.default_visibility) | |
| 717 | + | .await; | |
| 718 | + | } | |
| 719 | + | let _ = state | |
| 720 | + | .store | |
| 721 | + | .set_setting( | |
| 722 | + | setting_keys::ALLOW_REGISTRATION, | |
| 723 | + | if form.allow_registration.is_some() { | |
| 724 | + | "true" | |
| 725 | + | } else { | |
| 726 | + | "false" | |
| 727 | + | }, | |
| 728 | + | ) | |
| 729 | + | .await; | |
| 730 | + | let _ = state | |
| 731 | + | .store | |
| 732 | + | .set_setting( | |
| 733 | + | setting_keys::ALLOW_ANONYMOUS, | |
| 734 | + | if form.allow_anonymous.is_some() { | |
| 735 | + | "true" | |
| 736 | + | } else { | |
| 737 | + | "false" | |
| 738 | + | }, | |
| 739 | + | ) | |
| 740 | + | .await; | |
| 741 | + | state.reload_settings().await; | |
| 742 | + | Redirect::to("/admin/settings").into_response() | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | // ---- Helpers ---- | |
| 746 | + | ||
| 747 | + | /// A small POST form rendering a single button. | |
| 748 | + | fn post_button(action: &str, csrf: &str, label: &str, class: &str) -> Markup { | |
| 749 | + | html! { | |
| 750 | + | form method="post" action=(action) class="inline-form" { | |
| 751 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 752 | + | button class=(class) type="submit" { (label) } | |
| 753 | + | } | |
| 754 | + | } | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | /// A POST form that sets `field` to `value` (for enable/disable, admin toggles). | |
| 758 | + | fn toggle_button(action: &str, csrf: &str, field: &str, value: bool, label: &str) -> Markup { | |
| 759 | + | html! { | |
| 760 | + | form method="post" action=(action) class="inline-form" { | |
| 761 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 762 | + | input type="hidden" name=(field) value=(if value { "true" } else { "false" }); | |
| 763 | + | button class="btn" type="submit" { (label) } | |
| 764 | + | } | |
| 765 | + | } | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | /// Hash a password with the instance Argon2 cost. | |
| 769 | + | fn hash_password(state: &AppState, password: &str) -> Result<String, AppError> { | |
| 770 | + | let params = auth::HashParams { | |
| 771 | + | m_cost: state.config.auth.argon2_m_cost, | |
| 772 | + | t_cost: state.config.auth.argon2_t_cost, | |
| 773 | + | p_cost: state.config.auth.argon2_p_cost, | |
| 774 | + | }; | |
| 775 | + | auth::hash_password(password, params).map_err(|_| AppError::internal("hash failed")) | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | /// Recursively sum the sizes of regular files under `dir` (best-effort). | |
| 779 | + | fn dir_size(dir: &FsPath) -> u64 { | |
| 780 | + | let mut total = 0u64; | |
| 781 | + | let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()]; | |
| 782 | + | while let Some(path) = stack.pop() { | |
| 783 | + | let Ok(entries) = std::fs::read_dir(&path) else { | |
| 784 | + | continue; | |
| 785 | + | }; | |
| 786 | + | for entry in entries.flatten() { | |
| 787 | + | let Ok(ft) = entry.file_type() else { continue }; | |
| 788 | + | if ft.is_dir() { | |
| 789 | + | stack.push(entry.path()); | |
| 790 | + | } else if ft.is_file() | |
| 791 | + | && let Ok(meta) = entry.metadata() | |
| 792 | + | { | |
| 793 | + | total += meta.len(); | |
| 794 | + | } | |
| 795 | + | } | |
| 796 | + | } | |
| 797 | + | total | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | /// The on-disk size of a `sqlite:` database file, if the URL names one. | |
| 801 | + | fn sqlite_db_size(url: &str) -> Option<u64> { | |
| 802 | + | let path = url | |
| 803 | + | .strip_prefix("sqlite://") | |
| 804 | + | .or_else(|| url.strip_prefix("sqlite:"))?; | |
| 805 | + | let path = path.split('?').next().unwrap_or(path); | |
| 806 | + | if path.is_empty() || path == ":memory:" { | |
| 807 | + | return None; | |
| 808 | + | } | |
| 809 | + | std::fs::metadata(path).ok().map(|m| m.len()) | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | /// Format a byte count as a human-readable string. | |
| 813 | + | fn fmt_bytes(bytes: u64) -> String { | |
| 814 | + | const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; | |
| 815 | + | #[allow(clippy::cast_precision_loss)] | |
| 816 | + | let mut value = bytes as f64; | |
| 817 | + | let mut unit = 0; | |
| 818 | + | while value >= 1024.0 && unit < UNITS.len() - 1 { | |
| 819 | + | value /= 1024.0; | |
| 820 | + | unit += 1; | |
| 821 | + | } | |
| 822 | + | if unit == 0 { | |
| 823 | + | format!("{bytes} B") | |
| 824 | + | } else { | |
| 825 | + | format!("{value:.1} {}", UNITS[unit]) | |
| 826 | + | } | |
| 827 | + | } | |
crates/web/src/git_http.rs +1 −1
| @@ -227,7 +227,7 @@ async fn authorize( | |||
| 227 | 227 | viewer_id.as_ref(), | |
| 228 | 228 | &repo, | |
| 229 | 229 | collaborator, | |
| 230 | - | state.config.instance.allow_anonymous, | |
| 230 | + | state.allow_anonymous(), | |
| 231 | 231 | ); | |
| 232 | 232 | if access >= auth::Access::Read { | |
| 233 | 233 | Ok(repo) | |
crates/web/src/issues.rs +1 −1
| @@ -633,7 +633,7 @@ pub(crate) async fn access_for( | |||
| 633 | 633 | viewer_id.as_ref(), | |
| 634 | 634 | repo, | |
| 635 | 635 | collab, | |
| 636 | - | state.config.instance.allow_anonymous, | |
| 636 | + | state.allow_anonymous(), | |
| 637 | 637 | )) | |
| 638 | 638 | } | |
| 639 | 639 | ||
crates/web/src/layout.rs +4 −1
| @@ -129,7 +129,10 @@ fn drawer(chrome: &Chrome) -> Markup { | |||
| 129 | 129 | hr; | |
| 130 | 130 | } | |
| 131 | 131 | nav { | |
| 132 | - | @if chrome.user.is_some() { | |
| 132 | + | @if let Some(user) = &chrome.user { | |
| 133 | + | @if user.is_admin { | |
| 134 | + | a href="/admin" { (icon(Icon::Dashboard)) span { "Admin" } } | |
| 135 | + | } | |
| 133 | 136 | a href="/settings" { (icon(Icon::Settings)) span { "Settings" } } | |
| 134 | 137 | form method="post" action="/logout" { | |
| 135 | 138 | input type="hidden" name="_csrf" value=(chrome.csrf); | |
crates/web/src/lib.rs +100 −0
| @@ -11,6 +11,7 @@ | |||
| 11 | 11 | //! without JavaScript; htmx only enhances. | |
| 12 | 12 | ||
| 13 | 13 | mod activity; | |
| 14 | + | mod admin; | |
| 14 | 15 | mod assets; | |
| 15 | 16 | mod avatar; | |
| 16 | 17 | mod diff; | |
| @@ -78,6 +79,23 @@ pub struct AppState { | |||
| 78 | 79 | pub signatures: git::SignatureCache, | |
| 79 | 80 | /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it. | |
| 80 | 81 | assets: Arc<RwLock<Assets>>, | |
| 82 | + | /// Admin-set instance settings that override the config file, cached in | |
| 83 | + | /// memory and refreshed via [`AppState::reload_settings`]. | |
| 84 | + | settings: Arc<RwLock<std::collections::HashMap<String, String>>>, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Keys for the admin-overridable instance settings. | |
| 88 | + | pub(crate) mod setting_keys { | |
| 89 | + | /// Instance display name. | |
| 90 | + | pub const NAME: &str = "instance.name"; | |
| 91 | + | /// Instance description. | |
| 92 | + | pub const DESCRIPTION: &str = "instance.description"; | |
| 93 | + | /// Allow web self-registration. | |
| 94 | + | pub const ALLOW_REGISTRATION: &str = "instance.allow_registration"; | |
| 95 | + | /// Allow anonymous browse/clone. | |
| 96 | + | pub const ALLOW_ANONYMOUS: &str = "instance.allow_anonymous"; | |
| 97 | + | /// Default visibility for new repositories. | |
| 98 | + | pub const DEFAULT_VISIBILITY: &str = "instance.default_visibility"; | |
| 81 | 99 | } | |
| 82 | 100 | ||
| 83 | 101 | impl AppState { | |
| @@ -96,9 +114,69 @@ impl AppState { | |||
| 96 | 114 | secret: Arc::new(secret), | |
| 97 | 115 | signatures: git::SignatureCache::new(4096), | |
| 98 | 116 | assets: Arc::new(RwLock::new(assets)), | |
| 117 | + | settings: Arc::new(RwLock::new(std::collections::HashMap::new())), | |
| 99 | 118 | }) | |
| 100 | 119 | } | |
| 101 | 120 | ||
| 121 | + | /// Refresh the cached instance settings from the database. Call at startup and | |
| 122 | + | /// after any admin settings change. | |
| 123 | + | pub async fn reload_settings(&self) { | |
| 124 | + | if let Ok(map) = self.store.all_settings().await { | |
| 125 | + | *self | |
| 126 | + | .settings | |
| 127 | + | .write() | |
| 128 | + | .unwrap_or_else(PoisonError::into_inner) = map; | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// A settings override for `key`, if an admin has set one. | |
| 133 | + | fn setting(&self, key: &str) -> Option<String> { | |
| 134 | + | self.settings | |
| 135 | + | .read() | |
| 136 | + | .unwrap_or_else(PoisonError::into_inner) | |
| 137 | + | .get(key) | |
| 138 | + | .cloned() | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// A string setting: admin override, else `fallback` (the config-file value). | |
| 142 | + | pub(crate) fn setting_str(&self, key: &str, fallback: &str) -> String { | |
| 143 | + | self.setting(key).unwrap_or_else(|| fallback.to_string()) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// A boolean setting: admin override (`"true"`/`"false"`), else `fallback`. | |
| 147 | + | pub(crate) fn setting_bool(&self, key: &str, fallback: bool) -> bool { | |
| 148 | + | self.setting(key).map_or(fallback, |v| v == "true") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Whether web self-registration is currently allowed (override or config). | |
| 152 | + | pub(crate) fn allow_registration(&self) -> bool { | |
| 153 | + | self.setting_bool( | |
| 154 | + | setting_keys::ALLOW_REGISTRATION, | |
| 155 | + | self.config.instance.allow_registration, | |
| 156 | + | ) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Whether anonymous browse/clone is currently allowed (override or config). | |
| 160 | + | pub(crate) fn allow_anonymous(&self) -> bool { | |
| 161 | + | self.setting_bool( | |
| 162 | + | setting_keys::ALLOW_ANONYMOUS, | |
| 163 | + | self.config.instance.allow_anonymous, | |
| 164 | + | ) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The effective instance display name (override or config). | |
| 168 | + | pub(crate) fn instance_name(&self) -> String { | |
| 169 | + | self.setting_str(setting_keys::NAME, &self.config.instance.name) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// The effective default-visibility token for new repos (override or config). | |
| 173 | + | pub(crate) fn default_visibility_token(&self) -> String { | |
| 174 | + | self.setting_str( | |
| 175 | + | setting_keys::DEFAULT_VISIBILITY, | |
| 176 | + | self.config.instance.default_visibility.as_token(), | |
| 177 | + | ) | |
| 178 | + | } | |
| 179 | + | ||
| 102 | 180 | /// A read guard over the asset registry, recovering from a poisoned lock. | |
| 103 | 181 | fn assets(&self) -> RwLockReadGuard<'_, Assets> { | |
| 104 | 182 | self.assets.read().unwrap_or_else(PoisonError::into_inner) | |
| @@ -147,6 +225,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 147 | 225 | "/register", | |
| 148 | 226 | get(pages::register_form).post(pages::register_submit), | |
| 149 | 227 | ) | |
| 228 | + | .route("/register/{token}", get(pages::register_invite_form)) | |
| 150 | 229 | .route("/logout", post(pages::logout)) | |
| 151 | 230 | .route( | |
| 152 | 231 | "/invite/{token}", | |
| @@ -178,6 +257,27 @@ pub fn build_router(state: AppState) -> Router { | |||
| 178 | 257 | .route("/settings/keys/delete", post(pages::key_delete)) | |
| 179 | 258 | .route("/settings/keys/verify", post(pages::key_verify)) | |
| 180 | 259 | .route("/settings/avatar", post(avatar::upload)) | |
| 260 | + | // Admin dashboard (RequireAdmin-gated in each handler). | |
| 261 | + | .route("/admin", get(admin::overview)) | |
| 262 | + | .route("/admin/users", get(admin::users).post(admin::user_create)) | |
| 263 | + | .route("/admin/users/password", post(admin::user_set_password)) | |
| 264 | + | .route("/admin/users/{id}/verify", post(admin::user_verify)) | |
| 265 | + | .route("/admin/users/{id}/disable", post(admin::user_disable)) | |
| 266 | + | .route("/admin/users/{id}/admin", post(admin::user_toggle_admin)) | |
| 267 | + | .route("/admin/users/{id}/delete", post(admin::user_delete)) | |
| 268 | + | .route("/admin/repos", get(admin::repos)) | |
| 269 | + | .route("/admin/repos/{id}/delete", post(admin::repo_delete)) | |
| 270 | + | .route("/admin/groups", get(admin::groups)) | |
| 271 | + | .route("/admin/groups/{id}/delete", post(admin::group_delete)) | |
| 272 | + | .route( | |
| 273 | + | "/admin/invites", | |
| 274 | + | get(admin::invites).post(admin::invite_create), | |
| 275 | + | ) | |
| 276 | + | .route("/admin/invites/{id}/delete", post(admin::invite_delete)) | |
| 277 | + | .route( | |
| 278 | + | "/admin/settings", | |
| 279 | + | get(admin::settings).post(admin::settings_save), | |
| 280 | + | ) | |
| 181 | 281 | .route("/settings/theme", get(pages::set_theme)) | |
| 182 | 282 | .route("/avatar/{username}", get(avatar::show)) | |
| 183 | 283 | // Repo administration (owner/admin only; enforced in the handlers). A | |
crates/web/src/pages.rs +74 −7
| @@ -46,13 +46,13 @@ pub(crate) fn build_chrome( | |||
| 46 | 46 | drop(assets); | |
| 47 | 47 | ||
| 48 | 48 | let chrome = Chrome { | |
| 49 | - | instance_name: state.config.instance.name.clone(), | |
| 49 | + | instance_name: state.instance_name(), | |
| 50 | 50 | user, | |
| 51 | 51 | active_theme: active, | |
| 52 | 52 | active_scheme: scheme, | |
| 53 | 53 | themes, | |
| 54 | 54 | allow_theme_choice: state.config.ui.allow_theme_choice, | |
| 55 | - | allow_registration: state.config.instance.allow_registration, | |
| 55 | + | allow_registration: state.allow_registration(), | |
| 56 | 56 | path, | |
| 57 | 57 | csrf, | |
| 58 | 58 | }; | |
| @@ -406,7 +406,7 @@ async fn create_repo_from_form( | |||
| 406 | 406 | } | |
| 407 | 407 | }; | |
| 408 | 408 | let visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| { | |
| 409 | - | model::Visibility::from_token(state.config.instance.default_visibility.as_token()) | |
| 409 | + | model::Visibility::from_token(&state.default_visibility_token()) | |
| 410 | 410 | .unwrap_or(model::Visibility::Private) | |
| 411 | 411 | }); | |
| 412 | 412 | let description = { | |
| @@ -459,7 +459,7 @@ async fn create_repo_from_form( | |||
| 459 | 459 | ||
| 460 | 460 | /// Render the new-repository form with an optional error and prefilled name. | |
| 461 | 461 | fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup { | |
| 462 | - | let default_vis = state.config.instance.default_visibility.as_token(); | |
| 462 | + | let default_vis = state.default_visibility_token(); | |
| 463 | 463 | let vis_option = |value: &str, label: &str| { | |
| 464 | 464 | html! { option value=(value) selected[value == default_vis] { (label) } } | |
| 465 | 465 | }; | |
| @@ -654,6 +654,9 @@ pub struct RegisterForm { | |||
| 654 | 654 | /// reCAPTCHA solved token (populated by the reCAPTCHA widget). | |
| 655 | 655 | #[serde(default, rename = "g-recaptcha-response")] | |
| 656 | 656 | grecaptcha_response: String, | |
| 657 | + | /// A sign-up invite token, when registering via an invite link. | |
| 658 | + | #[serde(default)] | |
| 659 | + | invite: String, | |
| 657 | 660 | /// CSRF token. | |
| 658 | 661 | #[serde(rename = "_csrf")] | |
| 659 | 662 | csrf: String, | |
| @@ -674,7 +677,7 @@ pub async fn register_form( | |||
| 674 | 677 | jar: CookieJar, | |
| 675 | 678 | uri: Uri, | |
| 676 | 679 | ) -> Response { | |
| 677 | - | if !state.config.instance.allow_registration { | |
| 680 | + | if !state.allow_registration() { | |
| 678 | 681 | return AppError::NotFound.into_response(); | |
| 679 | 682 | } | |
| 680 | 683 | if user.is_some() { | |
| @@ -687,6 +690,42 @@ pub async fn register_form( | |||
| 687 | 690 | "", | |
| 688 | 691 | "", | |
| 689 | 692 | captcha_view(&state.config).as_ref(), | |
| 693 | + | None, | |
| 694 | + | ); | |
| 695 | + | (jar, page(&chrome, "Sign up", body)).into_response() | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | /// `GET /register/{token}` — the sign-up form reached via an admin invite link. | |
| 699 | + | /// Works even when self-registration is disabled, provided the token is valid. | |
| 700 | + | pub async fn register_invite_form( | |
| 701 | + | State(state): State<AppState>, | |
| 702 | + | MaybeUser(user): MaybeUser, | |
| 703 | + | jar: CookieJar, | |
| 704 | + | uri: Uri, | |
| 705 | + | Path(token): Path<String>, | |
| 706 | + | ) -> Response { | |
| 707 | + | if user.is_some() { | |
| 708 | + | return Redirect::to("/").into_response(); | |
| 709 | + | } | |
| 710 | + | let hash = auth::session_id(&token); | |
| 711 | + | match state.store.signup_invite_valid(&hash).await { | |
| 712 | + | Ok(Some(_)) => {} | |
| 713 | + | Ok(None) => { | |
| 714 | + | return AppError::BadRequest( | |
| 715 | + | "That invite link is invalid or has been used.".to_string(), | |
| 716 | + | ) | |
| 717 | + | .into_response(); | |
| 718 | + | } | |
| 719 | + | Err(err) => return AppError::from(err).into_response(), | |
| 720 | + | } | |
| 721 | + | let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); | |
| 722 | + | let body = register_body( | |
| 723 | + | &chrome.csrf, | |
| 724 | + | None, | |
| 725 | + | "", | |
| 726 | + | "", | |
| 727 | + | captcha_view(&state.config).as_ref(), | |
| 728 | + | Some(&token), | |
| 690 | 729 | ); | |
| 691 | 730 | (jar, page(&chrome, "Sign up", body)).into_response() | |
| 692 | 731 | } | |
| @@ -699,16 +738,23 @@ fn register_body( | |||
| 699 | 738 | username: &str, | |
| 700 | 739 | email: &str, | |
| 701 | 740 | captcha: Option<&(config::CaptchaProvider, String)>, | |
| 741 | + | invite: Option<&str>, | |
| 702 | 742 | ) -> Markup { | |
| 703 | 743 | html! { | |
| 704 | 744 | div class="auth-wrap" { | |
| 705 | 745 | div class="auth-card" { | |
| 706 | 746 | h1 { "Sign up" } | |
| 747 | + | @if invite.is_some() { | |
| 748 | + | div class="notice notice-success" { "You've been invited to create an account." } | |
| 749 | + | } | |
| 707 | 750 | @if let Some(err) = error { | |
| 708 | 751 | div class="notice notice-error" { (err) } | |
| 709 | 752 | } | |
| 710 | 753 | form method="post" action="/register" { | |
| 711 | 754 | input type="hidden" name="_csrf" value=(csrf); | |
| 755 | + | @if let Some(token) = invite { | |
| 756 | + | input type="hidden" name="invite" value=(token); | |
| 757 | + | } | |
| 712 | 758 | label for="username" { "Username" } | |
| 713 | 759 | input type="text" id="username" name="username" value=(username) | |
| 714 | 760 | autocomplete="username" required; | |
| @@ -736,13 +782,21 @@ fn register_body( | |||
| 736 | 782 | } | |
| 737 | 783 | ||
| 738 | 784 | /// `POST /register` — create an account and sign in. | |
| 785 | + | #[allow(clippy::too_many_lines)] // Validation, invite redemption, and session setup. | |
| 739 | 786 | pub async fn register_submit( | |
| 740 | 787 | State(state): State<AppState>, | |
| 741 | 788 | jar: CookieJar, | |
| 742 | 789 | headers: HeaderMap, | |
| 743 | 790 | Form(form): Form<RegisterForm>, | |
| 744 | 791 | ) -> Response { | |
| 745 | - | if !state.config.instance.allow_registration { | |
| 792 | + | // Registration is allowed either globally or via a valid invite token. | |
| 793 | + | let invite_hash = | |
| 794 | + | (!form.invite.trim().is_empty()).then(|| auth::session_id(form.invite.trim())); | |
| 795 | + | let invite_ok = match &invite_hash { | |
| 796 | + | Some(h) => matches!(state.store.signup_invite_valid(h).await, Ok(Some(_))), | |
| 797 | + | None => false, | |
| 798 | + | }; | |
| 799 | + | if !state.allow_registration() && !invite_ok { | |
| 746 | 800 | return AppError::NotFound.into_response(); | |
| 747 | 801 | } | |
| 748 | 802 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| @@ -750,11 +804,19 @@ pub async fn register_submit( | |||
| 750 | 804 | } | |
| 751 | 805 | let username = form.username.trim().to_string(); | |
| 752 | 806 | let email = form.email.trim().to_string(); | |
| 807 | + | let invite_field = (!form.invite.trim().is_empty()).then_some(form.invite.trim()); | |
| 753 | 808 | ||
| 754 | 809 | let captcha = captcha_view(&state.config); | |
| 755 | 810 | let reject = |msg: &str, username: &str, email: &str| { | |
| 756 | 811 | let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string()); | |
| 757 | - | let body = register_body(&chrome.csrf, Some(msg), username, email, captcha.as_ref()); | |
| 812 | + | let body = register_body( | |
| 813 | + | &chrome.csrf, | |
| 814 | + | Some(msg), | |
| 815 | + | username, | |
| 816 | + | email, | |
| 817 | + | captcha.as_ref(), | |
| 818 | + | invite_field, | |
| 819 | + | ); | |
| 758 | 820 | ( | |
| 759 | 821 | axum::http::StatusCode::BAD_REQUEST, | |
| 760 | 822 | jar, | |
| @@ -829,6 +891,11 @@ pub async fn register_submit( | |||
| 829 | 891 | Err(err) => return AppError::from(err).into_response(), | |
| 830 | 892 | }; | |
| 831 | 893 | ||
| 894 | + | // Consume the invite (if any) now that the account exists. | |
| 895 | + | if let Some(h) = &invite_hash { | |
| 896 | + | let _ = state.store.redeem_signup_invite(h, &user.id).await; | |
| 897 | + | } | |
| 898 | + | ||
| 832 | 899 | // Send a verification email for the new primary address (best-effort). | |
| 833 | 900 | if let Ok(emails) = state.store.emails_by_user(&user.id).await | |
| 834 | 901 | && let Some(primary) = emails.into_iter().find(|e| e.is_primary) | |
crates/web/src/repo.rs +3 −13
| @@ -139,7 +139,7 @@ pub(crate) async fn resolve( | |||
| 139 | 139 | viewer_id.as_ref(), | |
| 140 | 140 | &repo, | |
| 141 | 141 | collaborator, | |
| 142 | - | state.config.instance.allow_anonymous, | |
| 142 | + | state.allow_anonymous(), | |
| 143 | 143 | ); | |
| 144 | 144 | if access == auth::Access::None { | |
| 145 | 145 | return Err(AppError::NotFound); | |
| @@ -1277,12 +1277,7 @@ async fn require_admin_repo( | |||
| 1277 | 1277 | .and_then(|p| auth::Permission::parse(&p)), | |
| 1278 | 1278 | None => None, | |
| 1279 | 1279 | }; | |
| 1280 | - | let access = auth::access( | |
| 1281 | - | viewer_id.as_ref(), | |
| 1282 | - | &repo, | |
| 1283 | - | collab, | |
| 1284 | - | state.config.instance.allow_anonymous, | |
| 1285 | - | ); | |
| 1280 | + | let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous()); | |
| 1286 | 1281 | if access == auth::Access::Admin { | |
| 1287 | 1282 | Ok(repo) | |
| 1288 | 1283 | } else { | |
| @@ -2044,12 +2039,7 @@ async fn visible_repos( | |||
| 2044 | 2039 | .and_then(|p| auth::Permission::parse(&p)), | |
| 2045 | 2040 | None => None, | |
| 2046 | 2041 | }; | |
| 2047 | - | let access = auth::access( | |
| 2048 | - | viewer_id.as_ref(), | |
| 2049 | - | &repo, | |
| 2050 | - | collab, | |
| 2051 | - | state.config.instance.allow_anonymous, | |
| 2052 | - | ); | |
| 2042 | + | let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous()); | |
| 2053 | 2043 | if access >= auth::Access::Read { | |
| 2054 | 2044 | out.push(repo); | |
| 2055 | 2045 | } | |
crates/web/src/search.rs +1 −1
| @@ -511,7 +511,7 @@ async fn can_read(state: &AppState, repo: &Repo, viewer: Option<&User>) -> AppRe | |||
| 511 | 511 | viewer_id.as_ref(), | |
| 512 | 512 | repo, | |
| 513 | 513 | collaborator, | |
| 514 | - | state.config.instance.allow_anonymous, | |
| 514 | + | state.allow_anonymous(), | |
| 515 | 515 | ); | |
| 516 | 516 | Ok(access >= auth::Access::Read) | |
| 517 | 517 | } | |
crates/web/src/session.rs +22 −0
| @@ -74,6 +74,28 @@ impl FromRequestParts<AppState> for RequireUser { | |||
| 74 | 74 | } | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | /// A signed-in **administrator**. Non-admins get a 404 (the admin area does not | |
| 78 | + | /// acknowledge its existence to them); anonymous requests are redirected to login. | |
| 79 | + | pub struct RequireAdmin(pub User); | |
| 80 | + | ||
| 81 | + | impl FromRequestParts<AppState> for RequireAdmin { | |
| 82 | + | type Rejection = Response; | |
| 83 | + | ||
| 84 | + | async fn from_request_parts( | |
| 85 | + | parts: &mut Parts, | |
| 86 | + | state: &AppState, | |
| 87 | + | ) -> Result<Self, Self::Rejection> { | |
| 88 | + | let MaybeUser(user) = MaybeUser::from_request_parts(parts, state) | |
| 89 | + | .await | |
| 90 | + | .unwrap_or(MaybeUser(None)); | |
| 91 | + | match user { | |
| 92 | + | Some(user) if user.is_admin => Ok(Self(user)), | |
| 93 | + | Some(_) => Err(crate::error::AppError::NotFound.into_response()), | |
| 94 | + | None => Err(Redirect::to("/login").into_response()), | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 77 | 99 | /// Ensure the CSRF cookie exists, returning the (possibly updated) jar and the | |
| 78 | 100 | /// token to embed in forms and htmx headers. A stable token avoids invalidating | |
| 79 | 101 | /// forms opened in other tabs. | |
crates/web/src/tests.rs +49 −0
| @@ -60,6 +60,55 @@ fn get(uri: &str) -> Request<Body> { | |||
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | #[tokio::test] | |
| 63 | + | async fn admin_dashboard_gating_and_settings_override() { | |
| 64 | + | let (state, app) = app().await; | |
| 65 | + | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | |
| 66 | + | let cookie = login(&app).await; | |
| 67 | + | let token = cookie | |
| 68 | + | .split("fabrica_csrf=") | |
| 69 | + | .nth(1) | |
| 70 | + | .and_then(|s| s.split(';').next()) | |
| 71 | + | .unwrap() | |
| 72 | + | .to_string(); | |
| 73 | + | ||
| 74 | + | // Non-admin: the admin area does not acknowledge itself (404). | |
| 75 | + | let req = Request::builder() | |
| 76 | + | .uri("/admin") | |
| 77 | + | .header(header::COOKIE, cookie.clone()) | |
| 78 | + | .body(Body::empty()) | |
| 79 | + | .unwrap(); | |
| 80 | + | assert_eq!( | |
| 81 | + | app.clone().oneshot(req).await.unwrap().status(), | |
| 82 | + | StatusCode::NOT_FOUND | |
| 83 | + | ); | |
| 84 | + | ||
| 85 | + | // Promote and retry (the session re-reads the user each request). | |
| 86 | + | state.store.set_admin(&ada.id, true).await.unwrap(); | |
| 87 | + | let req = Request::builder() | |
| 88 | + | .uri("/admin") | |
| 89 | + | .header(header::COOKIE, cookie.clone()) | |
| 90 | + | .body(Body::empty()) | |
| 91 | + | .unwrap(); | |
| 92 | + | let res = app.clone().oneshot(req).await.unwrap(); | |
| 93 | + | assert_eq!(res.status(), StatusCode::OK); | |
| 94 | + | assert!(body_string(res).await.contains("Overview")); | |
| 95 | + | ||
| 96 | + | // Override the instance name via the settings tab. | |
| 97 | + | let req = Request::builder() | |
| 98 | + | .method("POST") | |
| 99 | + | .uri("/admin/settings") | |
| 100 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 101 | + | .header(header::COOKIE, cookie) | |
| 102 | + | .body(Body::from(format!( | |
| 103 | + | "name=Renamed&description=&default_visibility=private&_csrf={token}" | |
| 104 | + | ))) | |
| 105 | + | .unwrap(); | |
| 106 | + | let res = app.oneshot(req).await.unwrap(); | |
| 107 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER); | |
| 108 | + | assert_eq!(state.instance_name(), "Renamed", "override took effect"); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | #[tokio::test] | |
| 63 | 112 | async fn home_renders_the_shell() { | |
| 64 | 113 | let (_state, app) = app().await; | |
| 65 | 114 | let res = app.oneshot(get("/")).await.unwrap(); | |
migrations/postgres/0012_admin.sql +18 −0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | -- Admin dashboard: open sign-up invites (independent of a pre-created account) | |
| 2 | + | -- and instance settings that override the config file. | |
| 3 | + | CREATE TABLE signup_invites ( | |
| 4 | + | id TEXT PRIMARY KEY, | |
| 5 | + | token_hash TEXT NOT NULL, | |
| 6 | + | note TEXT, | |
| 7 | + | expires_at BIGINT, | |
| 8 | + | used_at BIGINT, | |
| 9 | + | used_by TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 10 | + | created_by TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 11 | + | created_at BIGINT NOT NULL | |
| 12 | + | ); | |
| 13 | + | CREATE UNIQUE INDEX idx_signup_invites_token ON signup_invites (token_hash); | |
| 14 | + | ||
| 15 | + | CREATE TABLE instance_settings ( | |
| 16 | + | key TEXT PRIMARY KEY, | |
| 17 | + | value TEXT NOT NULL | |
| 18 | + | ); | |
migrations/sqlite/0012_admin.sql +18 −0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | -- Admin dashboard: open sign-up invites (independent of a pre-created account) | |
| 2 | + | -- and instance settings that override the config file. | |
| 3 | + | CREATE TABLE signup_invites ( | |
| 4 | + | id TEXT PRIMARY KEY, | |
| 5 | + | token_hash TEXT NOT NULL, | |
| 6 | + | note TEXT, | |
| 7 | + | expires_at BIGINT, | |
| 8 | + | used_at BIGINT, | |
| 9 | + | used_by TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 10 | + | created_by TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 11 | + | created_at BIGINT NOT NULL | |
| 12 | + | ); | |
| 13 | + | CREATE UNIQUE INDEX idx_signup_invites_token ON signup_invites (token_hash); | |
| 14 | + | ||
| 15 | + | CREATE TABLE instance_settings ( | |
| 16 | + | key TEXT PRIMARY KEY, | |
| 17 | + | value TEXT NOT NULL | |
| 18 | + | ); | |
src/serve.rs +2 −0
| @@ -68,6 +68,8 @@ pub fn run(req: ServeRequest) -> anyhow::Result<ExitCode> { | |||
| 68 | 68 | }); | |
| 69 | 69 | ||
| 70 | 70 | let state = web::AppState::build(config, store, secret)?; | |
| 71 | + | // Load admin config overrides from the database before serving. | |
| 72 | + | state.reload_settings().await; | |
| 71 | 73 | let result = web::serve(state, addr).await; | |
| 72 | 74 | ssh_task.abort(); | |
| 73 | 75 | result?; | |