OXYGENHUB
# a7aa76ac52d7 oxygenhub / crates / hub / src / web / admin.rs
21.3 KiB · Rust · last changed security.txt from a contact set under Administration 21 days ago source blame history raw
19bb31ff Andy McCall ↶ 1
//! Site administration (hub spec §6.3 `/admin/*`): accounts, registration
2
//! mode and invitations, the audit log.
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
#[derive(Template)]
14
#[template(path = "admin.html")]
15
pub struct AdminPage {
16
    shell: Shell,
17
    accounts: Vec<accounts::Account>,
18
    orgs: Vec<accounts::Organisation>,
19
    mode: String,
20
    invitations: Vec<(String, Option<i64>, i64)>,
21
    audit: Vec<accounts::AuditRow>,
22
    repos: usize,
23
    notice: Option<String>,
3556d08e Andy McCall ↶ 24
    /// Mail settings (#101): the SMTP URL with its password masked, the
25
    /// From address, whether mail is on, and the administrator's own
26
    /// address for a test message.
27
    mail_smtp: String,
28
    mail_from: String,
29
    mail_enabled: bool,
cd46e56b Andy McCall ↶ 30
    /// The saved mail settings as the panel shows them (oxygenhub#181).
31
    mail: crate::mail::Choice,
3556d08e Andy McCall ↶ 32
    my_email: String,
e8e7158b Andy McCall ↶ 33
    /// The audit log's next page: rows older than this id (#110).
34
    audit_older: Option<i64>,
e71846aa Andy McCall ↶ 35
    /// The site's AI assistants and prompt (#97).
36
    assistants: super::assistants::Panel,
2a83f530 Andy McCall ↶ 37
    /// The site's Flow variables (#102).
38
    variables: super::variables::Panel,
4ac01a73 Andy McCall ↶ 39
    /// The site's Flow secrets (#107).
40
    secrets: super::secrets::Panel,
83940d53 Andy McCall ↶ 41
    /// Sign-in providers (#106).
42
    providers: Vec<super::signin::ProviderView>,
3dd6582d Andy McCall ↶ 43
    /// The security.txt contact and policy (oxygenhub#224).
44
    security_contact: String,
45
    security_policy: String,
19bb31ff Andy McCall ↶ 46
}
47
3dd6582d Andy McCall ↶ 48
pub(super) fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
19bb31ff Andy McCall ↶ 49
    match viewer.account() {
50
        Some(a) if a.is_admin => Ok(a.clone()),
51
        _ => Err(Error::not_found(shell.clone())),
52
    }
53
}
54
3dd6582d Andy McCall ↶ 55
pub(super) async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> {
e8e7158b Andy McCall ↶ 56
    admin_page_at(app, shell, notice, None).await
57
}
58
59
/// The audit log shows this many rows a page.
60
const AUDIT_PAGE: i64 = 50;
61
62
async fn admin_page_at(app: &Shared, shell: Shell, notice: Option<String>, audit_before: Option<i64>) -> Result<Page<AdminPage>, Error> {
19bb31ff Andy McCall ↶ 63
    let e = |e| Error::from_db(shell.clone(), e);
64
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
65
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
66
    let mode = super::session::registration_mode(app).await;
67
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
e8e7158b Andy McCall ↶ 68
    let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?;
69
    let audit_older = if audit.len() as i64 > AUDIT_PAGE {
70
        audit.truncate(AUDIT_PAGE as usize);
71
        audit.last().map(|r| r.id)
72
    } else {
73
        None
74
    };
19bb31ff Andy McCall ↶ 75
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
3556d08e Andy McCall ↶ 76
    let mail = crate::mail::settings(app);
77
    let my_email = match shell.user.as_deref() {
78
        Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(),
79
        None => String::new(),
80
    };
e71846aa Andy McCall ↶ 81
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
2a83f530 Andy McCall ↶ 82
    let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?;
4ac01a73 Andy McCall ↶ 83
    let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?;
83940d53 Andy McCall ↶ 84
    let providers = super::signin::provider_views(app).await.map_err(e)?;
3dd6582d Andy McCall ↶ 85
    let (security_contact, security_policy) = super::security_txt::settings(app).await;
3556d08e Andy McCall ↶ 86
    Ok(Page(AdminPage {
83940d53 Andy McCall ↶ 87
        providers,
3dd6582d Andy McCall ↶ 88
        security_contact,
89
        security_policy,
e71846aa Andy McCall ↶ 90
        assistants,
2a83f530 Andy McCall ↶ 91
        variables,
4ac01a73 Andy McCall ↶ 92
        secrets,
3556d08e Andy McCall ↶ 93
        shell,
94
        accounts: accounts_list,
95
        orgs,
96
        mode,
97
        invitations,
98
        audit,
99
        repos,
100
        notice,
cd46e56b Andy McCall ↶ 101
        mail: crate::mail::describe(&mail.smtp),
3556d08e Andy McCall ↶ 102
        mail_smtp: crate::mail::masked(&mail.smtp),
103
        mail_from: mail.from.clone(),
104
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
105
        my_email,
e8e7158b Andy McCall ↶ 106
        audit_older,
3556d08e Andy McCall ↶ 107
    }))
108
}
109
cd46e56b Andy McCall ↶ 110
impl AdminPage {
111
    fn mail_services(&self) -> &'static [(&'static str, &'static str)] {
112
        crate::mail::SERVICES
113
    }
114
}
115
3556d08e Andy McCall ↶ 116
#[derive(Deserialize)]
117
pub struct MailForm {
118
    #[serde(rename = "_csrf")]
119
    csrf_token: String,
120
    #[serde(default)]
121
    smtp: String,
122
    #[serde(default)]
123
    from: String,
cd46e56b Andy McCall ↶ 124
    /// The panel's service (oxygenhub#181); absent, `smtp` is the URL as
125
    /// before.
126
    #[serde(default)]
127
    service: String,
128
    #[serde(default)]
129
    region: String,
130
    #[serde(default)]
131
    user: String,
132
    #[serde(default)]
133
    secret: String,
134
    #[serde(default)]
135
    host: String,
136
    #[serde(default)]
137
    port: String,
138
    #[serde(default)]
139
    security: String,
3556d08e Andy McCall ↶ 140
}
141
142
/// `POST /admin/mail`: the SMTP URL and From address (#101). The URL
143
/// comes back masked, so a masked value left as it was keeps the stored
144
/// one; an empty URL turns mail off.
145
pub async fn set_mail(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MailForm>) -> Result<Page<AdminPage>, Error> {
146
    let shell = Shell::new(&app, "/admin", &viewer);
147
    let me = require_admin(&viewer, &shell)?;
148
    csrf(&viewer, &f.csrf_token, &shell)?;
149
    let current = crate::mail::settings(&app);
cd46e56b Andy McCall ↶ 150
    // A service the panel knows: it makes the SMTP URL (oxygenhub#181).
151
    let smtp = match f.service.as_str() {
152
        "off" => String::new(),
153
        "" | "url" => {
154
            let smtp = f.smtp.trim();
155
            if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) {
156
                current.smtp.clone()
157
            } else {
158
                smtp.to_string()
159
            }
160
        }
161
        _ => {
162
            let asked = crate::mail::Asked {
163
                service: f.service.clone(),
164
                region: f.region.clone(),
165
                user: f.user.clone(),
166
                secret: f.secret.trim().to_string(),
167
                host: f.host.clone(),
168
                port: f.port.clone(),
169
                security: f.security.clone(),
170
            };
171
            match crate::mail::compose(&asked, &current.smtp) {
172
                Ok(url) => url,
173
                Err(why) => return admin_page(&app, shell, Some(why)).await,
174
            }
175
        }
176
    };
3556d08e Andy McCall ↶ 177
    let from = f.from.trim().to_string();
178
    if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) {
179
        return admin_page(
180
            &app,
181
            shell,
182
            Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()),
183
        )
184
        .await;
185
    }
186
    if !smtp.is_empty() && from.is_empty() {
cd46e56b Andy McCall ↶ 187
        return admin_page(&app, shell, Some("A From address is needed: OxygenHub <[email protected]>.".into())).await;
3556d08e Andy McCall ↶ 188
    }
189
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
190
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
191
    admin_page(&app, shell, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })).await
192
}
193
194
/// `POST /admin/mail/test`: a message to the administrator's own address.
195
pub async fn test_mail(
196
    State(app): State<Shared>,
197
    viewer: Viewer,
198
    Form(f): Form<super::session::CsrfForm>,
199
) -> Result<Page<AdminPage>, Error> {
200
    let shell = Shell::new(&app, "/admin", &viewer);
201
    let me = require_admin(&viewer, &shell)?;
202
    csrf(&viewer, &f.csrf, &shell)?;
203
    if !crate::mail::enabled(&app) {
204
        return admin_page(&app, shell, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
205
    }
206
    if me.email.is_empty() {
207
        return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await;
208
    }
cd46e56b Andy McCall ↶ 209
    let base = app.config.base_url().to_string();
210
    let notice = match crate::mail::send_letter(
3556d08e Andy McCall ↶ 211
        &app,
212
        me.name(),
213
        &me.email,
214
        "Test message",
cd46e56b Andy McCall ↶ 215
        "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.",
216
        Some(crate::mail::Action { label: "Open the hub", url: &base }),
217
        None,
3556d08e Andy McCall ↶ 218
    )
219
    .await
220
    {
221
        Ok(()) => format!("Sent a test message to {}.", me.email),
222
        Err(e) => format!("Sending failed: {e}"),
223
    };
224
    admin_page(&app, shell, Some(notice)).await
19bb31ff Andy McCall ↶ 225
}
226
e8e7158b Andy McCall ↶ 227
#[derive(Deserialize)]
228
pub struct IndexQuery {
229
    audit_before: Option<i64>,
230
}
231
232
pub async fn index(
233
    State(app): State<Shared>,
234
    viewer: Viewer,
235
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
236
) -> Result<Page<AdminPage>, Error> {
19bb31ff Andy McCall ↶ 237
    let shell = Shell::new(&app, "/admin", &viewer);
238
    require_admin(&viewer, &shell)?;
e8e7158b Andy McCall ↶ 239
    admin_page_at(&app, shell, None, q.audit_before).await
19bb31ff Andy McCall ↶ 240
}
241
242
#[derive(Deserialize)]
243
pub struct ModeForm {
244
    #[serde(rename = "_csrf")]
245
    csrf_token: String,
246
    mode: String,
247
}
248
249
pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> {
250
    let shell = Shell::new(&app, "/admin", &viewer);
251
    let me = require_admin(&viewer, &shell)?;
252
    csrf(&viewer, &f.csrf_token, &shell)?;
253
    if !matches!(f.mode.as_str(), "open" | "invite" | "closed") {
254
        return Err(Error::bad(shell, "mode is open, invite or closed"));
255
    }
256
    accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?;
257
    accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok();
258
    admin_page(&app, shell, Some(format!("Registration is {}.", f.mode))).await
259
}
260
261
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
262
    let shell = Shell::new(&app, "/admin", &viewer);
263
    let me = require_admin(&viewer, &shell)?;
264
    csrf(&viewer, &f.csrf, &shell)?;
265
    let code = auth::random_hex(12);
266
    accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
267
    accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok();
268
    admin_page(&app, shell, Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url()))).await
269
}
270
271
#[derive(Deserialize)]
272
pub struct AccountForm {
273
    #[serde(rename = "_csrf")]
274
    csrf_token: String,
275
    username: String,
276
    action: String,
6a178055 Andy McCall ↶ 277
    /// For delete: the username typed again.
278
    #[serde(default)]
279
    confirm: String,
19bb31ff Andy McCall ↶ 280
}
281
282
pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> {
283
    let shell = Shell::new(&app, "/admin", &viewer);
284
    let me = require_admin(&viewer, &shell)?;
285
    csrf(&viewer, &f.csrf_token, &shell)?;
286
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
287
        return admin_page(&app, shell, Some("No such account.".into())).await;
288
    };
6a178055 Andy McCall ↶ 289
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
19bb31ff Andy McCall ↶ 290
        return admin_page(&app, shell, Some("Not on yourself.".into())).await;
291
    }
292
    let e = |e| Error::from_db(shell.clone(), e);
6a178055 Andy McCall ↶ 293
    // The hub must keep an admin who can sign in (oxygenhub#81).
294
    if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") {
295
        let others =
296
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
297
        if others == 0 {
298
            return admin_page(&app, shell, Some(format!("{} is the last admin who can sign in.", target.username))).await;
299
        }
300
    }
301
    if f.action == "delete" {
302
        if f.confirm.trim() != target.username {
303
            return admin_page(&app, shell, Some(format!("Not deleted: type {} to confirm.", target.username))).await;
304
        }
305
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
306
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
307
        if !repos.is_empty() || !orgs.is_empty() {
308
            let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect();
309
            held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)")));
310
            return admin_page(
311
                &app,
312
                shell,
313
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
314
            )
315
            .await;
316
        }
317
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
318
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
319
        return admin_page(&app, shell, Some(format!("{} deleted; the name stays retired.", target.username))).await;
320
    }
34beb1a7 Andy McCall ↶ 321
    if f.action == "reset_link" {
6a178055 Andy McCall ↶ 322
        if target.blocked() {
323
            return admin_page(&app, shell, Some("A banned or disabled account cannot be reset.".into())).await;
34beb1a7 Andy McCall ↶ 324
        }
325
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
326
        return admin_page(
327
            &app,
328
            shell,
329
            Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)),
330
        )
331
        .await;
332
    }
19bb31ff Andy McCall ↶ 333
    match f.action.as_str() {
334
        "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?,
335
        "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?,
6a178055 Andy McCall ↶ 336
        "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?,
337
        "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?,
19bb31ff Andy McCall ↶ 338
        "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?,
339
        "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?,
340
        _ => return Err(Error::bad(shell, "unknown action")),
341
    }
342
    accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok();
343
    admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await
344
}
8fb35df7 Andy McCall ↶ 345
346
// ------------------------------------------------------------------ storage
347
348
/// One repository's footprint on disk.
349
pub struct StorageRow {
350
    pub slug: String,
351
    pub bytes: u64,
352
    /// Objects not yet in a pack.
353
    pub loose: usize,
354
    pub packs: usize,
355
}
356
357
#[derive(Template)]
358
#[template(path = "admin_storage.html")]
359
pub struct StoragePage {
360
    shell: Shell,
361
    rows: Vec<StorageRow>,
362
    total: u64,
363
    notice: Option<String>,
364
    error: Option<String>,
365
}
366
367
/// Size, loose objects and packs under a repository directory.
368
fn footprint(dir: &std::path::Path) -> (u64, usize, usize) {
369
    let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize);
370
    let mut stack = vec![dir.to_path_buf()];
371
    while let Some(d) = stack.pop() {
372
        let Ok(rd) = std::fs::read_dir(&d) else { continue };
373
        for e in rd.flatten() {
374
            let Ok(ft) = e.file_type() else { continue };
375
            if ft.is_dir() {
376
                stack.push(e.path());
377
            } else if ft.is_file() {
378
                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
379
                let p = e.path();
380
                if p.components().any(|c| c.as_os_str() == "loose") {
381
                    loose += 1;
382
                } else if p.extension().is_some_and(|x| x == "pack") {
383
                    packs += 1;
384
                }
385
            }
386
        }
387
    }
388
    (bytes, loose, packs)
389
}
390
391
async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> {
392
    let repos = crate::db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
393
    let dirs: Vec<(String, std::path::PathBuf)> =
394
        repos.iter().filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), app.repos.dir(&n)))).collect();
395
    let mut rows = tokio::task::spawn_blocking(move || {
396
        dirs.into_iter()
397
            .map(|(slug, dir)| {
398
                let (bytes, loose, packs) = footprint(&dir);
399
                StorageRow { slug, bytes, loose, packs }
400
            })
401
            .collect::<Vec<_>>()
402
    })
403
    .await
404
    .unwrap_or_default();
405
    rows.sort_by_key(|r| std::cmp::Reverse(r.bytes));
406
    let total = rows.iter().map(|r| r.bytes).sum();
407
    Ok(Page(StoragePage { shell, rows, total, notice, error }))
408
}
409
410
pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> {
411
    let shell = Shell::new(&app, "/admin/storage", &viewer);
412
    require_admin(&viewer, &shell)?;
413
    storage_page(&app, shell, None, None).await
414
}
415
416
#[derive(Deserialize)]
417
pub struct MaintainForm {
418
    #[serde(rename = "_csrf")]
419
    csrf_token: String,
420
    /// `owner/name`, or `*` for every repository.
421
    repo: String,
422
}
423
424
/// `POST /admin/storage`: `oxy gc` on one repository or all of them, under
425
/// each repository's write lock, with the grace period kept.
426
pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> {
427
    let shell = Shell::new(&app, "/admin/storage", &viewer);
428
    let me = require_admin(&viewer, &shell)?;
429
    csrf(&viewer, &f.csrf_token, &shell)?;
430
    let names: Vec<crate::repos::RepoName> = if f.repo == "*" {
431
        crate::db::repos::list(&app.db)
432
            .await
433
            .map_err(|e| Error::from_db(shell.clone(), e))?
434
            .iter()
435
            .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name))
436
            .collect()
437
    } else {
438
        match crate::repos::RepoName::parse(&f.repo) {
439
            Some(n) if app.repos.exists(&n) => vec![n],
440
            _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await,
441
        }
442
    };
443
    let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new());
444
    for name in &names {
445
        match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await {
446
            Ok(rep) => {
447
                packed += rep.packed.objects;
448
                pruned += rep.pruned.objects;
449
                freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes);
450
            }
451
            Err(e) => failed.push(format!("{name}: {e}")),
452
        }
453
    }
454
    let summary = format!(
455
        "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.",
456
        names.len(),
457
        if names.len() == 1 { "y" } else { "ies" },
458
        crate::render::bytes(freed)
459
    );
460
    accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok();
461
    let error = (!failed.is_empty()).then(|| failed.join("; "));
462
    storage_page(&app, shell, Some(summary), error).await
463
}
e71846aa Andy McCall ↶ 464
2a83f530 Andy McCall ↶ 465
fn site_assistants() -> crate::levels::At {
466
    crate::levels::At::site()
e71846aa Andy McCall ↶ 467
}
468
469
/// `POST /admin/assistants`: the site's assistants and prompt (#97).
470
pub async fn assistants(
471
    State(app): State<Shared>,
472
    viewer: Viewer,
473
    Form(f): Form<super::assistants::AssistantForm>,
474
) -> Result<Page<AdminPage>, Error> {
475
    let shell = Shell::new(&app, "/admin", &viewer);
476
    require_admin(&viewer, &shell)?;
477
    csrf(&viewer, &f.csrf_token, &shell)?;
478
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
479
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
480
}
2a83f530 Andy McCall ↶ 481
482
/// `POST /admin/variables`: the site's Flow variables (#102).
483
pub async fn variables(
484
    State(app): State<Shared>,
485
    viewer: Viewer,
486
    Form(f): Form<super::variables::VariableForm>,
487
) -> Result<Page<AdminPage>, Error> {
488
    let shell = Shell::new(&app, "/admin", &viewer);
489
    require_admin(&viewer, &shell)?;
490
    csrf(&viewer, &f.csrf_token, &shell)?;
491
    let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
492
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
493
}
4ac01a73 Andy McCall ↶ 494
495
/// `POST /admin/secrets`: the site's Flow secrets (#107).
496
pub async fn secrets(
497
    State(app): State<Shared>,
498
    viewer: Viewer,
499
    Form(f): Form<super::secrets::SecretForm>,
500
) -> Result<Page<AdminPage>, Error> {
501
    let shell = Shell::new(&app, "/admin", &viewer);
502
    require_admin(&viewer, &shell)?;
503
    csrf(&viewer, &f.csrf_token, &shell)?;
504
    let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
505
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
506
}
83940d53 Andy McCall ↶ 507
508
/// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106).
509
pub async fn signin(
510
    State(app): State<Shared>,
511
    viewer: Viewer,
512
    Form(f): Form<super::signin::ProviderForm>,
513
) -> Result<Page<AdminPage>, Error> {
514
    let shell = Shell::new(&app, "/admin", &viewer);
515
    require_admin(&viewer, &shell)?;
516
    csrf(&viewer, &f.csrf_token, &shell)?;
517
    let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
518
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
519
}
155cab14 Andy McCall ↶ 520
521
/// `POST /admin/assistants/models`: what a provider offers, as JSON (#109).
522
pub async fn assistant_models(
523
    State(app): State<Shared>,
524
    viewer: Viewer,
525
    Form(f): Form<super::assistants::ModelsForm>,
526
) -> Result<axum::Json<serde_json::Value>, Error> {
527
    let shell = Shell::new(&app, "/admin", &viewer);
528
    require_admin(&viewer, &shell)?;
529
    csrf(&viewer, &f.csrf_token, &shell)?;
530
    Ok(axum::Json(super::assistants::models(&app, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
531
}