OXYGENHUB

Coverage / crates/hub/src/web/settings.rs 75.3%

505 of 671 lines run, 76 of 243 functions · run #545 on main · run missed

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, Redirect, Response};
12
use oxy_core::identity::{Identity, Signature};
13
use serde::Deserialize;
14
15
/// The signed-in account, or a redirect to sign in.
16455×
pub fn require(viewer: &Viewer, shell: &Shell) -> Result<ViewerAccount, Error> {
17455×
    viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path))))
18455×
}
19
20393×
pub fn urlencode(s: &str) -> String {
21393×
    let mut out = String::new();
228541×
    for b in s.bytes() {
238541×
        match b {
247653×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char),
25888×
            _ => out.push_str(&format!("%{b:02X}")),
26
        }
27
    }
28393×
    out
29393×
}
30
31506×
pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> {
32506×
    if viewer.csrf_ok(token) {
33503×
        Ok(())
34
    } else {
353×
        Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again."))
36
    }
37506×
}
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
    /// The default bookmark of the repositories they make (#371), and the
55
    /// one they get when that is empty.
56
    default_bookmark: String,
57
    site_bookmark: String,
58
}
59
60
/// The themes (hub spec §6.1): the cookie value and its label.
61
const THEMES: &[(&str, &str)] = &[("light", "Light"), ("dark", "Dark"), ("auto", "Match the system")];
62
63
/// Backdrops (docs/backgrounds.md §7): the cookie value and its label.
64
const BACKDROPS: &[(&str, &str)] = &[("moving", "Moving"), ("still", "Still"), ("off", "Off")];
65
66
/// The backdrop the request's cookie asks for; moving when unset.
6727×
fn backdrop_of(headers: &HeaderMap) -> &'static str {
6827×
    let v = auth::viewer::cookie(headers, "backdrop").unwrap_or_default();
6981×
    BACKDROPS.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("moving")
7027×
}
71
72
/// The theme the request's cookie asks for; light when unset or unknown.
7327×
fn theme_of(headers: &HeaderMap) -> &'static str {
7427×
    let v = auth::viewer::cookie(headers, "theme").unwrap_or_default();
7580×
    THEMES.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("light")
7627×
}
77
78
impl SettingsPage {
7927×
    fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage {
8027×
        SettingsPage {
8127×
            shell,
8227×
            me,
8327×
            notice: None,
8427×
            error: None,
8527×
            theme: theme_of(headers),
8627×
            themes: THEMES,
8727×
            backdrop: backdrop_of(headers),
8827×
            backdrops: BACKDROPS,
8927×
            mail: false,
9027×
            default_bookmark: String::new(),
9127×
            site_bookmark: crate::repo_setup::MAIN.into(),
9227×
        }
9327×
    }
94
95
    /// What the page reads from the hub: whether it sends mail, and the
96
    /// default bookmarks (#371).
9727×
    async fn with_hub(mut self, app: &Shared) -> Self {
98
        use crate::repo_setup::{bookmark_setting, BookmarkScope};
9927×
        self.mail = crate::mail::enabled(app);
10027×
        self.default_bookmark = bookmark_setting(&app.db, BookmarkScope::Account(self.me.id)).await.ok().flatten().unwrap_or_default();
10127×
        if let Ok(Some(b)) = bookmark_setting(&app.db, BookmarkScope::Site).await {
1022×
            self.site_bookmark = b;
10325×
        }
10427×
        self
10527×
    }
106
1079×
    fn notice(mut self, n: impl Into<String>) -> Self {
1089×
        self.notice = Some(n.into());
1099×
        self
1109×
    }
111
1124×
    fn error(mut self, e: impl Into<String>) -> Self {
1134×
        self.error = Some(e.into());
1144×
        self
1154×
    }
116
}
117
11819×
pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> {
11919×
    let shell = Shell::new(&app, "/settings", &viewer);
12019×
    let me = require(&viewer, &shell)?;
12114×
    Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await))
12219×
}
123
124
/// `POST /settings/avatar`: upload or remove the profile picture.
1254×
pub async fn avatar(
1264×
    State(app): State<Shared>,
1274×
    viewer: Viewer,
1284×
    headers: HeaderMap,
1294×
    multipart: Multipart,
1304×
) -> Result<Page<SettingsPage>, Error> {
1314×
    let shell = Shell::new(&app, "/settings", &viewer);
1324×
    let me = require(&viewer, &shell)?;
1334×
    let upload = super::avatar::read_upload(multipart).await;
1344×
    csrf(&viewer, &upload.csrf, &shell)?;
1353×
    let result = super::avatar::apply(&app, "account", me.account.id, upload).await;
1363×
    if result.is_ok() {
1372×
        accounts::audit(&app.db, Some(&me.account), "profile.picture", &me.account.username, "").await.ok();
1381×
    }
1393×
    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);
1403×
    let page = SettingsPage::new(shell, fresh, &headers).with_hub(&app).await;
1413×
    Ok(Page(match result {
1422×
        Ok(m) => page.notice(m),
1431×
        Err(e) => page.error(e),
144
    }))
1454×
}
146
147
/// `POST /settings/email/verify`: send a new verification link.
1481×
pub async fn resend_verification(
1491×
    State(app): State<Shared>,
1501×
    viewer: Viewer,
1511×
    headers: HeaderMap,
1521×
    Form(f): Form<super::session::CsrfForm>,
1531×
) -> Result<Page<SettingsPage>, Error> {
1541×
    let shell = Shell::new(&app, "/settings", &viewer);
1551×
    let me = require(&viewer, &shell)?;
1561×
    csrf(&viewer, &f.csrf, &shell)?;
1571×
    let page = SettingsPage::new(shell, me.account.clone(), &headers).with_hub(&app).await;
1581×
    Ok(Page(match super::recovery::send_verification(&app, &me.account).await {
1590×
        Ok(()) if me.account.email_verified => page.notice("Your email is already verified."),
1600×
        Ok(()) => page.notice(format!("A verification link is on its way to {}.", me.account.email)),
1611×
        Err(e) => page.error(e),
162
    }))
1631×
}
164
165
#[derive(Deserialize)]
166
pub struct AppearanceForm {
167
    #[serde(rename = "_csrf")]
168
    csrf_token: String,
169
    theme: String,
170
    #[serde(default)]
171
    backdrop: String,
172
}
173
174
/// Remember the theme and the backdrop in cookies for a year; the page
175
/// scripts apply them.
1765×
pub async fn appearance(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AppearanceForm>) -> Result<Response, Error> {
1775×
    let shell = Shell::new(&app, "/settings", &viewer);
1785×
    require(&viewer, &shell)?;
1795×
    csrf(&viewer, &f.csrf_token, &shell)?;
1807×
    let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else {
1811×
        return Err(Error::bad(shell, "Pick dark, light or match the system."));
182
    };
1833×
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
1843×
    let backdrop = if f.backdrop.is_empty() { "moving" } else { f.backdrop.as_str() };
1856×
    let Some((backdrop, _)) = BACKDROPS.iter().find(|(b, _)| *b == backdrop) else {
1861×
        return Err(Error::bad(shell, "Pick a moving, still or no background."));
187
    };
1882×
    Ok((
1892×
        StatusCode::SEE_OTHER,
1902×
        axum::response::AppendHeaders([
1912×
            (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")),
1922×
            (header::SET_COOKIE, format!("backdrop={backdrop}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")),
1932×
            (header::LOCATION, "/settings".to_string()),
1942×
        ]),
1952×
    )
1962×
        .into_response())
1975×
}
198
199
#[derive(Deserialize)]
200
pub struct ProfileForm {
201
    #[serde(rename = "_csrf")]
202
    csrf_token: String,
203
    display_name: String,
204
    email: String,
205
    #[serde(default)]
206
    bio: String,
207
    #[serde(default)]
208
    website: String,
209
    #[serde(default)]
210
    location: String,
211
    #[serde(default)]
212
    bluesky: String,
213
    #[serde(default)]
214
    mastodon: String,
215
    #[serde(default)]
216
    github: String,
217
}
218
2199×
pub async fn update_profile(
2209×
    State(app): State<Shared>,
2219×
    viewer: Viewer,
2229×
    headers: HeaderMap,
2239×
    Form(f): Form<ProfileForm>,
2249×
) -> Result<Page<SettingsPage>, Error> {
2259×
    let shell = Shell::new(&app, "/settings", &viewer);
2269×
    let me = require(&viewer, &shell)?;
2279×
    csrf(&viewer, &f.csrf_token, &shell)?;
2288×
    let website = f.website.trim();
2298×
    if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 {
230
        return Ok(Page(
2311×
            SettingsPage::new(shell, me.account, &headers)
2321×
                .with_hub(&app)
2331×
                .await
2341×
                .error("The website must be an http:// or https:// address."),
235
        ));
2367×
    }
237
    // Accounts elsewhere: checked and normalised, or refused by name.
23819×
    let link = |given: &str, what: &str, parse: fn(&str) -> Option<String>| -> Result<String, String> {
23919×
        if given.trim().is_empty() {
24015×
            Ok(String::new())
241
        } else {
2424×
            parse(given).ok_or_else(|| format!("That is not a {what}."))
243
        }
24419×
    };
2457×
    let links = (|| {
246
        Ok::<_, String>((
2477×
            link(&f.bluesky, "Bluesky handle (such as andy.bsky.social)", crate::social::bluesky)?,
2486×
            link(&f.mastodon, "Mastodon address (such as @[email protected])", crate::social::mastodon)?,
2496×
            link(&f.github, "GitHub username", crate::social::github)?,
250
        ))
251
    })();
2527×
    let (bluesky, mastodon, github) = match links {
2536×
        Ok(l) => l,
2541×
        Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(&msg))),
255
    };
2566×
    let bio: String = f.bio.trim().chars().take(280).collect();
2576×
    let location: String = f.location.trim().chars().take(80).collect();
2586×
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
2596×
        .await
2606×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
2616×
    accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2626×
    accounts::set_links(&app.db, me.account.id, &bluesky, &mastodon, &github).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2636×
    let changed = me.account.email != f.email.trim();
2646×
    let updated =
2656×
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
2666×
    let mut notice = "Profile saved.".to_string();
2676×
    if changed && !updated.email.is_empty() {
2684×
        accounts::audit(&app.db, Some(&updated), "email.changed", &updated.username, "").await.ok();
2694×
        match super::recovery::send_verification(&app, &updated).await {
2703×
            Ok(()) => notice = format!("Profile saved. A verification link is on its way to {}.", updated.email),
2711×
            Err(e) => notice = format!("Profile saved. {e}"),
272
        }
2732×
    }
2746×
    Ok(Page(SettingsPage::new(shell, updated, &headers).with_hub(&app).await.notice(notice)))
2759×
}
276
277
#[derive(Deserialize)]
278
pub struct PasswordForm {
279
    #[serde(rename = "_csrf")]
280
    csrf_token: String,
281
    current: String,
282
    password: String,
283
}
284
285
#[derive(Deserialize)]
286
pub struct BookmarkForm {
287
    #[serde(rename = "_csrf")]
288
    csrf_token: String,
289
    #[serde(default)]
290
    default_bookmark: String,
291
}
292
293
/// `POST /settings/repositories` (oxygenhub#371): the default bookmark of
294
/// the repositories they make; empty, the site's.
2951×
pub async fn repositories(
2961×
    State(app): State<Shared>,
2971×
    viewer: Viewer,
2981×
    headers: HeaderMap,
2991×
    Form(f): Form<BookmarkForm>,
3001×
) -> Result<Page<SettingsPage>, Error> {
3011×
    let shell = Shell::new(&app, "/settings", &viewer);
3021×
    let me = require(&viewer, &shell)?;
3031×
    csrf(&viewer, &f.csrf_token, &shell)?;
3041×
    let scope = crate::repo_setup::BookmarkScope::Account(me.account.id);
3051×
    let page = match crate::repo_setup::set_bookmark_setting(&app.db, scope, &f.default_bookmark).await {
306
        Ok(()) => {
3071×
            accounts::audit(&app.db, Some(&me.account), "account.default_bookmark", &me.account.username, f.default_bookmark.trim())
3081×
                .await
3091×
                .ok();
3101×
            SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.notice("Saved: new repositories start with that bookmark.")
311
        }
3120×
        Err(m) => SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(&m),
313
    };
3141×
    Ok(Page(page))
3151×
}
316
3170×
pub async fn change_password(
3180×
    State(app): State<Shared>,
3190×
    viewer: Viewer,
3200×
    headers: HeaderMap,
3210×
    Form(f): Form<PasswordForm>,
3220×
) -> Result<Response, Error> {
3230×
    let shell = Shell::new(&app, "/settings", &viewer);
3240×
    let me = require(&viewer, &shell)?;
3250×
    csrf(&viewer, &f.csrf_token, &shell)?;
3260×
    if !auth::verify_password(&me.account.password_hash, &f.current) {
3270×
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error("Your current password is wrong."))
3280×
            .into_response());
3290×
    }
3300×
    if let Err(e) = auth::password_ok(&f.password) {
3310×
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(e)).into_response());
3320×
    }
3330×
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
334
    // A new session salt signs out every other session; this one is renewed.
3350×
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
3360×
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
3370×
    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);
3380×
    Ok(super::session::signed_in(&app, &fresh, "/settings/security").await)
3390×
}
340
341
#[derive(Template)]
342
#[template(path = "settings_security.html")]
343
pub struct SecurityPage {
344
    shell: Shell,
345
    me: accounts::Account,
346
    notice: Option<String>,
347
    error: Option<String>,
348
    /// Enrolment in progress: secret, URI and QR.
349
    enrol: Option<(String, String, String)>,
350
    recovery: Vec<String>,
351
    /// Providers on this hub and the login connected, if any (#106).
352
    connected: Vec<(&'static str, &'static str, Option<String>)>,
353
    /// Where the account is signed in (oxygenhub#223).
354
    sessions: Vec<SessionView>,
355
    /// Phones that approve as a second factor (oxygenhub#356).
356
    phones: Vec<crate::db::approvals::Authenticator>,
357
}
358
359
/// One signed-in browser, as Settings › Security lists it.
360
pub struct SessionView {
361
    pub id: i64,
362
    pub current: bool,
363
    pub browser: String,
364
    pub address: String,
365
    pub seen_ms: i64,
366
    pub created_ms: i64,
367
}
368
369
/// "Firefox on Linux" from a User-Agent; "Unknown browser" when it says nothing useful.
37022×
pub fn browser_of(agent: &str) -> String {
371303×
    let has = |s: &str| agent.contains(s);
37222×
    let browser = if has("Edg/") {
3731×
        "Edge"
37421×
    } else if has("OPR/") {
3750×
        "Opera"
37621×
    } else if has("Firefox/") {
3771×
        "Firefox"
37820×
    } else if has("Chrome/") || has("Chromium/") {
3791×
        "Chrome"
38019×
    } else if has("Safari/") {
3811×
        "Safari"
38218×
    } else if has("curl/") {
3830×
        "curl"
384
    } else {
38518×
        ""
386
    };
38722×
    let os = if has("Android") {
3880×
        "Android"
38922×
    } else if has("iPhone") || has("iPad") {
3901×
        "iOS"
39121×
    } else if has("Windows") {
3921×
        "Windows"
39320×
    } else if has("Mac OS X") || has("Macintosh") {
3941×
        "macOS"
39519×
    } else if has("CrOS") {
3960×
        "ChromeOS"
39719×
    } else if has("Linux") {
3981×
        "Linux"
399
    } else {
40018×
        ""
401
    };
40222×
    match (browser, os) {
40322×
        ("", "") => "Unknown browser".into(),
4044×
        (b, "") => b.into(),
4054×
        ("", o) => format!("A browser on {o}"),
4064×
        (b, o) => format!("{b} on {o}"),
407
    }
40822×
}
409
41010×
async fn sessions_of(app: &Shared, me: &crate::auth::ViewerAccount) -> sqlx::Result<Vec<SessionView>> {
41110×
    Ok(crate::db::sessions::of_account(&app.db, me.account.id, &me.account.session_salt)
41210×
        .await?
41310×
        .into_iter()
41410×
        .map(|s| SessionView {
41514×
            id: s.id,
41614×
            current: s.id == me.session_id,
41714×
            browser: browser_of(&s.agent),
41814×
            address: s.address,
41914×
            seen_ms: s.seen_ms,
42014×
            created_ms: s.created_ms,
42114×
        })
42210×
        .collect())
42310×
}
424
425
#[derive(Deserialize)]
426
pub struct SecurityQuery {
427
    notice: Option<String>,
428
    error: Option<String>,
429
}
430
43114×
pub async fn security(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SecurityQuery>) -> Result<Page<SecurityPage>, Error> {
43214×
    let shell = Shell::new(&app, "/settings/security", &viewer);
43314×
    let me = require(&viewer, &shell)?;
43410×
    let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
43510×
    let sessions = sessions_of(&app, &me).await.map_err(|e| Error::from_db(shell.clone(), e))?;
43610×
    let phones = crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
43710×
    Ok(Page(SecurityPage {
43810×
        shell,
43910×
        me: me.account,
44010×
        notice: q.notice,
44110×
        error: q.error,
44210×
        enrol: None,
44310×
        recovery: vec![],
44410×
        connected,
44510×
        sessions,
44610×
        phones,
44710×
    }))
44814×
}
449
450
/// Each sign-in provider this hub offers, with the login connected to the account.
45110×
pub async fn connected_of(app: &Shared, account_id: i64) -> sqlx::Result<Vec<(&'static str, &'static str, Option<String>)>> {
45210×
    let links = crate::signin::links_of(&app.db, account_id).await?;
45310×
    let mut out = vec![];
45410×
    for p in crate::signin::enabled(app).await {
4556×
        out.push((p.id, p.name, links.iter().find(|l| l.provider == p.id).map(|l| l.login.clone())));
456
    }
45710×
    Ok(out)
45810×
}
459
460
#[derive(Deserialize)]
461
pub struct TotpForm {
462
    #[serde(rename = "_csrf")]
463
    csrf_token: String,
464
    #[serde(default)]
465
    secret: String,
466
    #[serde(default)]
467
    code: String,
468
    #[serde(default)]
469
    password: String,
470
}
471
472
/// Step one: show a fresh secret to scan.
4732×
pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
4742×
    let shell = Shell::new(&app, "/settings/security", &viewer);
4752×
    let me = require(&viewer, &shell)?;
4762×
    csrf(&viewer, &f.csrf_token, &shell)?;
4772×
    let secret = totp::new_secret();
4782×
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
4792×
    let qr = totp::qr_svg(&uri).unwrap_or_default();
4802×
    Ok(Page(SecurityPage {
4812×
        shell,
4822×
        me: me.account,
4832×
        notice: None,
4842×
        error: None,
4852×
        enrol: Some((secret, uri, qr)),
4862×
        recovery: vec![],
4872×
        connected: vec![],
4882×
        sessions: vec![],
4892×
        phones: vec![],
4902×
    }))
4912×
}
492
493
/// Step two: the first code proves the app holds the secret.
4942×
pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
4952×
    let shell = Shell::new(&app, "/settings/security", &viewer);
4962×
    let me = require(&viewer, &shell)?;
4972×
    csrf(&viewer, &f.csrf_token, &shell)?;
4982×
    let now = (db::now_ms() / 1000) as u64;
4992×
    if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) {
5000×
        let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret);
5010×
        let qr = totp::qr_svg(&uri).unwrap_or_default();
5020×
        return Ok(Page(SecurityPage {
5030×
            shell,
5040×
            me: me.account,
5050×
            notice: None,
5060×
            error: Some("That code did not match; scan again and enter the current code.".into()),
5070×
            enrol: Some((f.secret, uri, qr)),
5080×
            recovery: vec![],
5090×
            connected: vec![],
5100×
            sessions: vec![],
5110×
            phones: vec![],
5120×
        }));
5132×
    }
5142×
    let (codes, stored) = totp::new_recovery_codes();
5152×
    accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5162×
    accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok();
5172×
    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);
5182×
    Ok(Page(SecurityPage {
5192×
        shell,
5202×
        me: fresh,
5212×
        notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()),
5222×
        error: None,
5232×
        enrol: None,
5242×
        recovery: codes,
5252×
        connected: vec![],
5262×
        sessions: vec![],
5272×
        phones: vec![],
5282×
    }))
5292×
}
530
5311×
pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
5321×
    let shell = Shell::new(&app, "/settings/security", &viewer);
5331×
    let me = require(&viewer, &shell)?;
5341×
    csrf(&viewer, &f.csrf_token, &shell)?;
5351×
    if !auth::verify_password(&me.account.password_hash, &f.password) {
5360×
        return Ok(Page(SecurityPage {
5370×
            shell,
5380×
            me: me.account,
5390×
            notice: None,
5400×
            error: Some("Your password is wrong.".into()),
5410×
            enrol: None,
5420×
            recovery: vec![],
5430×
            connected: vec![],
5440×
            sessions: vec![],
5450×
            phones: vec![],
5460×
        }));
5471×
    }
5481×
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
5491×
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
550
    // A phone stands beside the app's codes, never alone (oxygenhub#356).
5511×
    for p in crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
5521×
        crate::db::approvals::remove_authenticator(&app.db, me.account.id, p.token_id)
5531×
            .await
5541×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
555
    }
5561×
    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);
5571×
    Ok(Page(SecurityPage {
5581×
        shell,
5591×
        me: fresh,
5601×
        notice: Some("Two-factor authentication is off, and no phone approves for you now.".into()),
5611×
        error: None,
5621×
        enrol: None,
5631×
        recovery: vec![],
5641×
        connected: vec![],
5651×
        sessions: vec![],
5661×
        phones: vec![],
5671×
    }))
5681×
}
569
570
/// Sign out everywhere: a new session salt invalidates every cookie, then
571
/// this browser gets a fresh one.
5721×
pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> {
5731×
    let shell = Shell::new(&app, "/settings/security", &viewer);
5741×
    let me = require(&viewer, &shell)?;
5751×
    csrf(&viewer, &f.csrf, &shell)?;
5761×
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5771×
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
5781×
    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);
5791×
    Ok(super::session::signed_in(&app, &fresh, "/settings/security").await)
5801×
}
581
582
/// `POST /settings/security/sessions/{id}/end`: sign one browser out.
5831×
pub async fn session_end(
5841×
    State(app): State<Shared>,
5851×
    viewer: Viewer,
5861×
    Path(id): Path<i64>,
5871×
    Form(f): Form<super::session::CsrfForm>,
5881×
) -> Result<Response, Error> {
5891×
    let shell = Shell::new(&app, "/settings/security", &viewer);
5901×
    let me = require(&viewer, &shell)?;
5911×
    csrf(&viewer, &f.csrf, &shell)?;
5921×
    let ended = crate::db::sessions::end(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5931×
    if ended {
5941×
        accounts::audit(&app.db, Some(&me.account), "session.ended", &me.account.username, &id.to_string()).await.ok();
5950×
    }
5961×
    let to = if id == me.session_id { "/" } else { "/settings/security?notice=That+browser+is+signed+out." };
5971×
    Ok(Redirect::to(to).into_response())
5981×
}
599
600
/// `POST /settings/security/phones/{id}/remove` (oxygenhub#356): that
601
/// phone no longer approves for the account.
6020×
pub async fn phone_remove(
6030×
    State(app): State<Shared>,
6040×
    viewer: Viewer,
6050×
    Path(id): Path<i64>,
6060×
    Form(f): Form<super::session::CsrfForm>,
6070×
) -> Result<Response, Error> {
6080×
    let shell = Shell::new(&app, "/settings/security", &viewer);
6090×
    let me = require(&viewer, &shell)?;
6100×
    csrf(&viewer, &f.csrf, &shell)?;
6110×
    let removed =
6120×
        crate::db::approvals::remove_authenticator(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6130×
    if removed {
6140×
        accounts::audit(&app.db, Some(&me.account), "2fa.phone_removed", &me.account.username, &id.to_string()).await.ok();
6150×
    }
6160×
    Ok(Redirect::to("/settings/security?notice=That+phone+no+longer+approves+for+you.#phones").into_response())
6170×
}
618
619
// ------------------------------------------------------------------- keys
620
621
#[derive(Template)]
622
#[template(path = "settings_keys.html")]
623
pub struct KeysPage {
624
    shell: Shell,
625
    me: accounts::Account,
626
    keys: Vec<accounts::LinkedIdentity>,
627
    notice: Option<String>,
628
    error: Option<String>,
629
    /// A pending challenge: id, hex bytes, identity name.
630
    pending: Option<(i64, String, String)>,
631
}
632
6339×
async fn keys_page(
6349×
    app: &Shared,
6359×
    shell: Shell,
6369×
    me: accounts::Account,
6379×
    notice: Option<String>,
6389×
    error: Option<String>,
6399×
    pending: Option<(i64, String, String)>,
6409×
) -> Result<Page<KeysPage>, Error> {
6419×
    let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6429×
    Ok(Page(KeysPage { shell, me, keys, notice, error, pending }))
6439×
}
644
6453×
pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> {
6463×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
6473×
    let me = require(&viewer, &shell)?;
6483×
    keys_page(&app, shell, me.account, None, None, None).await
6493×
}
650
651
/// Upload an identity object (`oxy identity export`): the hub issues a
652
/// challenge to sign with that key.
6533×
pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> {
6543×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
6553×
    let me = require(&viewer, &shell)?;
6563×
    let mut token = String::new();
6573×
    let mut object: Vec<u8> = Vec::new();
6589×
    while let Ok(Some(field)) = multipart.next_field().await {
6596×
        match field.name() {
6606×
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
6613×
            Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
6620×
            _ => {}
663
        }
664
    }
6653×
    csrf(&viewer, &token, &shell)?;
6663×
    let identity = match Identity::decode(&object) {
6673×
        Ok(i) => i,
6680×
        Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await,
669
    };
6703×
    if identity.hash() == app.hub_key.key {
6711×
        let msg = "That is this hub's own key, which only the hub holds. Upload your identity: after `oxy identity delegate add` with the hub's key, run `oxy identity export` in that checkout and upload the file it writes.";
6721×
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
6732×
    }
6742×
    let key = identity.hash().to_hex();
6752×
    if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? {
6760×
        let msg = if existing.account_id == me.account.id {
6770×
            "That key is already linked to you. If you have delegated since (`oxy identity delegate add`), export again: a delegation makes a new version of your identity."
678
        } else {
6790×
            "That key is linked to another account."
680
        };
6810×
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
6822×
    }
6832×
    let bytes = auth::random_hex(32);
6842×
    let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6852×
    sqlx::query("UPDATE challenges SET object = $1 WHERE id = $2")
6862×
        .bind(&object)
6872×
        .bind(id)
6882×
        .execute(&app.db)
6892×
        .await
6902×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
6912×
    keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await
6923×
}
693
694
#[derive(Deserialize)]
695
pub struct KeyVerifyForm {
696
    #[serde(rename = "_csrf")]
697
    csrf_token: String,
698
    challenge: i64,
699
    signature: String,
700
}
701
7023×
pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> {
7033×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
7043×
    let me = require(&viewer, &shell)?;
7053×
    csrf(&viewer, &f.csrf_token, &shell)?;
7063×
    let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = $1 AND account_id = $2")
7073×
        .bind(f.challenge)
7083×
        .bind(me.account.id)
7093×
        .fetch_optional(&app.db)
7103×
        .await
7113×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
7123×
    let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7133×
    let (Some(object), Some(bytes)) = (object, bytes) else {
7141×
        return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await;
715
    };
7162×
    let Ok(identity) = Identity::decode(&object) else {
7170×
        return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await;
718
    };
7192×
    let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default();
7202×
    let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default();
7212×
    let challenge = hex::decode(&bytes).unwrap_or_default();
7222×
    let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok());
7232×
    if !ok {
724
        // Signed by another key than the one uploaded: say which, as it is
725
        // usually the wrong checkout or an `author.signer` set there.
7261×
        let msg = match sigs.first() {
7271×
            Some(sig) if sig.key != identity.hash() => format!(
728
                "That signature is by key {}, but the identity uploaded is {} ({}). Sign in a checkout whose `oxy identity export` gives that identity, without an `author.signer` set, or upload the identity that checkout exports.",
7291×
                sig.key.short(12),
7301×
                identity.hash().short(12),
731
                identity.name
732
            ),
7330×
            _ => "The signature does not verify against that identity and challenge.".into(),
734
        };
7351×
        return keys_page(&app, shell, me.account, None, Some(msg), None).await;
7361×
    }
7371×
    let key = identity.hash().to_hex();
7381×
    accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object)
7391×
        .await
7401×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
7411×
    accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok();
7421×
    keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await
7433×
}
744
745
#[derive(Deserialize)]
746
pub struct KeyForm {
747
    #[serde(rename = "_csrf")]
748
    csrf_token: String,
749
    key: String,
750
}
751
7520×
pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> {
7530×
    let shell = Shell::new(&app, "/settings/keys", &viewer);
7540×
    let me = require(&viewer, &shell)?;
7550×
    csrf(&viewer, &f.csrf_token, &shell)?;
7560×
    accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7570×
    accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok();
7580×
    keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await
7590×
}
760
761
// ----------------------------------------------------------------- tokens
762
763
#[derive(Template)]
764
#[template(path = "settings_tokens.html")]
765
pub struct TokensPage {
766
    shell: Shell,
767
    scopes: &'static [(&'static str, &'static str)],
768
    me: accounts::Account,
769
    tokens: Vec<accounts::Token>,
770
    /// A freshly created token, shown once.
771
    fresh: Option<String>,
772
    notice: Option<String>,
773
    error: Option<String>,
774
}
775
77650×
async fn tokens_page(
77750×
    app: &Shared,
77850×
    shell: Shell,
77950×
    me: accounts::Account,
78050×
    fresh: Option<String>,
78150×
    notice: Option<String>,
78250×
    error: Option<String>,
78350×
) -> Result<Page<TokensPage>, Error> {
78450×
    let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
78550×
    Ok(Page(TokensPage { shell, scopes: SCOPES, me, tokens, fresh, notice, error }))
78650×
}
787
78825×
pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> {
78925×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
79025×
    let me = require(&viewer, &shell)?;
79125×
    tokens_page(&app, shell, me.account, None, None, None).await
79225×
}
793
794
#[derive(Deserialize)]
795
pub struct TokenForm {
796
    #[serde(rename = "_csrf")]
797
    csrf_token: String,
798
    name: String,
799
    #[serde(default)]
800
    scopes: Vec<String>,
801
    #[serde(default)]
802
    repos: String,
803
    #[serde(default)]
804
    expires_days: String,
805
    /// An agent uses it: tell me what it does (oxygenhub#183).
806
    #[serde(default)]
807
    agent: String,
808
}
809
810
/// Every scope the hub checks, with what it grants. The tokens page lists
811
/// exactly these, so a scope cannot exist without a checkbox.
812
pub const SCOPES: &[(&str, &str)] = &[
813
    ("repo:read", "read repositories you can see: clone, fetch, pull, and the read API"),
814
    ("repo:write", "push to repositories you can write to; also covers proposals, issues and checks"),
815
    ("repo:admin", "create repositories and change settings through the API"),
816
    ("proposals:write", "open, comment on and review proposals, without push access"),
817
    ("issues:write", "open, comment on and close issues, without push access"),
818
    ("checks:write", "report checks on states, for CI systems, without push access"),
819
    ("hooks:admin", "manage webhooks through the API"),
820
    ("user:read", "read your own profile through the API"),
821
];
822
82325×
pub async fn tokens_create(
82425×
    State(app): State<Shared>,
82525×
    viewer: Viewer,
82625×
    axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>,
82725×
) -> Result<Page<TokensPage>, Error> {
82825×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
82925×
    let me = require(&viewer, &shell)?;
83025×
    csrf(&viewer, &f.csrf_token, &shell)?;
83125×
    let name = f.name.trim();
83225×
    if name.is_empty() {
8330×
        return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await;
83425×
    }
83592×
    let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect();
83625×
    if scopes.is_empty() {
8370×
        return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await;
83825×
    }
83925×
    let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000);
84025×
    let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(",");
84125×
    let (secret, hash) = auth::new_token();
84225×
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
84325×
        .await
84425×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
84525×
    if !f.agent.is_empty() {
8460×
        if let Ok(Some(t)) = accounts::token_by_hash(&app.db, &hash).await {
8470×
            accounts::set_token_agent(&app.db, me.account.id, t.id, true).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8480×
        }
84925×
    }
85025×
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
85125×
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
85225×
}
853
854
/// Mark a token as an agent's, or not (oxygenhub#183): what is done with
855
/// an agent's token is told to its owner, as anyone else's doing is.
8560×
pub async fn tokens_agent(
8570×
    State(app): State<Shared>,
8580×
    viewer: Viewer,
8590×
    Path(id): Path<i64>,
8600×
    Form(f): Form<super::session::CsrfForm>,
8610×
) -> Result<Page<TokensPage>, Error> {
8620×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
8630×
    let me = require(&viewer, &shell)?;
8640×
    csrf(&viewer, &f.csrf, &shell)?;
8650×
    let tokens = accounts::tokens_of(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8660×
    let Some(t) = tokens.iter().find(|t| t.id == id) else {
8670×
        return tokens_page(&app, shell, me.account, None, None, Some("No such token.".into())).await;
868
    };
8690×
    accounts::set_token_agent(&app.db, me.account.id, id, !t.agent).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8700×
    accounts::audit(&app.db, Some(&me.account), "token.agent", &t.name, if t.agent { "off" } else { "on" }).await.ok();
8710×
    let notice = if t.agent {
8720×
        format!("{} is no longer marked as an agent's.", t.name)
873
    } else {
8740×
        format!("{} is an agent's: you will hear what it does.", t.name)
875
    };
8760×
    tokens_page(&app, shell, me.account, None, Some(notice), None).await
8770×
}
878
8790×
pub async fn tokens_delete(
8800×
    State(app): State<Shared>,
8810×
    viewer: Viewer,
8820×
    Path(id): Path<i64>,
8830×
    Form(f): Form<super::session::CsrfForm>,
8840×
) -> Result<Page<TokensPage>, Error> {
8850×
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
8860×
    let me = require(&viewer, &shell)?;
8870×
    csrf(&viewer, &f.csrf, &shell)?;
8880×
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8890×
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
8900×
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
8910×
}
892
893
#[cfg(test)]
894
mod tests {
895
    use super::browser_of;
896
897
    #[test]
8981×
    fn browsers_are_named_from_their_agent() {
8991×
        let ff = "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0";
9001×
        let chrome_mac =
9011×
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36";
9021×
        let edge = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36 Edg/129.0";
9031×
        let iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1";
9041×
        assert_eq!(browser_of(ff), "Firefox on Linux");
9051×
        assert_eq!(browser_of(chrome_mac), "Chrome on macOS");
9061×
        assert_eq!(browser_of(edge), "Edge on Windows");
9071×
        assert_eq!(browser_of(iphone), "Safari on iOS");
9081×
        assert_eq!(browser_of(""), "Unknown browser");
9091×
    }
910
}