fabrica

hanna/fabrica

34811 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! 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, Query, 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/// `show_invites` hides the Invites tab when self-registration is on (invites
28/// are only useful when registration is otherwise closed).
29#[allow(clippy::needless_pass_by_value)] // `content` is embedded once.
30fn admin_shell(active: &str, content: Markup, show_invites: bool) -> Markup {
31 let tab = |href: &str, key: &str, label: &str| {
32 html! { a href=(href) aria-current=[(active == key).then_some("page")] { (label) } }
33 };
34 html! {
35 div class="settings-layout" {
36 aside class="settings-nav" {
37 h1 { "Admin" }
38 nav {
39 (tab("/admin", "overview", "Overview"))
40 (tab("/admin/users", "users", "Users"))
41 (tab("/admin/repos", "repos", "Repositories"))
42 (tab("/admin/groups", "groups", "Groups"))
43 @if show_invites { (tab("/admin/invites", "invites", "Invites")) }
44 (tab("/admin/settings", "settings", "Settings"))
45 }
46 }
47 div class="settings-content" { (content) }
48 }
49 }
50}
51
52/// Render an admin page with the shell and chrome.
53fn render(
54 state: &AppState,
55 jar: CookieJar,
56 user: User,
57 uri: &Uri,
58 active: &str,
59 content: Markup,
60) -> Response {
61 let show_invites = !state.allow_registration();
62 let (jar, chrome) = build_chrome(state, jar, Some(user), uri.path().to_string());
63 (
64 jar,
65 page(&chrome, "Admin", admin_shell(active, content, show_invites)),
66 )
67 .into_response()
68}
69
70/// Rows shown per page in the admin list views.
71const PAGE_SIZE: i64 = 20;
72
73/// The `?page=N` (1-based) query parameter shared by the paginated lists.
74#[derive(Debug, Default, Deserialize)]
75pub struct Pager {
76 #[serde(default)]
77 page: Option<i64>,
78}
79
80impl Pager {
81 /// The clamped 1-based page number and its row offset.
82 fn resolve(&self) -> (i64, i64) {
83 let page = self.page.unwrap_or(1).max(1);
84 (page, (page - 1) * PAGE_SIZE)
85 }
86}
87
88/// Previous/next controls under a list, shown only when `total` spans more than
89/// one page. `base` is the list path (e.g. `/admin/users`).
90fn pager_nav(base: &str, page: i64, total: i64) -> Markup {
91 let pages = ((total + PAGE_SIZE - 1) / PAGE_SIZE).max(1);
92 html! {
93 @if pages > 1 {
94 nav class="pagination" aria-label="Pagination" {
95 @if page > 1 {
96 a class="btn" href=(format!("{base}?page={}", page - 1)) { "← Previous" }
97 } @else {
98 span class="btn disabled" aria-disabled="true" { "← Previous" }
99 }
100 span class="muted pagination-page" { "Page " (page) " of " (pages) }
101 @if page < pages {
102 a class="btn" href=(format!("{base}?page={}", page + 1)) { "Next →" }
103 } @else {
104 span class="btn disabled" aria-disabled="true" { "Next →" }
105 }
106 }
107 }
108 }
109}
110
111// ---- Overview ----
112
113/// `GET /admin` — instance statistics.
114pub async fn overview(
115 State(state): State<AppState>,
116 RequireAdmin(user): RequireAdmin,
117 jar: CookieJar,
118 uri: Uri,
119) -> AppResult<Response> {
120 let counts = state.store.admin_counts().await?;
121 let tracked = state.store.total_repo_bytes().await.unwrap_or(0);
122 // Disk usage of the repo tree (best-effort) and the SQLite DB file, if any.
123 let repo_dir = state.config.storage.repo_dir.clone();
124 let disk = tokio::task::spawn_blocking(move || dir_size(&repo_dir))
125 .await
126 .unwrap_or(0);
127 let db_bytes = sqlite_db_size(&state.config.database.url);
128
129 let stat = |label: &str, value: String| {
130 html! { div class="stat-card" { div class="stat-value" { (value) } div class="stat-label muted" { (label) } } }
131 };
132 let content = html! {
133 h2 { "Overview" }
134 div class="stat-grid" {
135 (stat("Users", counts.users.to_string()))
136 (stat("Administrators", counts.admins.to_string()))
137 (stat("Disabled", counts.disabled_users.to_string()))
138 (stat("Repositories", counts.repos.to_string()))
139 (stat("Groups", counts.groups.to_string()))
140 (stat("Issues (open)", format!("{} ({})", counts.issues, counts.open_issues)))
141 (stat("Pull requests", counts.pulls.to_string()))
142 (stat("SSH/GPG keys", counts.keys.to_string()))
143 (stat("Active API tokens", counts.tokens.to_string()))
144 (stat("Live sessions", counts.sessions.to_string()))
145 (stat("Repository storage", fmt_bytes(disk.max(u64::try_from(tracked).unwrap_or(0)))))
146 @if let Some(db) = db_bytes { (stat("Database size", fmt_bytes(db))) }
147 }
148 };
149 Ok(render(&state, jar, user, &uri, "overview", content))
150}
151
152// ---- Users ----
153
154/// `GET /admin/users` — the user list and a create form.
155pub async fn users(
156 State(state): State<AppState>,
157 RequireAdmin(user): RequireAdmin,
158 jar: CookieJar,
159 uri: Uri,
160 Query(pager): Query<Pager>,
161) -> AppResult<Response> {
162 let (page, offset) = pager.resolve();
163 let total = state.store.admin_counts().await?.users;
164 let all = state.store.list_users_paged(PAGE_SIZE, offset).await?;
165 // Verified state of each shown user's primary email, so the row can badge it
166 // and only offer "Verify" when it is not yet verified.
167 let mut verified: std::collections::HashSet<String> = std::collections::HashSet::new();
168 for u in &all {
169 if state
170 .store
171 .primary_email_verified(&u.id)
172 .await
173 .unwrap_or(false)
174 {
175 verified.insert(u.id.clone());
176 }
177 }
178 // The password picker offers every account, not just the current page.
179 let everyone = state.store.list_users().await?;
180 let (_, chrome) = build_chrome(
181 &state,
182 jar.clone(),
183 Some(user.clone()),
184 uri.path().to_string(),
185 );
186 let csrf = chrome.csrf.clone();
187 let content = html! {
188 h2 { "Users" }
189 section class="listing" {
190 div class="card" {
191 ul class="entry-list admin-list" {
192 @for u in &all {
193 li class="entry-row" {
194 div class="entry-row-body" {
195 span class="entry-row-title" {
196 a href={ "/" (u.username) } { (u.username) }
197 @if u.is_admin { " " span class="badge" { "admin" } }
198 @if verified.contains(&u.id) { " " span class="badge badge-verified" { "verified" } }
199 @if u.disabled_at.is_some() { " " span class="badge badge-private" { "disabled" } }
200 }
201 div class="entry-row-meta muted" { (u.email) }
202 }
203 div class="entry-row-actions admin-actions" {
204 @if !verified.contains(&u.id) {
205 (post_button(&format!("/admin/users/{}/verify", u.id), &csrf, "Verify", "btn"))
206 }
207 (toggle_button(&format!("/admin/users/{}/admin", u.id), &csrf, "admin", !u.is_admin,
208 if u.is_admin { "Revoke admin" } else { "Make admin" }))
209 (toggle_button(&format!("/admin/users/{}/disable", u.id), &csrf, "disabled", u.disabled_at.is_none(),
210 if u.disabled_at.is_some() { "Enable" } else { "Disable" }))
211 @if u.id != user.id {
212 (post_button(&format!("/admin/users/{}/delete", u.id), &csrf, "Delete", "btn btn-danger"))
213 }
214 }
215 }
216 }
217 }
218 (pager_nav("/admin/users", page, total))
219 }
220 }
221 section class="listing" {
222 h3 { "Create a user" }
223 div class="card" {
224 form method="post" action="/admin/users" {
225 input type="hidden" name="_csrf" value=(csrf);
226 div class="admin-form-row" {
227 input type="text" name="username" placeholder="username" required;
228 input type="email" name="email" placeholder="email" required;
229 input type="password" name="password" placeholder="password (optional)";
230 }
231 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)." }
232 div class="admin-form-actions" {
233 button class="btn btn-primary inline-btn" type="submit" { "Create user" }
234 label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" }
235 }
236 }
237 }
238 }
239 section class="listing" {
240 h3 { "Reset a password" }
241 div class="card" {
242 form method="post" action="/admin/users/password" {
243 input type="hidden" name="_csrf" value=(csrf);
244 div class="admin-form-row admin-form-row-last" {
245 select name="user_id" aria-label="User" {
246 @for u in &everyone { option value=(u.id) { (u.username) } }
247 }
248 input type="password" name="password" placeholder="new password" required minlength="8";
249 button class="btn inline-btn" type="submit" { "Set password" }
250 }
251 }
252 }
253 }
254 };
255 Ok(render(&state, jar, user, &uri, "users", content))
256}
257
258/// Create-user form.
259#[derive(Debug, Deserialize)]
260pub struct CreateUserForm {
261 #[serde(default)]
262 username: String,
263 #[serde(default)]
264 email: String,
265 #[serde(default)]
266 password: String,
267 #[serde(default)]
268 admin: Option<String>,
269 #[serde(rename = "_csrf")]
270 csrf: String,
271}
272
273/// `POST /admin/users` — create a user.
274pub async fn user_create(
275 State(state): State<AppState>,
276 RequireAdmin(_): RequireAdmin,
277 jar: CookieJar,
278 headers: HeaderMap,
279 Form(form): Form<CreateUserForm>,
280) -> Response {
281 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
282 return AppError::Forbidden.into_response();
283 }
284 let hash = if form.password.trim().is_empty() {
285 None
286 } else {
287 match hash_password(&state, form.password.trim()) {
288 Ok(h) => Some(h),
289 Err(e) => return e.into_response(),
290 }
291 };
292 match state
293 .store
294 .create_user(store::NewUser {
295 username: form.username.trim().to_string(),
296 email: form.email.trim().to_string(),
297 display_name: None,
298 password_hash: hash,
299 is_admin: form.admin.is_some(),
300 must_change_password: false,
301 })
302 .await
303 {
304 Ok(_) => Redirect::to("/admin/users").into_response(),
305 Err(store::StoreError::Conflict { .. }) => {
306 AppError::BadRequest("That username or email is already taken.".to_string())
307 .into_response()
308 }
309 Err(store::StoreError::Name(_)) => {
310 AppError::BadRequest("Invalid username.".to_string()).into_response()
311 }
312 Err(err) => AppError::from(err).into_response(),
313 }
314}
315
316/// A `{disabled|admin}=true/false` toggle form.
317#[derive(Debug, Deserialize)]
318pub struct ToggleForm {
319 #[serde(default)]
320 disabled: String,
321 #[serde(default)]
322 admin: String,
323 #[serde(rename = "_csrf")]
324 csrf: String,
325}
326
327/// A single-field password form.
328#[derive(Debug, Deserialize)]
329pub struct PasswordForm {
330 #[serde(default)]
331 user_id: String,
332 #[serde(default)]
333 password: String,
334 #[serde(rename = "_csrf")]
335 csrf: String,
336}
337
338/// A bare CSRF form for parameter-less actions.
339#[derive(Debug, Deserialize)]
340pub struct BareForm {
341 #[serde(rename = "_csrf")]
342 csrf: String,
343}
344
345/// `POST /admin/users/{id}/verify`.
346pub async fn user_verify(
347 State(state): State<AppState>,
348 RequireAdmin(_): RequireAdmin,
349 jar: CookieJar,
350 headers: HeaderMap,
351 Path(id): Path<String>,
352 Form(form): Form<BareForm>,
353) -> Response {
354 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
355 return AppError::Forbidden.into_response();
356 }
357 let _ = state.store.verify_primary_email(&id).await;
358 Redirect::to("/admin/users").into_response()
359}
360
361/// `POST /admin/users/{id}/disable`.
362pub async fn user_disable(
363 State(state): State<AppState>,
364 RequireAdmin(_): RequireAdmin,
365 jar: CookieJar,
366 headers: HeaderMap,
367 Path(id): Path<String>,
368 Form(form): Form<ToggleForm>,
369) -> Response {
370 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
371 return AppError::Forbidden.into_response();
372 }
373 let _ = state.store.set_disabled(&id, form.disabled == "true").await;
374 Redirect::to("/admin/users").into_response()
375}
376
377/// `POST /admin/users/{id}/admin`.
378pub async fn user_toggle_admin(
379 State(state): State<AppState>,
380 RequireAdmin(_): RequireAdmin,
381 jar: CookieJar,
382 headers: HeaderMap,
383 Path(id): Path<String>,
384 Form(form): Form<ToggleForm>,
385) -> Response {
386 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
387 return AppError::Forbidden.into_response();
388 }
389 let _ = state.store.set_admin(&id, form.admin == "true").await;
390 Redirect::to("/admin/users").into_response()
391}
392
393/// `POST /admin/users/{id}/delete`.
394pub async fn user_delete(
395 State(state): State<AppState>,
396 RequireAdmin(admin): RequireAdmin,
397 jar: CookieJar,
398 headers: HeaderMap,
399 Path(id): Path<String>,
400 Form(form): Form<BareForm>,
401) -> Response {
402 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
403 return AppError::Forbidden.into_response();
404 }
405 if id == admin.id {
406 return AppError::BadRequest("You cannot delete your own account here.".to_string())
407 .into_response();
408 }
409 let _ = state.store.delete_user(&id).await;
410 Redirect::to("/admin/users").into_response()
411}
412
413/// `POST /admin/users/password` — set a user's password.
414pub async fn user_set_password(
415 State(state): State<AppState>,
416 RequireAdmin(_): RequireAdmin,
417 jar: CookieJar,
418 headers: HeaderMap,
419 Form(form): Form<PasswordForm>,
420) -> Response {
421 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
422 return AppError::Forbidden.into_response();
423 }
424 let hash = match hash_password(&state, form.password.trim()) {
425 Ok(h) => h,
426 Err(e) => return e.into_response(),
427 };
428 let _ = state.store.set_password(&form.user_id, Some(&hash)).await;
429 Redirect::to("/admin/users").into_response()
430}
431
432// ---- Repositories ----
433
434/// `GET /admin/repos`.
435pub async fn repos(
436 State(state): State<AppState>,
437 RequireAdmin(user): RequireAdmin,
438 jar: CookieJar,
439 uri: Uri,
440 Query(pager): Query<Pager>,
441) -> AppResult<Response> {
442 let (page, offset) = pager.resolve();
443 let total = state.store.admin_counts().await?.repos;
444 let repos = state.store.list_repos_paged(PAGE_SIZE, offset).await?;
445 // Resolve owner names for links.
446 let mut owners = std::collections::HashMap::new();
447 for r in &repos {
448 if !owners.contains_key(&r.owner_id)
449 && let Ok(Some(u)) = state.store.user_by_id(&r.owner_id).await
450 {
451 owners.insert(r.owner_id.clone(), u.username);
452 }
453 }
454 let (_, chrome) = build_chrome(
455 &state,
456 jar.clone(),
457 Some(user.clone()),
458 uri.path().to_string(),
459 );
460 let csrf = chrome.csrf.clone();
461 let content = html! {
462 h2 { "Repositories" }
463 section class="listing" {
464 div class="card" {
465 @if repos.is_empty() { p class="muted empty-note" { "No repositories." } }
466 ul class="entry-list admin-list" {
467 @for r in &repos {
468 @let owner = owners.get(&r.owner_id).cloned().unwrap_or_else(|| r.owner_id.clone());
469 li class="entry-row" {
470 div class="entry-row-body" {
471 span class="entry-row-title" { a href={ "/" (owner) "/" (r.path) } { (owner) "/" (r.path) } }
472 div class="entry-row-meta muted" { (r.visibility.as_str()) " · " (r.object_format) }
473 }
474 div class="entry-row-actions admin-actions" {
475 a class="btn" href={ "/" (owner) "/" (r.path) "/-/settings" } { "Manage" }
476 (post_button(&format!("/admin/repos/{}/delete", r.id), &csrf, "Delete", "btn btn-danger"))
477 }
478 }
479 }
480 }
481 (pager_nav("/admin/repos", page, total))
482 }
483 }
484 };
485 Ok(render(&state, jar, user, &uri, "repos", content))
486}
487
488/// `POST /admin/repos/{id}/delete`.
489pub async fn repo_delete(
490 State(state): State<AppState>,
491 RequireAdmin(_): RequireAdmin,
492 jar: CookieJar,
493 headers: HeaderMap,
494 Path(id): Path<String>,
495 Form(form): Form<BareForm>,
496) -> Response {
497 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
498 return AppError::Forbidden.into_response();
499 }
500 let _ = state.store.delete_repo(&id).await;
501 Redirect::to("/admin/repos").into_response()
502}
503
504// ---- Groups ----
505
506/// `GET /admin/groups`.
507pub async fn groups(
508 State(state): State<AppState>,
509 RequireAdmin(user): RequireAdmin,
510 jar: CookieJar,
511 uri: Uri,
512 Query(pager): Query<Pager>,
513) -> AppResult<Response> {
514 let (page, offset) = pager.resolve();
515 let total = state.store.admin_counts().await?.groups;
516 let groups = state.store.list_groups_paged(PAGE_SIZE, offset).await?;
517 let mut owners = std::collections::HashMap::new();
518 for g in &groups {
519 if !owners.contains_key(&g.owner_id)
520 && let Ok(Some(u)) = state.store.user_by_id(&g.owner_id).await
521 {
522 owners.insert(g.owner_id.clone(), u.username);
523 }
524 }
525 let (_, chrome) = build_chrome(
526 &state,
527 jar.clone(),
528 Some(user.clone()),
529 uri.path().to_string(),
530 );
531 let csrf = chrome.csrf.clone();
532 let content = html! {
533 h2 { "Groups" }
534 section class="listing" {
535 div class="card" {
536 @if groups.is_empty() { p class="muted empty-note" { "No groups." } }
537 ul class="entry-list admin-list" {
538 @for g in &groups {
539 @let owner = owners.get(&g.owner_id).cloned().unwrap_or_else(|| g.owner_id.clone());
540 li class="entry-row" {
541 div class="entry-row-body" {
542 span class="entry-row-title" { a href={ "/" (owner) "/" (g.path) } { (owner) "/" (g.path) } }
543 }
544 div class="entry-row-actions admin-actions" {
545 (post_button(&format!("/admin/groups/{}/delete", g.id), &csrf, "Delete", "btn btn-danger"))
546 }
547 }
548 }
549 }
550 (pager_nav("/admin/groups", page, total))
551 p class="muted field-hint" { "Deleting a group ungroups its repositories (they are not deleted)." }
552 }
553 }
554 };
555 Ok(render(&state, jar, user, &uri, "groups", content))
556}
557
558/// `POST /admin/groups/{id}/delete`.
559pub async fn group_delete(
560 State(state): State<AppState>,
561 RequireAdmin(_): RequireAdmin,
562 jar: CookieJar,
563 headers: HeaderMap,
564 Path(id): Path<String>,
565 Form(form): Form<BareForm>,
566) -> Response {
567 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
568 return AppError::Forbidden.into_response();
569 }
570 let _ = state.store.delete_group(&id).await;
571 Redirect::to("/admin/groups").into_response()
572}
573
574// ---- Invites ----
575
576/// `GET /admin/invites`.
577pub async fn invites(
578 State(state): State<AppState>,
579 RequireAdmin(user): RequireAdmin,
580 jar: CookieJar,
581 uri: Uri,
582 Query(pager): Query<Pager>,
583) -> AppResult<Response> {
584 // Invites only matter when self-registration is closed; otherwise the page
585 // is hidden and its route redirects to the overview.
586 if state.allow_registration() {
587 return Ok(Redirect::to("/admin").into_response());
588 }
589 let (page, offset) = pager.resolve();
590 let all = state.store.list_signup_invites().await?;
591 let total = i64::try_from(all.len()).unwrap_or(i64::MAX);
592 let invites: Vec<_> = all
593 .into_iter()
594 .skip(usize::try_from(offset).unwrap_or(0))
595 .take(usize::try_from(PAGE_SIZE).unwrap_or(20))
596 .collect();
597 let base = state.config.instance.url.trim_end_matches('/').to_string();
598 let (_, chrome) = build_chrome(
599 &state,
600 jar.clone(),
601 Some(user.clone()),
602 uri.path().to_string(),
603 );
604 let csrf = chrome.csrf.clone();
605 let content = html! {
606 h2 { "Sign-up invites" }
607 p class="muted field-hint" {
608 "Invite links let people register even when self-registration is disabled. The link is shown once at creation."
609 }
610 section class="listing" {
611 div class="card" {
612 @if invites.is_empty() { p class="muted empty-note" { "No invites." } }
613 ul class="entry-list admin-list" {
614 @for iv in &invites {
615 li class="entry-row" {
616 div class="entry-row-body" {
617 span class="entry-row-title" {
618 @if iv.used_at.is_some() {
619 span class="badge badge-used" { "used" }
620 } @else {
621 span class="badge badge-unused" { "unused" }
622 }
623 " " (iv.note.clone().unwrap_or_else(|| "(no note)".to_string()))
624 }
625 @if let Some(exp) = iv.expires_at {
626 div class="entry-row-meta muted" { "expires " (crate::repo::fmt_date(exp)) }
627 }
628 }
629 div class="entry-row-actions admin-actions" {
630 (post_button(&format!("/admin/invites/{}/delete", iv.id), &csrf, "Delete", "btn btn-danger"))
631 }
632 }
633 }
634 }
635 (pager_nav("/admin/invites", page, total))
636 form method="post" action="/admin/invites" {
637 input type="hidden" name="_csrf" value=(csrf);
638 div class="admin-form-row admin-form-row-last" {
639 input type="text" name="note" placeholder="note (optional, e.g. who it's for)";
640 button class="btn btn-primary inline-btn" type="submit" { "Create invite" }
641 }
642 }
643 }
644 }
645 @if let Some(link) = jar.get("fabrica_flash_invite").map(|c| c.value().to_string()) {
646 div class="card notice notice-success" {
647 p { strong { "Invite link created." } " Copy it now:" }
648 pre class="token-secret" { code { (base) "/register/" (link) } }
649 }
650 }
651 };
652 // Clear the flash cookie after showing it once. The removal must carry the
653 // same path the cookie was set with ("/admin/invites"), or the browser keeps
654 // it and the link reappears on reload.
655 let jar = jar.remove(
656 axum_extra::extract::cookie::Cookie::build("fabrica_flash_invite")
657 .path("/admin/invites")
658 .build(),
659 );
660 Ok(render(&state, jar, user, &uri, "invites", content))
661}
662
663/// Create-invite form.
664#[derive(Debug, Deserialize)]
665pub struct CreateInviteForm {
666 #[serde(default)]
667 note: String,
668 #[serde(rename = "_csrf")]
669 csrf: String,
670}
671
672/// `POST /admin/invites` — create a sign-up invite and show its link once.
673pub async fn invite_create(
674 State(state): State<AppState>,
675 RequireAdmin(admin): RequireAdmin,
676 jar: CookieJar,
677 headers: HeaderMap,
678 Form(form): Form<CreateInviteForm>,
679) -> Response {
680 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
681 return AppError::Forbidden.into_response();
682 }
683 if state.allow_registration() {
684 return Redirect::to("/admin").into_response();
685 }
686 let token = auth::new_session_token();
687 let note = form.note.trim();
688 let created = state
689 .store
690 .create_signup_invite(store::NewSignupInvite {
691 token_hash: token.id,
692 note: (!note.is_empty()).then(|| note.to_string()),
693 expires_at: None,
694 created_by: admin.id,
695 })
696 .await;
697 if created.is_err() {
698 return AppError::internal("could not create invite").into_response();
699 }
700 // Stash the raw token in a short-lived cookie so the list page can show it once.
701 let flash = axum_extra::extract::cookie::Cookie::build(("fabrica_flash_invite", token.cookie))
702 .http_only(true)
703 .same_site(axum_extra::extract::cookie::SameSite::Lax)
704 .secure(state.config.auth.cookie_secure)
705 .path("/admin/invites")
706 .build();
707 (jar.add(flash), Redirect::to("/admin/invites")).into_response()
708}
709
710/// `POST /admin/invites/{id}/delete`.
711pub async fn invite_delete(
712 State(state): State<AppState>,
713 RequireAdmin(_): RequireAdmin,
714 jar: CookieJar,
715 headers: HeaderMap,
716 Path(id): Path<String>,
717 Form(form): Form<BareForm>,
718) -> Response {
719 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
720 return AppError::Forbidden.into_response();
721 }
722 let _ = state.store.delete_signup_invite(&id).await;
723 Redirect::to("/admin/invites").into_response()
724}
725
726// ---- Settings ----
727
728/// `GET /admin/settings` — override config values.
729pub async fn settings(
730 State(state): State<AppState>,
731 RequireAdmin(user): RequireAdmin,
732 jar: CookieJar,
733 uri: Uri,
734) -> AppResult<Response> {
735 let (_, chrome) = build_chrome(
736 &state,
737 jar.clone(),
738 Some(user.clone()),
739 uri.path().to_string(),
740 );
741 let csrf = chrome.csrf.clone();
742 let name = state.instance_name();
743 let desc = state.setting_str(
744 setting_keys::DESCRIPTION,
745 &state.config.instance.description,
746 );
747 let reg = state.allow_registration();
748 let anon = state.allow_anonymous();
749 let vis = state.default_visibility_token();
750 let vis_opt = |v: &str, label: &str| html! { option value=(v) selected[v == vis] { (label) } };
751 let content = html! {
752 h2 { "Instance settings" }
753 p class="muted field-hint" {
754 "These override the config file. Leaving a field at its default keeps the config value; each row can be reset."
755 }
756 div class="card" {
757 form method="post" action="/admin/settings" class="stacked-form" {
758 input type="hidden" name="_csrf" value=(csrf);
759 div class="form-field" {
760 label for="s-name" { "Instance name" }
761 input type="text" id="s-name" name="name" value=(name);
762 }
763 div class="form-field" {
764 label for="s-desc" { "Description" }
765 input type="text" id="s-desc" name="description" value=(desc);
766 }
767 div class="form-field" {
768 label for="s-vis" { "Default repository visibility" }
769 select id="s-vis" name="default_visibility" {
770 (vis_opt("public", "Public"))
771 (vis_opt("internal", "Internal"))
772 (vis_opt("private", "Private"))
773 }
774 }
775 div class="admin-form-actions" {
776 label class="checkbox" { input type="checkbox" name="allow_registration" value="1" checked[reg]; " Allow self-registration" }
777 label class="checkbox" { input type="checkbox" name="allow_anonymous" value="1" checked[anon]; " Allow anonymous browse/clone" }
778 button class="btn btn-primary inline-btn" type="submit" { "Save settings" }
779 }
780 }
781 }
782 };
783 Ok(render(&state, jar, user, &uri, "settings", content))
784}
785
786/// Settings form.
787#[derive(Debug, Deserialize)]
788pub struct SettingsForm {
789 #[serde(default)]
790 name: String,
791 #[serde(default)]
792 description: String,
793 #[serde(default)]
794 default_visibility: String,
795 #[serde(default)]
796 allow_registration: Option<String>,
797 #[serde(default)]
798 allow_anonymous: Option<String>,
799 #[serde(rename = "_csrf")]
800 csrf: String,
801}
802
803/// `POST /admin/settings` — persist overrides (a blank name/description clears it).
804pub async fn settings_save(
805 State(state): State<AppState>,
806 RequireAdmin(_): RequireAdmin,
807 jar: CookieJar,
808 headers: HeaderMap,
809 Form(form): Form<SettingsForm>,
810) -> Response {
811 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
812 return AppError::Forbidden.into_response();
813 }
814 // A blank text field reverts to the config value (delete the override).
815 for (key, value) in [
816 (setting_keys::NAME, form.name.trim()),
817 (setting_keys::DESCRIPTION, form.description.trim()),
818 ] {
819 if value.is_empty() {
820 let _ = state.store.delete_setting(key).await;
821 } else {
822 let _ = state.store.set_setting(key, value).await;
823 }
824 }
825 if matches!(
826 form.default_visibility.as_str(),
827 "public" | "internal" | "private"
828 ) {
829 let _ = state
830 .store
831 .set_setting(setting_keys::DEFAULT_VISIBILITY, &form.default_visibility)
832 .await;
833 }
834 let _ = state
835 .store
836 .set_setting(
837 setting_keys::ALLOW_REGISTRATION,
838 if form.allow_registration.is_some() {
839 "true"
840 } else {
841 "false"
842 },
843 )
844 .await;
845 let _ = state
846 .store
847 .set_setting(
848 setting_keys::ALLOW_ANONYMOUS,
849 if form.allow_anonymous.is_some() {
850 "true"
851 } else {
852 "false"
853 },
854 )
855 .await;
856 state.reload_settings().await;
857 Redirect::to("/admin/settings").into_response()
858}
859
860// ---- Helpers ----
861
862/// A small POST form rendering a single button.
863fn post_button(action: &str, csrf: &str, label: &str, class: &str) -> Markup {
864 html! {
865 form method="post" action=(action) class="inline-form" {
866 input type="hidden" name="_csrf" value=(csrf);
867 button class=(class) type="submit" { (label) }
868 }
869 }
870}
871
872/// A POST form that sets `field` to `value` (for enable/disable, admin toggles).
873fn toggle_button(action: &str, csrf: &str, field: &str, value: bool, label: &str) -> Markup {
874 html! {
875 form method="post" action=(action) class="inline-form" {
876 input type="hidden" name="_csrf" value=(csrf);
877 input type="hidden" name=(field) value=(if value { "true" } else { "false" });
878 button class="btn" type="submit" { (label) }
879 }
880 }
881}
882
883/// Hash a password with the instance Argon2 cost.
884fn hash_password(state: &AppState, password: &str) -> Result<String, AppError> {
885 let params = auth::HashParams {
886 m_cost: state.config.auth.argon2_m_cost,
887 t_cost: state.config.auth.argon2_t_cost,
888 p_cost: state.config.auth.argon2_p_cost,
889 };
890 auth::hash_password(password, params).map_err(|_| AppError::internal("hash failed"))
891}
892
893/// Recursively sum the sizes of regular files under `dir` (best-effort).
894pub(crate) fn dir_size(dir: &FsPath) -> u64 {
895 let mut total = 0u64;
896 let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()];
897 while let Some(path) = stack.pop() {
898 let Ok(entries) = std::fs::read_dir(&path) else {
899 continue;
900 };
901 for entry in entries.flatten() {
902 let Ok(ft) = entry.file_type() else { continue };
903 if ft.is_dir() {
904 stack.push(entry.path());
905 } else if ft.is_file()
906 && let Ok(meta) = entry.metadata()
907 {
908 total += meta.len();
909 }
910 }
911 }
912 total
913}
914
915/// The on-disk size of a `sqlite:` database file, if the URL names one.
916fn sqlite_db_size(url: &str) -> Option<u64> {
917 let path = url
918 .strip_prefix("sqlite://")
919 .or_else(|| url.strip_prefix("sqlite:"))?;
920 let path = path.split('?').next().unwrap_or(path);
921 if path.is_empty() || path == ":memory:" {
922 return None;
923 }
924 std::fs::metadata(path).ok().map(|m| m.len())
925}
926
927/// Format a byte count as a human-readable string.
928fn fmt_bytes(bytes: u64) -> String {
929 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
930 #[allow(clippy::cast_precision_loss)]
931 let mut value = bytes as f64;
932 let mut unit = 0;
933 while value >= 1024.0 && unit < UNITS.len() - 1 {
934 value /= 1024.0;
935 unit += 1;
936 }
937 if unit == 0 {
938 format!("{bytes} B")
939 } else {
940 format!("{value:.1} {}", UNITS[unit])
941 }
942}