| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | use std::path::{Path as FsPath, PathBuf}; |
| 10 | |
| 11 | use axum::Form; |
| 12 | use axum::extract::{Path, Query, State}; |
| 13 | use axum::http::{HeaderMap, Uri}; |
| 14 | use axum::response::{IntoResponse, Redirect, Response}; |
| 15 | use axum_extra::extract::cookie::CookieJar; |
| 16 | use maud::{Markup, html}; |
| 17 | use model::User; |
| 18 | use serde::Deserialize; |
| 19 | |
| 20 | use crate::error::{AppError, AppResult}; |
| 21 | use crate::layout::page; |
| 22 | use crate::pages::build_chrome; |
| 23 | use crate::session::{RequireAdmin, verify_csrf}; |
| 24 | use crate::{AppState, setting_keys}; |
| 25 | |
| 26 | |
| 27 | |
| 28 | |
| 29 | #[allow(clippy::needless_pass_by_value)] |
| 30 | fn 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 | |
| 53 | fn 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 | |
| 71 | const PAGE_SIZE: i64 = 20; |
| 72 | |
| 73 | |
| 74 | #[derive(Debug, Default, Deserialize)] |
| 75 | pub struct Pager { |
| 76 | #[serde(default)] |
| 77 | page: Option<i64>, |
| 78 | } |
| 79 | |
| 80 | impl Pager { |
| 81 | |
| 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 | |
| 89 | |
| 90 | fn 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 | |
| 112 | |
| 113 | |
| 114 | pub 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 | |
| 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 | |
| 153 | |
| 154 | |
| 155 | pub 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 | |
| 166 | |
| 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 | |
| 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 | |
| 259 | #[derive(Debug, Deserialize)] |
| 260 | pub 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 | |
| 274 | pub 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 | |
| 317 | #[derive(Debug, Deserialize)] |
| 318 | pub struct ToggleForm { |
| 319 | #[serde(default)] |
| 320 | disabled: String, |
| 321 | #[serde(default)] |
| 322 | admin: String, |
| 323 | #[serde(rename = "_csrf")] |
| 324 | csrf: String, |
| 325 | } |
| 326 | |
| 327 | |
| 328 | #[derive(Debug, Deserialize)] |
| 329 | pub 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 | |
| 339 | #[derive(Debug, Deserialize)] |
| 340 | pub struct BareForm { |
| 341 | #[serde(rename = "_csrf")] |
| 342 | csrf: String, |
| 343 | } |
| 344 | |
| 345 | |
| 346 | pub 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 | |
| 362 | pub 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 | |
| 378 | pub 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 | |
| 394 | pub 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 | |
| 414 | pub 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 | |
| 433 | |
| 434 | |
| 435 | pub 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 | |
| 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 | |
| 489 | pub 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 | |
| 505 | |
| 506 | |
| 507 | pub 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 | |
| 559 | pub 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 | |
| 575 | |
| 576 | |
| 577 | pub 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 | |
| 585 | |
| 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 | |
| 653 | |
| 654 | |
| 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 | |
| 664 | #[derive(Debug, Deserialize)] |
| 665 | pub struct CreateInviteForm { |
| 666 | #[serde(default)] |
| 667 | note: String, |
| 668 | #[serde(rename = "_csrf")] |
| 669 | csrf: String, |
| 670 | } |
| 671 | |
| 672 | |
| 673 | pub 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 | |
| 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 | |
| 711 | pub 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 | |
| 727 | |
| 728 | |
| 729 | pub 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 | |
| 787 | #[derive(Debug, Deserialize)] |
| 788 | pub 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 | |
| 804 | pub 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 | |
| 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 | |
| 861 | |
| 862 | |
| 863 | fn 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 | |
| 873 | fn 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 | |
| 884 | fn 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 | |
| 894 | pub(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 | |
| 916 | fn 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 | |
| 928 | fn 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 | } |