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