| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use super::settings::csrf; |
| 5 | | use super::{filters, Error, Page, Shell}; |
| 6 | | use crate::auth::{self, Viewer}; |
| 7 | | use crate::db::accounts; |
| 8 | | use crate::Shared; |
| 9 | | use askama::Template; |
| 10 | | use axum::extract::{Form, State}; |
| 11 | | use serde::Deserialize; |
| 12 | |
|
| 13 | |
|
| 14 | |
|
| 15 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 16 | | pub enum AdminSection { |
| 17 | | Overview, |
| 18 | | Accounts, |
| 19 | | Registration, |
| 20 | | Organisations, |
| 21 | | SignIn, |
| 22 | | SecurityContact, |
| 23 | | Mail, |
| 24 | | Runners, |
| 25 | | Images, |
| 26 | | RunnerSettings, |
| 27 | | RunnerUpdates, |
| 28 | | Insights, |
| 29 | | Variables, |
| 30 | | Secrets, |
| 31 | | Assistants, |
| 32 | | Diffusion, |
| 33 | | Storage, |
| 34 | | Backups, |
| 35 | | Audit, |
| 36 | | } |
| 37 | |
|
| 38 | |
|
| 39 | | const ADMIN_GROUPS: &[(&str, &[AdminSection])] = &[ |
| 40 | | ("Overview", &[AdminSection::Overview]), |
| 41 | | ("People", &[AdminSection::Accounts, AdminSection::Registration, AdminSection::Organisations]), |
| 42 | | ("Sign-in", &[AdminSection::SignIn, AdminSection::SecurityContact]), |
| 43 | | ("Mail", &[AdminSection::Mail]), |
| 44 | | ( |
| 45 | | "Flow", |
| 46 | | &[ |
| 47 | | AdminSection::Runners, |
| 48 | | AdminSection::Images, |
| 49 | | AdminSection::RunnerSettings, |
| 50 | | AdminSection::RunnerUpdates, |
| 51 | | AdminSection::Insights, |
| 52 | | AdminSection::Variables, |
| 53 | | AdminSection::Secrets, |
| 54 | | ], |
| 55 | | ), |
| 56 | | ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]), |
| 57 | | ("Data", &[AdminSection::Storage, AdminSection::Backups]), |
| 58 | | ("Audit", &[AdminSection::Audit]), |
| 59 | | ]; |
| 60 | |
|
| 61 | |
|
| 62 | 176× | pub fn admin_groups() -> &'static [(&'static str, &'static [AdminSection])] { |
| 63 | 176× | ADMIN_GROUPS |
| 64 | 176× | } |
| 65 | |
|
| 66 | | impl AdminSection { |
| 67 | | |
| 68 | 3512× | pub fn href(self) -> &'static str { |
| 69 | 3512× | match self { |
| 70 | 174× | AdminSection::Overview => "/admin", |
| 71 | 195× | AdminSection::Accounts => "/admin/accounts", |
| 72 | 193× | AdminSection::Registration => "/admin/registration", |
| 73 | 192× | AdminSection::Organisations => "/admin/organisations", |
| 74 | 191× | AdminSection::SignIn => "/admin/signin", |
| 75 | 189× | AdminSection::SecurityContact => "/admin/security", |
| 76 | 187× | AdminSection::Mail => "/admin/mail", |
| 77 | 185× | AdminSection::Runners => "/admin/runners", |
| 78 | 185× | AdminSection::Images => "/admin/runners/images", |
| 79 | 185× | AdminSection::RunnerSettings => "/admin/runners/settings", |
| 80 | 185× | AdminSection::RunnerUpdates => "/admin/runners/updates", |
| 81 | 185× | AdminSection::Insights => "/admin/insights", |
| 82 | 185× | AdminSection::Variables => "/admin/variables", |
| 83 | 183× | AdminSection::Secrets => "/admin/secrets", |
| 84 | 182× | AdminSection::Assistants => "/admin/assistants", |
| 85 | 179× | AdminSection::Diffusion => "/admin/diffusion", |
| 86 | 179× | AdminSection::Storage => "/admin/storage", |
| 87 | 179× | AdminSection::Backups => "/admin/backups", |
| 88 | 179× | AdminSection::Audit => "/admin/audit", |
| 89 | | } |
| 90 | 3512× | } |
| 91 | |
|
| 92 | | |
| 93 | | |
| 94 | 2945× | pub fn slug(self) -> &'static str { |
| 95 | 2945× | match self { |
| 96 | 155× | AdminSection::Overview => "overview", |
| 97 | 155× | AdminSection::Accounts => "accounts", |
| 98 | 155× | AdminSection::Registration => "registration", |
| 99 | 155× | AdminSection::Organisations => "organisations", |
| 100 | 155× | AdminSection::SignIn => "signin", |
| 101 | 155× | AdminSection::SecurityContact => "security", |
| 102 | 155× | AdminSection::Mail => "mail", |
| 103 | 155× | AdminSection::Runners => "runners", |
| 104 | 155× | AdminSection::Images => "images", |
| 105 | 155× | AdminSection::RunnerSettings => "runner-settings", |
| 106 | 155× | AdminSection::RunnerUpdates => "runner-updates", |
| 107 | 155× | AdminSection::Insights => "insights", |
| 108 | 155× | AdminSection::Variables => "variables", |
| 109 | 155× | AdminSection::Secrets => "secrets", |
| 110 | 155× | AdminSection::Assistants => "assistants", |
| 111 | 155× | AdminSection::Diffusion => "diffusion", |
| 112 | 155× | AdminSection::Storage => "storage", |
| 113 | 155× | AdminSection::Backups => "backups", |
| 114 | 155× | AdminSection::Audit => "audit", |
| 115 | | } |
| 116 | 2945× | } |
| 117 | |
|
| 118 | 3398× | pub fn title(self) -> &'static str { |
| 119 | 3398× | match self { |
| 120 | 155× | AdminSection::Overview => "Overview", |
| 121 | 186× | AdminSection::Accounts => "Accounts", |
| 122 | 187× | AdminSection::Registration => "Registration & invitations", |
| 123 | 177× | AdminSection::Organisations => "Organisations", |
| 124 | 184× | AdminSection::SignIn => "Sign in with", |
| 125 | 182× | AdminSection::SecurityContact => "Security contact", |
| 126 | 189× | AdminSection::Mail => "Mail", |
| 127 | 176× | AdminSection::Runners => "Runners", |
| 128 | 176× | AdminSection::Images => "Images", |
| 129 | 176× | AdminSection::RunnerSettings => "Runner settings", |
| 130 | 176× | AdminSection::RunnerUpdates => "Runner updates", |
| 131 | 176× | AdminSection::Insights => "Insights", |
| 132 | 179× | AdminSection::Variables => "Variables", |
| 133 | 179× | AdminSection::Secrets => "Secrets", |
| 134 | 193× | AdminSection::Assistants => "AI assistants", |
| 135 | 176× | AdminSection::Diffusion => "Diffusion", |
| 136 | 176× | AdminSection::Storage => "Storage & maintenance", |
| 137 | 176× | AdminSection::Backups => "Backups", |
| 138 | 179× | AdminSection::Audit => "Audit log", |
| 139 | | } |
| 140 | 3398× | } |
| 141 | |
|
| 142 | | |
| 143 | 19× | fn of_path(path: &str) -> Option<AdminSection> { |
| 144 | 189× | ADMIN_GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.href() == path) |
| 145 | 19× | } |
| 146 | | } |
| 147 | |
|
| 148 | | #[derive(Template)] |
| 149 | | #[template(path = "admin.html")] |
| 150 | | pub struct AdminPage { |
| 151 | | shell: Shell, |
| 152 | | |
| 153 | | section: AdminSection, |
| 154 | | accounts: Vec<accounts::Account>, |
| 155 | | orgs: Vec<accounts::Organisation>, |
| 156 | | mode: String, |
| 157 | | invitations: Vec<(String, Option<i64>, i64)>, |
| 158 | | audit: Vec<accounts::AuditRow>, |
| 159 | | repos: usize, |
| 160 | | notice: Option<String>, |
| 161 | | |
| 162 | | |
| 163 | | |
| 164 | | mail_smtp: String, |
| 165 | | mail_from: String, |
| 166 | | mail_enabled: bool, |
| 167 | | |
| 168 | | mail: crate::mail::Choice, |
| 169 | | my_email: String, |
| 170 | | |
| 171 | | audit_older: Option<i64>, |
| 172 | | |
| 173 | | assistants: super::assistants::Panel, |
| 174 | | |
| 175 | | variables: super::variables::Panel, |
| 176 | | |
| 177 | | secrets: super::secrets::Panel, |
| 178 | | |
| 179 | | providers: Vec<super::signin::ProviderView>, |
| 180 | | |
| 181 | | security_contact: String, |
| 182 | | security_policy: String, |
| 183 | | |
| 184 | | security_email: String, |
| 185 | | security_page: String, |
| 186 | | security_default: &'static str, |
| 187 | | } |
| 188 | |
|
| 189 | 112× | pub(super) fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
| 190 | 112× | match viewer.account() { |
| 191 | 106× | Some(a) if a.is_admin => Ok(a.clone()), |
| 192 | 6× | _ => Err(Error::not_found(shell.clone())), |
| 193 | | } |
| 194 | 112× | } |
| 195 | |
|
| 196 | 56× | pub(super) async fn admin_page( |
| 197 | 56× | app: &Shared, |
| 198 | 56× | shell: Shell, |
| 199 | 56× | section: AdminSection, |
| 200 | 56× | notice: Option<String>, |
| 201 | 56× | ) -> Result<Page<AdminPage>, Error> { |
| 202 | 56× | admin_page_at(app, shell, section, notice, None).await |
| 203 | 56× | } |
| 204 | |
|
| 205 | |
|
| 206 | | const AUDIT_PAGE: i64 = 50; |
| 207 | |
|
| 208 | 96× | async fn admin_page_at( |
| 209 | 96× | app: &Shared, |
| 210 | 96× | shell: Shell, |
| 211 | 96× | section: AdminSection, |
| 212 | 96× | notice: Option<String>, |
| 213 | 96× | audit_before: Option<i64>, |
| 214 | 96× | ) -> Result<Page<AdminPage>, Error> { |
| 215 | 96× | let e = |e| Error::from_db(shell.clone(), e); |
| 216 | 96× | let accounts_list = accounts::accounts(&app.db).await.map_err(e)?; |
| 217 | 96× | let orgs = accounts::orgs(&app.db).await.map_err(e)?; |
| 218 | 96× | let mode = super::session::registration_mode(app).await; |
| 219 | 96× | let invitations = accounts::invitations(&app.db).await.map_err(e)?; |
| 220 | 96× | let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?; |
| 221 | 96× | let audit_older = if audit.len() as i64 > AUDIT_PAGE { |
| 222 | 12× | audit.truncate(AUDIT_PAGE as usize); |
| 223 | 12× | audit.last().map(|r| r.id) |
| 224 | | } else { |
| 225 | 84× | None |
| 226 | | }; |
| 227 | 96× | let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len(); |
| 228 | 96× | let mail = crate::mail::settings(app); |
| 229 | 96× | let my_email = match shell.user.as_deref() { |
| 230 | 96× | Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(), |
| 231 | 0× | None => String::new(), |
| 232 | | }; |
| 233 | 96× | let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?; |
| 234 | 96× | let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?; |
| 235 | 96× | let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?; |
| 236 | 96× | let providers = super::signin::provider_views(app).await.map_err(e)?; |
| 237 | 96× | let (security_contact, security_policy) = super::security_txt::settings(app).await; |
| 238 | 96× | let (security_email, security_page) = super::security_txt::page_settings(app).await; |
| 239 | | Ok(Page(AdminPage { |
| 240 | 96× | section, |
| 241 | 96× | providers, |
| 242 | 96× | security_contact, |
| 243 | 96× | security_policy, |
| 244 | 96× | security_email, |
| 245 | 96× | security_page, |
| 246 | | security_default: super::security_txt::DEFAULT_PAGE, |
| 247 | 96× | assistants, |
| 248 | 96× | variables, |
| 249 | 96× | secrets, |
| 250 | 96× | shell, |
| 251 | 96× | accounts: accounts_list, |
| 252 | 96× | orgs, |
| 253 | 96× | mode, |
| 254 | 96× | invitations, |
| 255 | 96× | audit, |
| 256 | 96× | repos, |
| 257 | 96× | notice, |
| 258 | 96× | mail: crate::mail::describe(&mail.smtp), |
| 259 | 96× | mail_smtp: crate::mail::masked(&mail.smtp), |
| 260 | 96× | mail_from: mail.from.clone(), |
| 261 | 96× | mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(), |
| 262 | 96× | my_email, |
| 263 | 96× | audit_older, |
| 264 | | })) |
| 265 | 96× | } |
| 266 | |
|
| 267 | | impl AdminPage { |
| 268 | 13× | fn mail_services(&self) -> &'static [(&'static str, &'static str)] { |
| 269 | 13× | crate::mail::SERVICES |
| 270 | 13× | } |
| 271 | | } |
| 272 | |
|
| 273 | | #[derive(Deserialize)] |
| 274 | | pub struct MailForm { |
| 275 | | #[serde(rename = "_csrf")] |
| 276 | | csrf_token: String, |
| 277 | | #[serde(default)] |
| 278 | | smtp: String, |
| 279 | | #[serde(default)] |
| 280 | | from: String, |
| 281 | | |
| 282 | | |
| 283 | | #[serde(default)] |
| 284 | | service: String, |
| 285 | | #[serde(default)] |
| 286 | | region: String, |
| 287 | | #[serde(default)] |
| 288 | | user: String, |
| 289 | | #[serde(default)] |
| 290 | | secret: String, |
| 291 | | #[serde(default)] |
| 292 | | host: String, |
| 293 | | #[serde(default)] |
| 294 | | port: String, |
| 295 | | #[serde(default)] |
| 296 | | security: String, |
| 297 | | } |
| 298 | |
|
| 299 | |
|
| 300 | |
|
| 301 | |
|
| 302 | 11× | pub async fn set_mail(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MailForm>) -> Result<Page<AdminPage>, Error> { |
| 303 | 11× | let shell = Shell::new(&app, "/admin", &viewer); |
| 304 | 11× | let me = require_admin(&viewer, &shell)?; |
| 305 | 10× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 306 | 10× | let current = crate::mail::settings(&app); |
| 307 | | |
| 308 | 10× | let smtp = match f.service.as_str() { |
| 309 | 10× | "off" => String::new(), |
| 310 | 9× | "" | "url" => { |
| 311 | 5× | let smtp = f.smtp.trim(); |
| 312 | 5× | if !current.smtp.is_empty() && smtp == crate::mail::masked(¤t.smtp) { |
| 313 | 1× | current.smtp.clone() |
| 314 | | } else { |
| 315 | 4× | smtp.to_string() |
| 316 | | } |
| 317 | | } |
| 318 | | _ => { |
| 319 | 4× | let asked = crate::mail::Asked { |
| 320 | 4× | service: f.service.clone(), |
| 321 | 4× | region: f.region.clone(), |
| 322 | 4× | user: f.user.clone(), |
| 323 | 4× | secret: f.secret.trim().to_string(), |
| 324 | 4× | host: f.host.clone(), |
| 325 | 4× | port: f.port.clone(), |
| 326 | 4× | security: f.security.clone(), |
| 327 | 4× | }; |
| 328 | 4× | match crate::mail::compose(&asked, ¤t.smtp) { |
| 329 | 3× | Ok(url) => url, |
| 330 | 1× | Err(why) => return admin_page(&app, shell, AdminSection::Mail, Some(why)).await, |
| 331 | | } |
| 332 | | } |
| 333 | | }; |
| 334 | 9× | let from = f.from.trim().to_string(); |
| 335 | 9× | if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) { |
| 336 | 1× | return admin_page( |
| 337 | 1× | &app, |
| 338 | 1× | shell, |
| 339 | 1× | AdminSection::Mail, |
| 340 | 1× | Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()), |
| 341 | 1× | ) |
| 342 | 1× | .await; |
| 343 | 8× | } |
| 344 | 8× | if !smtp.is_empty() && from.is_empty() { |
| 345 | 0× | return admin_page(&app, shell, AdminSection::Mail, Some("A From address is needed: OxygenHub <[email protected]>.".into())) |
| 346 | 0× | .await; |
| 347 | 8× | } |
| 348 | 8× | crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 349 | 8× | accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok(); |
| 350 | 8× | admin_page(&app, shell, AdminSection::Mail, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })) |
| 351 | 8× | .await |
| 352 | 11× | } |
| 353 | |
|
| 354 | |
|
| 355 | 1× | pub async fn test_mail( |
| 356 | 1× | State(app): State<Shared>, |
| 357 | 1× | viewer: Viewer, |
| 358 | 1× | Form(f): Form<super::session::CsrfForm>, |
| 359 | 1× | ) -> Result<Page<AdminPage>, Error> { |
| 360 | 1× | let shell = Shell::new(&app, "/admin", &viewer); |
| 361 | 1× | let me = require_admin(&viewer, &shell)?; |
| 362 | 1× | csrf(&viewer, &f.csrf, &shell)?; |
| 363 | 1× | if !crate::mail::enabled(&app) { |
| 364 | 0× | return admin_page(&app, shell, AdminSection::Mail, Some("Mail is off; save an SMTP URL and a From address first.".into())).await; |
| 365 | 1× | } |
| 366 | 1× | if me.email.is_empty() { |
| 367 | 0× | return admin_page(&app, shell, AdminSection::Mail, Some("Your account has no email address to send the test to.".into())).await; |
| 368 | 1× | } |
| 369 | 1× | let base = app.config.base_url().to_string(); |
| 370 | 1× | let notice = match crate::mail::send_letter( |
| 371 | 1× | &app, |
| 372 | 1× | me.name(), |
| 373 | 1× | &me.email, |
| 374 | 1× | "🧪 Test message", |
| 375 | 1× | "This is a test message from your hub's mail settings. If you can read it, mail works: verification links, password resets and notifications will reach people.", |
| 376 | 1× | Some(crate::mail::Action { label: "Open the hub", url: &base }), |
| 377 | 1× | None, |
| 378 | | ) |
| 379 | 1× | .await |
| 380 | | { |
| 381 | 1× | Ok(()) => format!("Sent a test message to {}.", me.email), |
| 382 | 0× | Err(e) => format!("Sending failed: {e}"), |
| 383 | | }; |
| 384 | 1× | admin_page(&app, shell, AdminSection::Mail, Some(notice)).await |
| 385 | 1× | } |
| 386 | |
|
| 387 | | #[derive(Deserialize)] |
| 388 | | pub struct IndexQuery { |
| 389 | | audit_before: Option<i64>, |
| 390 | | } |
| 391 | |
|
| 392 | 21× | pub async fn index( |
| 393 | 21× | State(app): State<Shared>, |
| 394 | 21× | viewer: Viewer, |
| 395 | 21× | axum::extract::Query(q): axum::extract::Query<IndexQuery>, |
| 396 | 21× | ) -> Result<Page<AdminPage>, Error> { |
| 397 | 21× | let shell = Shell::new(&app, "/admin", &viewer); |
| 398 | 21× | require_admin(&viewer, &shell)?; |
| 399 | 21× | admin_page_at(&app, shell, AdminSection::Overview, None, q.audit_before).await |
| 400 | 21× | } |
| 401 | |
|
| 402 | |
|
| 403 | |
|
| 404 | |
|
| 405 | 21× | pub async fn section( |
| 406 | 21× | State(app): State<Shared>, |
| 407 | 21× | viewer: Viewer, |
| 408 | 21× | uri: axum::http::Uri, |
| 409 | 21× | axum::extract::Query(q): axum::extract::Query<IndexQuery>, |
| 410 | 21× | ) -> Result<Page<AdminPage>, Error> { |
| 411 | 21× | let shell = Shell::new(&app, uri.path(), &viewer); |
| 412 | 21× | require_admin(&viewer, &shell)?; |
| 413 | 19× | let section = AdminSection::of_path(uri.path()).ok_or_else(|| Error::not_found(shell.clone()))?; |
| 414 | 19× | admin_page_at(&app, shell, section, None, q.audit_before).await |
| 415 | 21× | } |
| 416 | |
|
| 417 | | #[derive(Deserialize)] |
| 418 | | pub struct ModeForm { |
| 419 | | #[serde(rename = "_csrf")] |
| 420 | | csrf_token: String, |
| 421 | | mode: String, |
| 422 | | } |
| 423 | |
|
| 424 | 0× | pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> { |
| 425 | 0× | let shell = Shell::new(&app, "/admin", &viewer); |
| 426 | 0× | let me = require_admin(&viewer, &shell)?; |
| 427 | 0× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 428 | 0× | if !matches!(f.mode.as_str(), "open" | "invite" | "closed") { |
| 429 | 0× | return Err(Error::bad(shell, "mode is open, invite or closed")); |
| 430 | 0× | } |
| 431 | 0× | accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 432 | 0× | accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok(); |
| 433 | 0× | admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await |
| 434 | 0× | } |
| 435 | |
|
| 436 | 10× | pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> { |
| 437 | 10× | let shell = Shell::new(&app, "/admin", &viewer); |
| 438 | 10× | let me = require_admin(&viewer, &shell)?; |
| 439 | 10× | csrf(&viewer, &f.csrf, &shell)?; |
| 440 | 10× | let code = auth::random_hex(12); |
| 441 | 10× | accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 442 | 10× | accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok(); |
| 443 | 10× | admin_page( |
| 444 | 10× | &app, |
| 445 | 10× | shell, |
| 446 | 10× | AdminSection::Registration, |
| 447 | 10× | Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url())), |
| 448 | 10× | ) |
| 449 | 10× | .await |
| 450 | 10× | } |
| 451 | |
|
| 452 | | #[derive(Deserialize)] |
| 453 | | pub struct AccountForm { |
| 454 | | #[serde(rename = "_csrf")] |
| 455 | | csrf_token: String, |
| 456 | | username: String, |
| 457 | | action: String, |
| 458 | | |
| 459 | | #[serde(default)] |
| 460 | | confirm: String, |
| 461 | | } |
| 462 | |
|
| 463 | 8× | pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> { |
| 464 | 8× | let shell = Shell::new(&app, "/admin", &viewer); |
| 465 | 8× | let me = require_admin(&viewer, &shell)?; |
| 466 | 8× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 467 | 8× | let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
| 468 | 0× | return admin_page(&app, shell, AdminSection::Accounts, Some("No such account.".into())).await; |
| 469 | | }; |
| 470 | 8× | if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") { |
| 471 | 2× | return admin_page(&app, shell, AdminSection::Accounts, Some("Not on yourself.".into())).await; |
| 472 | 6× | } |
| 473 | 6× | let e = |e| Error::from_db(shell.clone(), e); |
| 474 | | |
| 475 | 6× | if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") { |
| 476 | 0× | let others = |
| 477 | 0× | accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count(); |
| 478 | 0× | if others == 0 { |
| 479 | 0× | return admin_page( |
| 480 | 0× | &app, |
| 481 | 0× | shell, |
| 482 | 0× | AdminSection::Accounts, |
| 483 | 0× | Some(format!("{} is the last admin who can sign in.", target.username)), |
| 484 | 0× | ) |
| 485 | 0× | .await; |
| 486 | 0× | } |
| 487 | 6× | } |
| 488 | 6× | if f.action == "delete" { |
| 489 | 3× | if f.confirm.trim() != target.username { |
| 490 | 1× | return admin_page(&app, shell, AdminSection::Accounts, Some(format!("Not deleted: type {} to confirm.", target.username))) |
| 491 | 1× | .await; |
| 492 | 2× | } |
| 493 | 2× | let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?; |
| 494 | 2× | let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?; |
| 495 | 2× | if !repos.is_empty() || !orgs.is_empty() { |
| 496 | 1× | let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect(); |
| 497 | 1× | held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)"))); |
| 498 | 1× | return admin_page( |
| 499 | 1× | &app, |
| 500 | 1× | shell, |
| 501 | 1× | AdminSection::Accounts, |
| 502 | 1× | Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))), |
| 503 | 1× | ) |
| 504 | 1× | .await; |
| 505 | 1× | } |
| 506 | 1× | accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?; |
| 507 | 1× | accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok(); |
| 508 | 1× | return admin_page(&app, shell, AdminSection::Accounts, Some(format!("{} deleted; the name stays retired.", target.username))) |
| 509 | 1× | .await; |
| 510 | 3× | } |
| 511 | 3× | if f.action == "reset_link" { |
| 512 | 1× | if target.blocked() { |
| 513 | 0× | return admin_page(&app, shell, AdminSection::Accounts, Some("A banned or disabled account cannot be reset.".into())).await; |
| 514 | 1× | } |
| 515 | 1× | let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?; |
| 516 | 1× | return admin_page( |
| 517 | 1× | &app, |
| 518 | 1× | shell, |
| 519 | 1× | AdminSection::Accounts, |
| 520 | 1× | Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)), |
| 521 | 1× | ) |
| 522 | 1× | .await; |
| 523 | 2× | } |
| 524 | 2× | match f.action.as_str() { |
| 525 | 2× | "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?, |
| 526 | 2× | "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?, |
| 527 | 2× | "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?, |
| 528 | 1× | "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?, |
| 529 | 0× | "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?, |
| 530 | 0× | "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?, |
| 531 | 0× | _ => return Err(Error::bad(shell, "unknown action")), |
| 532 | | } |
| 533 | 2× | accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok(); |
| 534 | 2× | admin_page(&app, shell, AdminSection::Accounts, Some(format!("{}: {}.", target.username, f.action))).await |
| 535 | 8× | } |
| 536 | |
|
| 537 | |
|
| 538 | |
|
| 539 | |
|
| 540 | | pub struct StorageRow { |
| 541 | | pub slug: String, |
| 542 | | |
| 543 | | pub drop: bool, |
| 544 | | pub bytes: u64, |
| 545 | | |
| 546 | | pub loose: usize, |
| 547 | | pub packs: usize, |
| 548 | | } |
| 549 | |
|
| 550 | | #[derive(Template)] |
| 551 | | #[template(path = "admin_storage.html")] |
| 552 | | pub struct StoragePage { |
| 553 | | shell: Shell, |
| 554 | | rows: Vec<StorageRow>, |
| 555 | | total: u64, |
| 556 | | notice: Option<String>, |
| 557 | | error: Option<String>, |
| 558 | | } |
| 559 | |
|
| 560 | |
|
| 561 | 12× | fn footprint(dir: &std::path::Path) -> (u64, usize, usize) { |
| 562 | 12× | let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize); |
| 563 | 12× | let mut stack = vec![dir.to_path_buf()]; |
| 564 | 51× | while let Some(d) = stack.pop() { |
| 565 | 39× | let Ok(rd) = std::fs::read_dir(&d) else { continue }; |
| 566 | 66× | for e in rd.flatten() { |
| 567 | 66× | let Ok(ft) = e.file_type() else { continue }; |
| 568 | 66× | if ft.is_dir() { |
| 569 | 27× | stack.push(e.path()); |
| 570 | 39× | } else if ft.is_file() { |
| 571 | 39× | bytes += e.metadata().map(|m| m.len()).unwrap_or(0); |
| 572 | 39× | let p = e.path(); |
| 573 | 339× | if p.components().any(|c| c.as_os_str() == "loose") { |
| 574 | 15× | loose += 1; |
| 575 | 24× | } else if p.extension().is_some_and(|x| x == "pack") { |
| 576 | 2× | packs += 1; |
| 577 | 22× | } |
| 578 | 0× | } |
| 579 | | } |
| 580 | | } |
| 581 | 12× | (bytes, loose, packs) |
| 582 | 12× | } |
| 583 | |
|
| 584 | 4× | async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> { |
| 585 | | |
| 586 | 4× | let repos = crate::db::repos::list_with_drops(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 587 | 4× | let dirs: Vec<(String, bool, std::path::PathBuf)> = repos |
| 588 | 4× | .iter() |
| 589 | 12× | .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), r.is_drop(), app.repos.dir(&n)))) |
| 590 | 4× | .collect(); |
| 591 | 4× | let mut rows = tokio::task::spawn_blocking(move || { |
| 592 | 4× | dirs.into_iter() |
| 593 | 12× | .map(|(slug, drop, dir)| { |
| 594 | 12× | let (bytes, loose, packs) = footprint(&dir); |
| 595 | 12× | StorageRow { slug, drop, bytes, loose, packs } |
| 596 | 12× | }) |
| 597 | 4× | .collect::<Vec<_>>() |
| 598 | 4× | }) |
| 599 | 4× | .await |
| 600 | 4× | .unwrap_or_default(); |
| 601 | 18× | rows.sort_by_key(|r| std::cmp::Reverse(r.bytes)); |
| 602 | 4× | let total = rows.iter().map(|r| r.bytes).sum(); |
| 603 | 4× | Ok(Page(StoragePage { shell, rows, total, notice, error })) |
| 604 | 4× | } |
| 605 | |
|
| 606 | 2× | pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> { |
| 607 | 2× | let shell = Shell::new(&app, "/admin/storage", &viewer); |
| 608 | 2× | require_admin(&viewer, &shell)?; |
| 609 | 2× | storage_page(&app, shell, None, None).await |
| 610 | 2× | } |
| 611 | |
|
| 612 | | #[derive(Deserialize)] |
| 613 | | pub struct MaintainForm { |
| 614 | | #[serde(rename = "_csrf")] |
| 615 | | csrf_token: String, |
| 616 | | |
| 617 | | repo: String, |
| 618 | | } |
| 619 | |
|
| 620 | |
|
| 621 | |
|
| 622 | 2× | pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> { |
| 623 | 2× | let shell = Shell::new(&app, "/admin/storage", &viewer); |
| 624 | 2× | let me = require_admin(&viewer, &shell)?; |
| 625 | 2× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 626 | 2× | let names: Vec<crate::repos::RepoName> = if f.repo == "*" { |
| 627 | 0× | crate::db::repos::list_with_drops(&app.db) |
| 628 | 0× | .await |
| 629 | 0× | .map_err(|e| Error::from_db(shell.clone(), e))? |
| 630 | 0× | .iter() |
| 631 | 0× | .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name)) |
| 632 | 0× | .collect() |
| 633 | | } else { |
| 634 | 2× | match crate::repos::RepoName::parse(&f.repo) { |
| 635 | 2× | Some(n) if app.repos.exists(&n) => vec![n], |
| 636 | 1× | _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await, |
| 637 | | } |
| 638 | | }; |
| 639 | 1× | let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new()); |
| 640 | 1× | for name in &names { |
| 641 | 1× | match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await { |
| 642 | 1× | Ok(rep) => { |
| 643 | 1× | packed += rep.packed.objects; |
| 644 | 1× | pruned += rep.pruned.objects; |
| 645 | 1× | freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes); |
| 646 | 1× | } |
| 647 | 0× | Err(e) => failed.push(format!("{name}: {e}")), |
| 648 | | } |
| 649 | | } |
| 650 | 1× | let summary = format!( |
| 651 | | "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.", |
| 652 | 1× | names.len(), |
| 653 | 1× | if names.len() == 1 { "y" } else { "ies" }, |
| 654 | 1× | crate::render::bytes(freed) |
| 655 | | ); |
| 656 | 1× | accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok(); |
| 657 | 1× | let error = (!failed.is_empty()).then(|| failed.join("; ")); |
| 658 | 1× | storage_page(&app, shell, Some(summary), error).await |
| 659 | 2× | } |
| 660 | |
|
| 661 | 307× | fn site_assistants() -> crate::levels::At { |
| 662 | 307× | crate::levels::At::site() |
| 663 | 307× | } |
| 664 | |
|
| 665 | |
|
| 666 | 15× | pub async fn assistants( |
| 667 | 15× | State(app): State<Shared>, |
| 668 | 15× | viewer: Viewer, |
| 669 | 15× | Form(f): Form<super::assistants::AssistantForm>, |
| 670 | 15× | ) -> Result<Page<AdminPage>, Error> { |
| 671 | 15× | let shell = Shell::new(&app, "/admin", &viewer); |
| 672 | 15× | require_admin(&viewer, &shell)?; |
| 673 | 14× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 674 | 14× | let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 675 | 14× | admin_page(&app, shell, AdminSection::Assistants, Some(outcome.unwrap_or_else(|e| e))).await |
| 676 | 15× | } |
| 677 | |
|
| 678 | |
|
| 679 | 1× | pub async fn variables( |
| 680 | 1× | State(app): State<Shared>, |
| 681 | 1× | viewer: Viewer, |
| 682 | 1× | Form(f): Form<super::variables::VariableForm>, |
| 683 | 1× | ) -> Result<Page<AdminPage>, Error> { |
| 684 | 1× | let shell = Shell::new(&app, "/admin", &viewer); |
| 685 | 1× | require_admin(&viewer, &shell)?; |
| 686 | 1× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 687 | 1× | let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 688 | 1× | admin_page(&app, shell, AdminSection::Variables, Some(outcome.unwrap_or_else(|e| e))).await |
| 689 | 1× | } |
| 690 | |
|
| 691 | |
|
| 692 | 2× | pub async fn secrets( |
| 693 | 2× | State(app): State<Shared>, |
| 694 | 2× | viewer: Viewer, |
| 695 | 2× | Form(f): Form<super::secrets::SecretForm>, |
| 696 | 2× | ) -> Result<Page<AdminPage>, Error> { |
| 697 | 2× | let shell = Shell::new(&app, "/admin", &viewer); |
| 698 | 2× | require_admin(&viewer, &shell)?; |
| 699 | 2× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 700 | 2× | let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 701 | 2× | admin_page(&app, shell, AdminSection::Secrets, Some(outcome.unwrap_or_else(|e| e))).await |
| 702 | 2× | } |
| 703 | |
|
| 704 | |
|
| 705 | 6× | pub async fn signin( |
| 706 | 6× | State(app): State<Shared>, |
| 707 | 6× | viewer: Viewer, |
| 708 | 6× | Form(f): Form<super::signin::ProviderForm>, |
| 709 | 6× | ) -> Result<Page<AdminPage>, Error> { |
| 710 | 6× | let shell = Shell::new(&app, "/admin", &viewer); |
| 711 | 6× | require_admin(&viewer, &shell)?; |
| 712 | 6× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 713 | 6× | let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 714 | 6× | admin_page(&app, shell, AdminSection::SignIn, Some(outcome.unwrap_or_else(|e| e))).await |
| 715 | 6× | } |
| 716 | |
|
| 717 | |
|
| 718 | 2× | pub async fn assistant_models( |
| 719 | 2× | State(app): State<Shared>, |
| 720 | 2× | viewer: Viewer, |
| 721 | 2× | Form(f): Form<super::assistants::ModelsForm>, |
| 722 | 2× | ) -> Result<axum::Json<serde_json::Value>, Error> { |
| 723 | 2× | let shell = Shell::new(&app, "/admin", &viewer); |
| 724 | 2× | require_admin(&viewer, &shell)?; |
| 725 | 2× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 726 | 2× | Ok(axum::Json(super::assistants::models(&app, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) |
| 727 | 2× | } |