OXYGENHUB
# a268d4df52bf oxygenhub / crates / hub / src / web / settings.rs
29.0 KiB · Rust · last changed HTTPS only: nothing taken over plain http through a proxy 20 days ago source blame history raw
1
//! Account settings (hub spec §6.3 `/settings/*`): profile, security,
2
//! linked identities, tokens.
3
4
use super::{filters, Error, Page, Shell};
5
use crate::auth::{self, totp, Viewer, ViewerAccount};
6
use crate::db::{self, accounts};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Multipart, Path, Query, State};
10
use axum::http::{header, HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use oxy_core::identity::{Identity, Signature};
13
use serde::Deserialize;
14
15
/// The signed-in account, or a redirect to sign in.
16
pub fn require(viewer: &Viewer, shell: &Shell) -> Result<ViewerAccount, Error> {
17
    viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path))))
18
}
19
20
pub fn urlencode(s: &str) -> String {
21
    let mut out = String::new();
22
    for b in s.bytes() {
23
        match b {
24
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char),
25
            _ => out.push_str(&format!("%{b:02X}")),
26
        }
27
    }
28
    out
29
}
30
31
pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> {
32
    if viewer.csrf_ok(token) {
33
        Ok(())
34
    } else {
35
        Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again."))
36
    }
37
}
38
39
#[derive(Template)]
40
#[template(path = "settings.html")]
41
pub struct SettingsPage {
42
    shell: Shell,
43
    me: accounts::Account,
44
    notice: Option<String>,
45
    error: Option<String>,
46
    /// The theme cookie's value, for the Appearance form.
47
    theme: &'static str,
48
    themes: &'static [(&'static str, &'static str)],
49
    /// The backdrop cookie's value and the choices (docs/backgrounds.md §7).
50
    backdrop: &'static str,
51
    backdrops: &'static [(&'static str, &'static str)],
52
    /// Whether the hub sends mail, for the "send a new link" button.
53
    mail: bool,
54
}
55
56
/// The themes (hub spec §6.1): the cookie value and its label.
57
const THEMES: &[(&str, &str)] = &[("light", "Light"), ("dark", "Dark"), ("auto", "Match the system")];
58
59
/// Backdrops (docs/backgrounds.md §7): the cookie value and its label.
60
const BACKDROPS: &[(&str, &str)] = &[("moving", "Moving"), ("still", "Still"), ("off", "Off")];
61
62
/// The backdrop the request's cookie asks for; moving when unset.
63
fn backdrop_of(headers: &HeaderMap) -> &'static str {
64
    let v = auth::viewer::cookie(headers, "backdrop").unwrap_or_default();
65
    BACKDROPS.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("moving")
66
}
67
68
/// The theme the request's cookie asks for; light when unset or unknown.
69
fn theme_of(headers: &HeaderMap) -> &'static str {
70
    let v = auth::viewer::cookie(headers, "theme").unwrap_or_default();
71
    THEMES.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("light")
72
}
73
74
impl SettingsPage {
75
    fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage {
76
        SettingsPage {
77
            shell,
78
            me,
79
            notice: None,
80
            error: None,
81
            theme: theme_of(headers),
82
            themes: THEMES,
83
            backdrop: backdrop_of(headers),
84
            backdrops: BACKDROPS,
85
            mail: false,
86
        }
87
    }
88
89
    fn with_mail(mut self, app: &Shared) -> Self {
90
        self.mail = crate::mail::enabled(app);
91
        self
92
    }
93
94
    fn notice(mut self, n: impl Into<String>) -> Self {
95
        self.notice = Some(n.into());
96
        self
97
    }
98
99
    fn error(mut self, e: impl Into<String>) -> Self {
100
        self.error = Some(e.into());
101
        self
102
    }
103
}
104
105
pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> {
106
    let shell = Shell::new(&app, "/settings", &viewer);
107
    let me = require(&viewer, &shell)?;
108
    Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app)))
109
}
110
111
/// `POST /settings/avatar`: upload or remove the profile picture.
112
pub async fn avatar(
113
    State(app): State<Shared>,
114
    viewer: Viewer,
115
    headers: HeaderMap,
116
    multipart: Multipart,
117
) -> Result<Page<SettingsPage>, Error> {
118
    let shell = Shell::new(&app, "/settings", &viewer);
119
    let me = require(&viewer, &shell)?;
120
    let upload = super::avatar::read_upload(multipart).await;
121
    csrf(&viewer, &upload.csrf, &shell)?;
122
    let result = super::avatar::apply(&app, "account", me.account.id, upload).await;
123
    if result.is_ok() {
124
        accounts::audit(&app.db, Some(&me.account), "profile.picture", &me.account.username, "").await.ok();
125
    }
126
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
127
    let page = SettingsPage::new(shell, fresh, &headers).with_mail(&app);
128
    Ok(Page(match result {
129
        Ok(m) => page.notice(m),
130
        Err(e) => page.error(e),
131
    }))
132
}
133
134
/// `POST /settings/email/verify`: send a new verification link.
135
pub async fn resend_verification(
136
    State(app): State<Shared>,
137
    viewer: Viewer,
138
    headers: HeaderMap,
139
    Form(f): Form<super::session::CsrfForm>,
140
) -> Result<Page<SettingsPage>, Error> {
141
    let shell = Shell::new(&app, "/settings", &viewer);
142
    let me = require(&viewer, &shell)?;
143
    csrf(&viewer, &f.csrf, &shell)?;
144
    let page = SettingsPage::new(shell, me.account.clone(), &headers).with_mail(&app);
145
    Ok(Page(match super::recovery::send_verification(&app, &me.account).await {
146
        Ok(()) if me.account.email_verified => page.notice("Your email is already verified."),
147
        Ok(()) => page.notice(format!("A verification link is on its way to {}.", me.account.email)),
148
        Err(e) => page.error(e),
149
    }))
150
}
151
152
#[derive(Deserialize)]
153
pub struct AppearanceForm {
154
    #[serde(rename = "_csrf")]
155
    csrf_token: String,
156
    theme: String,
157
    #[serde(default)]
158
    backdrop: String,
159
}
160
161
/// Remember the theme and the backdrop in cookies for a year; the page
162
/// scripts apply them.
163
pub async fn appearance(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AppearanceForm>) -> Result<Response, Error> {
164
    let shell = Shell::new(&app, "/settings", &viewer);
165
    require(&viewer, &shell)?;
166
    csrf(&viewer, &f.csrf_token, &shell)?;
167
    let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else {
168
        return Err(Error::bad(shell, "Pick dark, light or match the system."));
169
    };
170
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
171
    let backdrop = if f.backdrop.is_empty() { "moving" } else { f.backdrop.as_str() };
172
    let Some((backdrop, _)) = BACKDROPS.iter().find(|(b, _)| *b == backdrop) else {
173
        return Err(Error::bad(shell, "Pick a moving, still or no background."));
174
    };
175
    Ok((
176
        StatusCode::SEE_OTHER,
177
        axum::response::AppendHeaders([
178
            (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")),
179
            (header::SET_COOKIE, format!("backdrop={backdrop}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")),
180
            (header::LOCATION, "/settings".to_string()),
181
        ]),
182
    )
183
        .into_response())
184
}
185
186
#[derive(Deserialize)]
187
pub struct ProfileForm {
188
    #[serde(rename = "_csrf")]
189
    csrf_token: String,
190
    display_name: String,
191
    email: String,
192
    #[serde(default)]
193
    bio: String,
194
    #[serde(default)]
195
    website: String,
196
    #[serde(default)]
197
    location: String,
198
    #[serde(default)]
199
    bluesky: String,
200
    #[serde(default)]
201
    mastodon: String,
202
    #[serde(default)]
203
    github: String,
204
}
205
206
pub async fn update_profile(
207
    State(app): State<Shared>,
208
    viewer: Viewer,
209
    headers: HeaderMap,
210
    Form(f): Form<ProfileForm>,
211
) -> Result<Page<SettingsPage>, Error> {
212
    let shell = Shell::new(&app, "/settings", &viewer);
213
    let me = require(&viewer, &shell)?;
214
    csrf(&viewer, &f.csrf_token, &shell)?;
215
    let website = f.website.trim();
216
    if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 {
217
        return Ok(Page(
218
            SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."),
219
        ));
220
    }
221
    // Accounts elsewhere: checked and normalised, or refused by name.
222
    let link = |given: &str, what: &str, parse: fn(&str) -> Option<String>| -> Result<String, String> {
223
        if given.trim().is_empty() {
224
            Ok(String::new())
225
        } else {
226
            parse(given).ok_or_else(|| format!("That is not a {what}."))
227
        }
228
    };
229
    let links = (|| {
230
        Ok::<_, String>((
231
            link(&f.bluesky, "Bluesky handle (such as andy.bsky.social)", crate::social::bluesky)?,
232
            link(&f.mastodon, "Mastodon address (such as @[email protected])", crate::social::mastodon)?,
233
            link(&f.github, "GitHub username", crate::social::github)?,
234
        ))
235
    })();
236
    let (bluesky, mastodon, github) = match links {
237
        Ok(l) => l,
238
        Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(&msg))),
239
    };
240
    let bio: String = f.bio.trim().chars().take(280).collect();
241
    let location: String = f.location.trim().chars().take(80).collect();
242
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
243
        .await
244
        .map_err(|e| Error::from_db(shell.clone(), e))?;
245
    accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?;
246
    accounts::set_links(&app.db, me.account.id, &bluesky, &mastodon, &github).await.map_err(|e| Error::from_db(shell.clone(), e))?;
247
    let changed = me.account.email != f.email.trim();
248
    let updated =
249
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
250
    let mut notice = "Profile saved.".to_string();
251
    if changed && !updated.email.is_empty() {
252
        accounts::audit(&app.db, Some(&updated), "email.changed", &updated.username, "").await.ok();
253
        match super::recovery::send_verification(&app, &updated).await {
254
            Ok(()) => notice = format!("Profile saved. A verification link is on its way to {}.", updated.email),
255
            Err(e) => notice = format!("Profile saved. {e}"),
256
        }
257
    }
258
    Ok(Page(SettingsPage::new(shell, updated, &headers).with_mail(&app).notice(notice)))
259
}
260
261
#[derive(Deserialize)]
262
pub struct PasswordForm {
263
    #[serde(rename = "_csrf")]
264
    csrf_token: String,
265
    current: String,
266
    password: String,
267
}
268
269
pub async fn change_password(
270
    State(app): State<Shared>,
271
    viewer: Viewer,
272
    headers: HeaderMap,
273
    Form(f): Form<PasswordForm>,
274
) -> Result<Response, Error> {
275
    let shell = Shell::new(&app, "/settings", &viewer);
276
    let me = require(&viewer, &shell)?;
277
    csrf(&viewer, &f.csrf_token, &shell)?;
278
    if !auth::verify_password(&me.account.password_hash, &f.current) {
279
        return Ok(
280
            Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("Your current password is wrong.")).into_response()
281
        );
282
    }
283
    if let Err(e) = auth::password_ok(&f.password) {
284
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(e)).into_response());
285
    }
286
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
287
    // A new session salt signs out every other session; this one is renewed.
288
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
289
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
290
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
291
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
292
}
293
294
#[derive(Template)]
295
#[template(path = "settings_security.html")]
296
pub struct SecurityPage {
297
    shell: Shell,
298
    me: accounts::Account,
299
    notice: Option<String>,
300
    error: Option<String>,
301
    /// Enrolment in progress: secret, URI and QR.
302
    enrol: Option<(String, String, String)>,
303
    recovery: Vec<String>,
304
    /// Providers on this hub and the login connected, if any (#106).
305
    connected: Vec<(&'static str, &'static str, Option<String>)>,
306
}
307
308
#[derive(Deserialize)]
309
pub struct SecurityQuery {
310
    notice: Option<String>,
311
    error: Option<String>,
312
}
313
314
pub async fn security(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SecurityQuery>) -> Result<Page<SecurityPage>, Error> {
315
    let shell = Shell::new(&app, "/settings/security", &viewer);
316
    let me = require(&viewer, &shell)?;
317
    let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
318
    Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected }))
319
}
320
321
/// Each sign-in provider this hub offers, with the login connected to the account.
322
pub async fn connected_of(app: &Shared, account_id: i64) -> sqlx::Result<Vec<(&'static str, &'static str, Option<String>)>> {
323
    let links = crate::signin::links_of(&app.db, account_id).await?;
324
    let mut out = vec![];
325
    for p in crate::signin::enabled(app).await {
326
        out.push((p.id, p.name, links.iter().find(|l| l.provider == p.id).map(|l| l.login.clone())));
327
    }
328
    Ok(out)
329
}
330
331
#[derive(Deserialize)]
332
pub struct TotpForm {
333
    #[serde(rename = "_csrf")]
334
    csrf_token: String,
335
    #[serde(default)]
336
    secret: String,
337
    #[serde(default)]
338
    code: String,
339
    #[serde(default)]
340
    password: String,
341
}
342
343
/// Step one: show a fresh secret to scan.
344
pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
345
    let shell = Shell::new(&app, "/settings/security", &viewer);
346
    let me = require(&viewer, &shell)?;
347
    csrf(&viewer, &f.csrf_token, &shell)?;
348
    let secret = totp::new_secret();
349
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
350
    let qr = totp::qr_svg(&uri).unwrap_or_default();
351
    Ok(Page(SecurityPage {
352
        shell,
353
        me: me.account,
354
        notice: None,
355
        error: None,
356
        enrol: Some((secret, uri, qr)),
357
        recovery: vec![],
358
        connected: vec![],
359
    }))
360
}
361
362
/// Step two: the first code proves the app holds the secret.
363
pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
364
    let shell = Shell::new(&app, "/settings/security", &viewer);
365
    let me = require(&viewer, &shell)?;
366
    csrf(&viewer, &f.csrf_token, &shell)?;
367
    let now = (db::now_ms() / 1000) as u64;
368
    if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) {
369
        let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret);
370
        let qr = totp::qr_svg(&uri).unwrap_or_default();
371
        return Ok(Page(SecurityPage {
372
            shell,
373
            me: me.account,
374
            notice: None,
375
            error: Some("That code did not match; scan again and enter the current code.".into()),
376
            enrol: Some((f.secret, uri, qr)),
377
            recovery: vec![],
378
            connected: vec![],
379
        }));
380
    }
381
    let (codes, stored) = totp::new_recovery_codes();
382
    accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?;
383
    accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok();
384
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
385
    Ok(Page(SecurityPage {
386
        shell,
387
        me: fresh,
388
        notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()),
389
        error: None,
390
        enrol: None,
391
        recovery: codes,
392
        connected: vec![],
393
    }))
394
}
395
396
pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
397
    let shell = Shell::new(&app, "/settings/security", &viewer);
398
    let me = require(&viewer, &shell)?;
399
    csrf(&viewer, &f.csrf_token, &shell)?;
400
    if !auth::verify_password(&me.account.password_hash, &f.password) {
401
        return Ok(Page(SecurityPage {
402
            shell,
403
            me: me.account,
404
            notice: None,
405
            error: Some("Your password is wrong.".into()),
406
            enrol: None,
407
            recovery: vec![],
408
            connected: vec![],
409
        }));
410
    }
411
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
412
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
413
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
414
    Ok(Page(SecurityPage {
415
        shell,
416
        me: fresh,
417
        notice: Some("Two-factor authentication is off.".into()),
418
        error: None,
419
        enrol: None,
420
        recovery: vec![],
421
        connected: vec![],
422
    }))
423
}
424
425
/// Sign out everywhere: a new session salt invalidates every cookie, then
426
/// this browser gets a fresh one.
427
pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> {
428
    let shell = Shell::new(&app, "/settings/security", &viewer);
429
    let me = require(&viewer, &shell)?;
430
    csrf(&viewer, &f.csrf, &shell)?;
431
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
432
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
433
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
434
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
435
}
436
437
// ------------------------------------------------------------------- keys
438
439
#[derive(Template)]
440
#[template(path = "settings_keys.html")]
441
pub struct KeysPage {
442
    shell: Shell,
443
    me: accounts::Account,
444
    keys: Vec<accounts::LinkedIdentity>,
445
    notice: Option<String>,
446
    error: Option<String>,
447
    /// A pending challenge: id, hex bytes, identity name.
448
    pending: Option<(i64, String, String)>,
449
}
450
451
async fn keys_page(
452
    app: &Shared,
453
    shell: Shell,
454
    me: accounts::Account,
455
    notice: Option<String>,
456
    error: Option<String>,
457
    pending: Option<(i64, String, String)>,
458
) -> Result<Page<KeysPage>, Error> {
459
    let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
460
    Ok(Page(KeysPage { shell, me, keys, notice, error, pending }))
461
}
462
463
pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> {
464
    let shell = Shell::new(&app, "/settings/keys", &viewer);
465
    let me = require(&viewer, &shell)?;
466
    keys_page(&app, shell, me.account, None, None, None).await
467
}
468
469
/// Upload an identity object (`oxy identity export`): the hub issues a
470
/// challenge to sign with that key.
471
pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> {
472
    let shell = Shell::new(&app, "/settings/keys", &viewer);
473
    let me = require(&viewer, &shell)?;
474
    let mut token = String::new();
475
    let mut object: Vec<u8> = Vec::new();
476
    while let Ok(Some(field)) = multipart.next_field().await {
477
        match field.name() {
478
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
479
            Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
480
            _ => {}
481
        }
482
    }
483
    csrf(&viewer, &token, &shell)?;
484
    let identity = match Identity::decode(&object) {
485
        Ok(i) => i,
486
        Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await,
487
    };
488
    let key = identity.hash().to_hex();
489
    if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? {
490
        let msg = if existing.account_id == me.account.id {
491
            "That key is already linked to you."
492
        } else {
493
            "That key is linked to another account."
494
        };
495
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
496
    }
497
    let bytes = auth::random_hex(32);
498
    let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?;
499
    sqlx::query("UPDATE challenges SET object = ? WHERE id = ?")
500
        .bind(&object)
501
        .bind(id)
502
        .execute(&app.db)
503
        .await
504
        .map_err(|e| Error::from_db(shell.clone(), e))?;
505
    keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await
506
}
507
508
#[derive(Deserialize)]
509
pub struct KeyVerifyForm {
510
    #[serde(rename = "_csrf")]
511
    csrf_token: String,
512
    challenge: i64,
513
    signature: String,
514
}
515
516
pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> {
517
    let shell = Shell::new(&app, "/settings/keys", &viewer);
518
    let me = require(&viewer, &shell)?;
519
    csrf(&viewer, &f.csrf_token, &shell)?;
520
    let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = ? AND account_id = ?")
521
        .bind(f.challenge)
522
        .bind(me.account.id)
523
        .fetch_optional(&app.db)
524
        .await
525
        .map_err(|e| Error::from_db(shell.clone(), e))?;
526
    let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?;
527
    let (Some(object), Some(bytes)) = (object, bytes) else {
528
        return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await;
529
    };
530
    let Ok(identity) = Identity::decode(&object) else {
531
        return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await;
532
    };
533
    let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default();
534
    let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default();
535
    let challenge = hex::decode(&bytes).unwrap_or_default();
536
    let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok());
537
    if !ok {
538
        return keys_page(
539
            &app,
540
            shell,
541
            me.account,
542
            None,
543
            Some("The signature does not verify against that identity and challenge.".into()),
544
            None,
545
        )
546
        .await;
547
    }
548
    let key = identity.hash().to_hex();
549
    accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object)
550
        .await
551
        .map_err(|e| Error::from_db(shell.clone(), e))?;
552
    accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok();
553
    keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await
554
}
555
556
#[derive(Deserialize)]
557
pub struct KeyForm {
558
    #[serde(rename = "_csrf")]
559
    csrf_token: String,
560
    key: String,
561
}
562
563
pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> {
564
    let shell = Shell::new(&app, "/settings/keys", &viewer);
565
    let me = require(&viewer, &shell)?;
566
    csrf(&viewer, &f.csrf_token, &shell)?;
567
    accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?;
568
    accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok();
569
    keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await
570
}
571
572
// ----------------------------------------------------------------- tokens
573
574
#[derive(Template)]
575
#[template(path = "settings_tokens.html")]
576
pub struct TokensPage {
577
    shell: Shell,
578
    scopes: &'static [(&'static str, &'static str)],
579
    me: accounts::Account,
580
    tokens: Vec<accounts::Token>,
581
    /// A freshly created token, shown once.
582
    fresh: Option<String>,
583
    notice: Option<String>,
584
    error: Option<String>,
585
}
586
587
async fn tokens_page(
588
    app: &Shared,
589
    shell: Shell,
590
    me: accounts::Account,
591
    fresh: Option<String>,
592
    notice: Option<String>,
593
    error: Option<String>,
594
) -> Result<Page<TokensPage>, Error> {
595
    let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
596
    Ok(Page(TokensPage { shell, scopes: SCOPES, me, tokens, fresh, notice, error }))
597
}
598
599
pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> {
600
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
601
    let me = require(&viewer, &shell)?;
602
    tokens_page(&app, shell, me.account, None, None, None).await
603
}
604
605
#[derive(Deserialize)]
606
pub struct TokenForm {
607
    #[serde(rename = "_csrf")]
608
    csrf_token: String,
609
    name: String,
610
    #[serde(default)]
611
    scopes: Vec<String>,
612
    #[serde(default)]
613
    repos: String,
614
    #[serde(default)]
615
    expires_days: String,
616
    /// An agent uses it: tell me what it does (oxygenhub#183).
617
    #[serde(default)]
618
    agent: String,
619
}
620
621
/// Every scope the hub checks, with what it grants. The tokens page lists
622
/// exactly these, so a scope cannot exist without a checkbox.
623
pub const SCOPES: &[(&str, &str)] = &[
624
    ("repo:read", "read repositories you can see: clone, fetch, pull, and the read API"),
625
    ("repo:write", "push to repositories you can write to; also covers proposals, issues and checks"),
626
    ("repo:admin", "create repositories and change settings through the API"),
627
    ("proposals:write", "open, comment on and review proposals, without push access"),
628
    ("issues:write", "open, comment on and close issues, without push access"),
629
    ("checks:write", "report checks on states, for CI systems, without push access"),
630
    ("hooks:admin", "manage webhooks through the API"),
631
    ("user:read", "read your own profile through the API"),
632
];
633
634
pub async fn tokens_create(
635
    State(app): State<Shared>,
636
    viewer: Viewer,
637
    axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>,
638
) -> Result<Page<TokensPage>, Error> {
639
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
640
    let me = require(&viewer, &shell)?;
641
    csrf(&viewer, &f.csrf_token, &shell)?;
642
    let name = f.name.trim();
643
    if name.is_empty() {
644
        return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await;
645
    }
646
    let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect();
647
    if scopes.is_empty() {
648
        return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await;
649
    }
650
    let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000);
651
    let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(",");
652
    let (secret, hash) = auth::new_token();
653
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
654
        .await
655
        .map_err(|e| Error::from_db(shell.clone(), e))?;
656
    if !f.agent.is_empty() {
657
        if let Ok(Some(t)) = accounts::token_by_hash(&app.db, &hash).await {
658
            accounts::set_token_agent(&app.db, me.account.id, t.id, true).await.map_err(|e| Error::from_db(shell.clone(), e))?;
659
        }
660
    }
661
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
662
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
663
}
664
665
/// Mark a token as an agent's, or not (oxygenhub#183): what is done with
666
/// an agent's token is told to its owner, as anyone else's doing is.
667
pub async fn tokens_agent(
668
    State(app): State<Shared>,
669
    viewer: Viewer,
670
    Path(id): Path<i64>,
671
    Form(f): Form<super::session::CsrfForm>,
672
) -> Result<Page<TokensPage>, Error> {
673
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
674
    let me = require(&viewer, &shell)?;
675
    csrf(&viewer, &f.csrf, &shell)?;
676
    let tokens = accounts::tokens_of(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
677
    let Some(t) = tokens.iter().find(|t| t.id == id) else {
678
        return tokens_page(&app, shell, me.account, None, None, Some("No such token.".into())).await;
679
    };
680
    accounts::set_token_agent(&app.db, me.account.id, id, !t.agent).await.map_err(|e| Error::from_db(shell.clone(), e))?;
681
    accounts::audit(&app.db, Some(&me.account), "token.agent", &t.name, if t.agent { "off" } else { "on" }).await.ok();
682
    let notice = if t.agent {
683
        format!("{} is no longer marked as an agent's.", t.name)
684
    } else {
685
        format!("{} is an agent's: you will hear what it does.", t.name)
686
    };
687
    tokens_page(&app, shell, me.account, None, Some(notice), None).await
688
}
689
690
pub async fn tokens_delete(
691
    State(app): State<Shared>,
692
    viewer: Viewer,
693
    Path(id): Path<i64>,
694
    Form(f): Form<super::session::CsrfForm>,
695
) -> Result<Page<TokensPage>, Error> {
696
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
697
    let me = require(&viewer, &shell)?;
698
    csrf(&viewer, &f.csrf, &shell)?;
699
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
700
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
701
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
702
}