fabrica

hanna/fabrica

feat(web): admin dashboard

2abf849 · hanna committed on 2026-07-26

Add a tabbed admin dashboard (gated by RequireAdmin; non-admins 404):

- Overview: instance stats — user/repo/group/issue/PR/key/token/session
  counts, repository storage (disk-walk, floored at tracked size), and
  the SQLite DB file size.
- Users: list, create, verify, enable/disable, grant/revoke admin,
  delete, and reset passwords.
- Repositories and Groups: list and delete.
- Invites: create sign-up URLs (raw token shown once) that permit
  registration even when self-registration is disabled; list and revoke.
  /register/{token} validates and redeems the invite.
- Settings: override config-file values (name, description, allow
  registration/anonymous, default visibility). Overrides live in
  instance_settings and are cached in AppState (DB override > config >
  default), refreshed at boot and after each change. A blank field
  clears the override.

Store gains admin_counts/total_repo_bytes, the signup_invites and
instance_settings tables (migration 0012), list_groups, and
set_admin. allow_anonymous/instance_name/default_visibility reads
across the web crate now route through the override layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
18 files changed · +1486 −24UnifiedSplit
assets/base.css +35 −0
@@ -973,6 +973,41 @@ body.drawer-open .drawer-backdrop {
973 margin-left: auto;973 margin-left: auto;
974}974}
975975
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/* Settings: a left tab rail and the active tab's content. */1011/* Settings: a left tab rail and the active tab's content. */
977.settings-layout {1012.settings-layout {
978 display: flex;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
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}
crates/store/src/groups.rs +16 −0
@@ -128,6 +128,22 @@ impl Store {
128 .map_err(Into::into)128 .map_err(Into::into)
129 }129 }
130130
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 /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in147 /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in
132 /// the group have their `group_id` set null by the schema, so they survive as148 /// the group have their `group_id` set null by the schema, so they survive as
133 /// ungrouped repos. Returns `true` if a row was deleted.149 /// ungrouped repos. Returns `true` if a row was deleted.
crates/store/src/lib.rs +2 −0
@@ -25,6 +25,7 @@
25//! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching25//! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching
26//! set is embedded at build time and selected at runtime by [`Store::migrate`].26//! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
28mod admin;
28mod groups;29mod groups;
29mod invites;30mod invites;
30mod issues;31mod issues;
@@ -43,6 +44,7 @@ use sqlx::Row;
43use sqlx::any::{AnyPoolOptions, AnyRow};44use sqlx::any::{AnyPoolOptions, AnyRow};
44use sqlx::migrate::Migrator;45use sqlx::migrate::Migrator;
4546
47pub use crate::admin::{AdminCounts, NewSignupInvite, SignupInvite};
46pub use crate::invites::NewInvite;48pub use crate::invites::NewInvite;
47pub use crate::issues::StateCounts;49pub use crate::issues::StateCounts;
48pub use crate::keys::NewKey;50pub use crate::keys::NewKey;
crates/store/src/users.rs +16 −0
@@ -571,6 +571,22 @@ impl Store {
571 Ok(updated > 0)571 Ok(updated > 0)
572 }572 }
573573
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 /// Delete a user by id, cascading to their repositories, keys, tokens, and590 /// Delete a user by id, cascading to their repositories, keys, tokens, and
575 /// sessions (via `ON DELETE CASCADE`). The caller is responsible for any591 /// sessions (via `ON DELETE CASCADE`). The caller is responsible for any
576 /// policy that should *prevent* the delete (e.g. refusing when repos exist592 /// 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
9use std::path::{Path as FsPath, PathBuf};
10
11use axum::Form;
12use axum::extract::{Path, State};
13use axum::http::{HeaderMap, Uri};
14use axum::response::{IntoResponse, Redirect, Response};
15use axum_extra::extract::cookie::CookieJar;
16use maud::{Markup, html};
17use model::User;
18use serde::Deserialize;
19
20use crate::error::{AppError, AppResult};
21use crate::layout::page;
22use crate::pages::build_chrome;
23use crate::session::{RequireAdmin, verify_csrf};
24use 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.
28fn 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.
51fn 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.
66pub 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.
107pub 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)]
188pub 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.
202pub 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)]
246pub 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)]
257pub 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)]
268pub struct BareForm {
269 #[serde(rename = "_csrf")]
270 csrf: String,
271}
272
273/// `POST /admin/users/{id}/verify`.
274pub 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`.
290pub 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`.
306pub 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`.
322pub 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.
342pub 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`.
363pub 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`.
413pub 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`.
431pub 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`.
479pub 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`.
497pub 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)]
561pub 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.
569pub 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`.
604pub 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.
622pub 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)]
673pub 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).
689pub 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.
748fn 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).
758fn 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.
769fn 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).
779fn 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.
801fn 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.
813fn 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 viewer_id.as_ref(),227 viewer_id.as_ref(),
228 &repo,228 &repo,
229 collaborator,229 collaborator,
230 state.config.instance.allow_anonymous,230 state.allow_anonymous(),
231 );231 );
232 if access >= auth::Access::Read {232 if access >= auth::Access::Read {
233 Ok(repo)233 Ok(repo)
crates/web/src/issues.rs +1 −1
@@ -633,7 +633,7 @@ pub(crate) async fn access_for(
633 viewer_id.as_ref(),633 viewer_id.as_ref(),
634 repo,634 repo,
635 collab,635 collab,
636 state.config.instance.allow_anonymous,636 state.allow_anonymous(),
637 ))637 ))
638}638}
639639
crates/web/src/layout.rs +4 −1
@@ -129,7 +129,10 @@ fn drawer(chrome: &Chrome) -> Markup {
129 hr;129 hr;
130 }130 }
131 nav {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 a href="/settings" { (icon(Icon::Settings)) span { "Settings" } }136 a href="/settings" { (icon(Icon::Settings)) span { "Settings" } }
134 form method="post" action="/logout" {137 form method="post" action="/logout" {
135 input type="hidden" name="_csrf" value=(chrome.csrf);138 input type="hidden" name="_csrf" value=(chrome.csrf);
crates/web/src/lib.rs +100 −0
@@ -11,6 +11,7 @@
11//! without JavaScript; htmx only enhances.11//! without JavaScript; htmx only enhances.
1212
13mod activity;13mod activity;
14mod admin;
14mod assets;15mod assets;
15mod avatar;16mod avatar;
16mod diff;17mod diff;
@@ -78,6 +79,23 @@ pub struct AppState {
78 pub signatures: git::SignatureCache,79 pub signatures: git::SignatureCache,
79 /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it.80 /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it.
80 assets: Arc<RwLock<Assets>>,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.
88pub(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}
82100
83impl AppState {101impl AppState {
@@ -96,9 +114,69 @@ impl AppState {
96 secret: Arc::new(secret),114 secret: Arc::new(secret),
97 signatures: git::SignatureCache::new(4096),115 signatures: git::SignatureCache::new(4096),
98 assets: Arc::new(RwLock::new(assets)),116 assets: Arc::new(RwLock::new(assets)),
117 settings: Arc::new(RwLock::new(std::collections::HashMap::new())),
99 })118 })
100 }119 }
101120
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 /// A read guard over the asset registry, recovering from a poisoned lock.180 /// A read guard over the asset registry, recovering from a poisoned lock.
103 fn assets(&self) -> RwLockReadGuard<'_, Assets> {181 fn assets(&self) -> RwLockReadGuard<'_, Assets> {
104 self.assets.read().unwrap_or_else(PoisonError::into_inner)182 self.assets.read().unwrap_or_else(PoisonError::into_inner)
@@ -147,6 +225,7 @@ pub fn build_router(state: AppState) -> Router {
147 "/register",225 "/register",
148 get(pages::register_form).post(pages::register_submit),226 get(pages::register_form).post(pages::register_submit),
149 )227 )
228 .route("/register/{token}", get(pages::register_invite_form))
150 .route("/logout", post(pages::logout))229 .route("/logout", post(pages::logout))
151 .route(230 .route(
152 "/invite/{token}",231 "/invite/{token}",
@@ -178,6 +257,27 @@ pub fn build_router(state: AppState) -> Router {
178 .route("/settings/keys/delete", post(pages::key_delete))257 .route("/settings/keys/delete", post(pages::key_delete))
179 .route("/settings/keys/verify", post(pages::key_verify))258 .route("/settings/keys/verify", post(pages::key_verify))
180 .route("/settings/avatar", post(avatar::upload))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 .route("/settings/theme", get(pages::set_theme))281 .route("/settings/theme", get(pages::set_theme))
182 .route("/avatar/{username}", get(avatar::show))282 .route("/avatar/{username}", get(avatar::show))
183 // Repo administration (owner/admin only; enforced in the handlers). A283 // 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 drop(assets);46 drop(assets);
4747
48 let chrome = Chrome {48 let chrome = Chrome {
49 instance_name: state.config.instance.name.clone(),49 instance_name: state.instance_name(),
50 user,50 user,
51 active_theme: active,51 active_theme: active,
52 active_scheme: scheme,52 active_scheme: scheme,
53 themes,53 themes,
54 allow_theme_choice: state.config.ui.allow_theme_choice,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 path,56 path,
57 csrf,57 csrf,
58 };58 };
@@ -406,7 +406,7 @@ async fn create_repo_from_form(
406 }406 }
407 };407 };
408 let visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| {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 .unwrap_or(model::Visibility::Private)410 .unwrap_or(model::Visibility::Private)
411 });411 });
412 let description = {412 let description = {
@@ -459,7 +459,7 @@ async fn create_repo_from_form(
459459
460/// Render the new-repository form with an optional error and prefilled name.460/// Render the new-repository form with an optional error and prefilled name.
461fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup {461fn 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 let vis_option = |value: &str, label: &str| {463 let vis_option = |value: &str, label: &str| {
464 html! { option value=(value) selected[value == default_vis] { (label) } }464 html! { option value=(value) selected[value == default_vis] { (label) } }
465 };465 };
@@ -654,6 +654,9 @@ pub struct RegisterForm {
654 /// reCAPTCHA solved token (populated by the reCAPTCHA widget).654 /// reCAPTCHA solved token (populated by the reCAPTCHA widget).
655 #[serde(default, rename = "g-recaptcha-response")]655 #[serde(default, rename = "g-recaptcha-response")]
656 grecaptcha_response: String,656 grecaptcha_response: String,
657 /// A sign-up invite token, when registering via an invite link.
658 #[serde(default)]
659 invite: String,
657 /// CSRF token.660 /// CSRF token.
658 #[serde(rename = "_csrf")]661 #[serde(rename = "_csrf")]
659 csrf: String,662 csrf: String,
@@ -674,7 +677,7 @@ pub async fn register_form(
674 jar: CookieJar,677 jar: CookieJar,
675 uri: Uri,678 uri: Uri,
676) -> Response {679) -> Response {
677 if !state.config.instance.allow_registration {680 if !state.allow_registration() {
678 return AppError::NotFound.into_response();681 return AppError::NotFound.into_response();
679 }682 }
680 if user.is_some() {683 if user.is_some() {
@@ -687,6 +690,42 @@ pub async fn register_form(
687 "",690 "",
688 "",691 "",
689 captcha_view(&state.config).as_ref(),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.
700pub 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 (jar, page(&chrome, "Sign up", body)).into_response()730 (jar, page(&chrome, "Sign up", body)).into_response()
692}731}
@@ -699,16 +738,23 @@ fn register_body(
699 username: &str,738 username: &str,
700 email: &str,739 email: &str,
701 captcha: Option<&(config::CaptchaProvider, String)>,740 captcha: Option<&(config::CaptchaProvider, String)>,
741 invite: Option<&str>,
702) -> Markup {742) -> Markup {
703 html! {743 html! {
704 div class="auth-wrap" {744 div class="auth-wrap" {
705 div class="auth-card" {745 div class="auth-card" {
706 h1 { "Sign up" }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 @if let Some(err) = error {750 @if let Some(err) = error {
708 div class="notice notice-error" { (err) }751 div class="notice notice-error" { (err) }
709 }752 }
710 form method="post" action="/register" {753 form method="post" action="/register" {
711 input type="hidden" name="_csrf" value=(csrf);754 input type="hidden" name="_csrf" value=(csrf);
755 @if let Some(token) = invite {
756 input type="hidden" name="invite" value=(token);
757 }
712 label for="username" { "Username" }758 label for="username" { "Username" }
713 input type="text" id="username" name="username" value=(username)759 input type="text" id="username" name="username" value=(username)
714 autocomplete="username" required;760 autocomplete="username" required;
@@ -736,13 +782,21 @@ fn register_body(
736}782}
737783
738/// `POST /register` — create an account and sign in.784/// `POST /register` — create an account and sign in.
785#[allow(clippy::too_many_lines)] // Validation, invite redemption, and session setup.
739pub async fn register_submit(786pub async fn register_submit(
740 State(state): State<AppState>,787 State(state): State<AppState>,
741 jar: CookieJar,788 jar: CookieJar,
742 headers: HeaderMap,789 headers: HeaderMap,
743 Form(form): Form<RegisterForm>,790 Form(form): Form<RegisterForm>,
744) -> Response {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 return AppError::NotFound.into_response();800 return AppError::NotFound.into_response();
747 }801 }
748 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {802 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
@@ -750,11 +804,19 @@ pub async fn register_submit(
750 }804 }
751 let username = form.username.trim().to_string();805 let username = form.username.trim().to_string();
752 let email = form.email.trim().to_string();806 let email = form.email.trim().to_string();
807 let invite_field = (!form.invite.trim().is_empty()).then_some(form.invite.trim());
753808
754 let captcha = captcha_view(&state.config);809 let captcha = captcha_view(&state.config);
755 let reject = |msg: &str, username: &str, email: &str| {810 let reject = |msg: &str, username: &str, email: &str| {
756 let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string());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 axum::http::StatusCode::BAD_REQUEST,821 axum::http::StatusCode::BAD_REQUEST,
760 jar,822 jar,
@@ -829,6 +891,11 @@ pub async fn register_submit(
829 Err(err) => return AppError::from(err).into_response(),891 Err(err) => return AppError::from(err).into_response(),
830 };892 };
831893
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 // Send a verification email for the new primary address (best-effort).899 // Send a verification email for the new primary address (best-effort).
833 if let Ok(emails) = state.store.emails_by_user(&user.id).await900 if let Ok(emails) = state.store.emails_by_user(&user.id).await
834 && let Some(primary) = emails.into_iter().find(|e| e.is_primary)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 viewer_id.as_ref(),139 viewer_id.as_ref(),
140 &repo,140 &repo,
141 collaborator,141 collaborator,
142 state.config.instance.allow_anonymous,142 state.allow_anonymous(),
143 );143 );
144 if access == auth::Access::None {144 if access == auth::Access::None {
145 return Err(AppError::NotFound);145 return Err(AppError::NotFound);
@@ -1277,12 +1277,7 @@ async fn require_admin_repo(
1277 .and_then(|p| auth::Permission::parse(&p)),1277 .and_then(|p| auth::Permission::parse(&p)),
1278 None => None,1278 None => None,
1279 };1279 };
1280 let access = auth::access(1280 let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
1281 viewer_id.as_ref(),
1282 &repo,
1283 collab,
1284 state.config.instance.allow_anonymous,
1285 );
1286 if access == auth::Access::Admin {1281 if access == auth::Access::Admin {
1287 Ok(repo)1282 Ok(repo)
1288 } else {1283 } else {
@@ -2044,12 +2039,7 @@ async fn visible_repos(
2044 .and_then(|p| auth::Permission::parse(&p)),2039 .and_then(|p| auth::Permission::parse(&p)),
2045 None => None,2040 None => None,
2046 };2041 };
2047 let access = auth::access(2042 let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
2048 viewer_id.as_ref(),
2049 &repo,
2050 collab,
2051 state.config.instance.allow_anonymous,
2052 );
2053 if access >= auth::Access::Read {2043 if access >= auth::Access::Read {
2054 out.push(repo);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 viewer_id.as_ref(),511 viewer_id.as_ref(),
512 repo,512 repo,
513 collaborator,513 collaborator,
514 state.config.instance.allow_anonymous,514 state.allow_anonymous(),
515 );515 );
516 Ok(access >= auth::Access::Read)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}
7676
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.
79pub struct RequireAdmin(pub User);
80
81impl 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/// Ensure the CSRF cookie exists, returning the (possibly updated) jar and the99/// Ensure the CSRF cookie exists, returning the (possibly updated) jar and the
78/// token to embed in forms and htmx headers. A stable token avoids invalidating100/// token to embed in forms and htmx headers. A stable token avoids invalidating
79/// forms opened in other tabs.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}
6161
62#[tokio::test]62#[tokio::test]
63async 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]
63async fn home_renders_the_shell() {112async fn home_renders_the_shell() {
64 let (_state, app) = app().await;113 let (_state, app) = app().await;
65 let res = app.oneshot(get("/")).await.unwrap();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.
3CREATE 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);
13CREATE UNIQUE INDEX idx_signup_invites_token ON signup_invites (token_hash);
14
15CREATE 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.
3CREATE 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);
13CREATE UNIQUE INDEX idx_signup_invites_token ON signup_invites (token_hash);
14
15CREATE 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 });
6969
70 let state = web::AppState::build(config, store, secret)?;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 let result = web::serve(state, addr).await;73 let result = web::serve(state, addr).await;
72 ssh_task.abort();74 ssh_task.abort();
73 result?;75 result?;