//! Account settings (hub spec §6.3 `/settings/*`): profile, security, //! linked identities, tokens. use super::{filters, Error, Page, Shell}; use crate::auth::{self, totp, Viewer, ViewerAccount}; use crate::db::{self, accounts}; use crate::Shared; use askama::Template; use axum::extract::{Form, Multipart, Path, Query, State}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; use oxy_core::identity::{Identity, Signature}; use serde::Deserialize; /// The signed-in account, or a redirect to sign in. pub fn require(viewer: &Viewer, shell: &Shell) -> Result { viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path)))) } pub fn urlencode(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out } pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> { if viewer.csrf_ok(token) { Ok(()) } else { Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again.")) } } #[derive(Template)] #[template(path = "settings.html")] pub struct SettingsPage { shell: Shell, me: accounts::Account, notice: Option, error: Option, /// The theme cookie's value, for the Appearance form. theme: &'static str, themes: &'static [(&'static str, &'static str)], /// The backdrop cookie's value and the choices (docs/backgrounds.md §7). backdrop: &'static str, backdrops: &'static [(&'static str, &'static str)], /// Whether the hub sends mail, for the "send a new link" button. mail: bool, } /// The themes (hub spec §6.1): the cookie value and its label. const THEMES: &[(&str, &str)] = &[("light", "Light"), ("dark", "Dark"), ("auto", "Match the system")]; /// Backdrops (docs/backgrounds.md §7): the cookie value and its label. const BACKDROPS: &[(&str, &str)] = &[("moving", "Moving"), ("still", "Still"), ("off", "Off")]; /// The backdrop the request's cookie asks for; moving when unset. fn backdrop_of(headers: &HeaderMap) -> &'static str { let v = auth::viewer::cookie(headers, "backdrop").unwrap_or_default(); BACKDROPS.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("moving") } /// The theme the request's cookie asks for; light when unset or unknown. fn theme_of(headers: &HeaderMap) -> &'static str { let v = auth::viewer::cookie(headers, "theme").unwrap_or_default(); THEMES.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("light") } impl SettingsPage { fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage { SettingsPage { shell, me, notice: None, error: None, theme: theme_of(headers), themes: THEMES, backdrop: backdrop_of(headers), backdrops: BACKDROPS, mail: false, } } fn with_mail(mut self, app: &Shared) -> Self { self.mail = crate::mail::enabled(app); self } fn notice(mut self, n: impl Into) -> Self { self.notice = Some(n.into()); self } fn error(mut self, e: impl Into) -> Self { self.error = Some(e.into()); self } } pub async fn profile(State(app): State, viewer: Viewer, headers: HeaderMap) -> Result, Error> { let shell = Shell::new(&app, "/settings", &viewer); let me = require(&viewer, &shell)?; Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app))) } /// `POST /settings/avatar`: upload or remove the profile picture. pub async fn avatar( State(app): State, viewer: Viewer, headers: HeaderMap, multipart: Multipart, ) -> Result, Error> { let shell = Shell::new(&app, "/settings", &viewer); let me = require(&viewer, &shell)?; let upload = super::avatar::read_upload(multipart).await; csrf(&viewer, &upload.csrf, &shell)?; let result = super::avatar::apply(&app, "account", me.account.id, upload).await; if result.is_ok() { accounts::audit(&app.db, Some(&me.account), "profile.picture", &me.account.username, "").await.ok(); } 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); let page = SettingsPage::new(shell, fresh, &headers).with_mail(&app); Ok(Page(match result { Ok(m) => page.notice(m), Err(e) => page.error(e), })) } /// `POST /settings/email/verify`: send a new verification link. pub async fn resend_verification( State(app): State, viewer: Viewer, headers: HeaderMap, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/settings", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let page = SettingsPage::new(shell, me.account.clone(), &headers).with_mail(&app); Ok(Page(match super::recovery::send_verification(&app, &me.account).await { Ok(()) if me.account.email_verified => page.notice("Your email is already verified."), Ok(()) => page.notice(format!("A verification link is on its way to {}.", me.account.email)), Err(e) => page.error(e), })) } #[derive(Deserialize)] pub struct AppearanceForm { #[serde(rename = "_csrf")] csrf_token: String, theme: String, #[serde(default)] backdrop: String, } /// Remember the theme and the backdrop in cookies for a year; the page /// scripts apply them. pub async fn appearance(State(app): State, viewer: Viewer, Form(f): Form) -> Result { let shell = Shell::new(&app, "/settings", &viewer); require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else { return Err(Error::bad(shell, "Pick dark, light or match the system.")); }; let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; let backdrop = if f.backdrop.is_empty() { "moving" } else { f.backdrop.as_str() }; let Some((backdrop, _)) = BACKDROPS.iter().find(|(b, _)| *b == backdrop) else { return Err(Error::bad(shell, "Pick a moving, still or no background.")); }; Ok(( StatusCode::SEE_OTHER, axum::response::AppendHeaders([ (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")), (header::SET_COOKIE, format!("backdrop={backdrop}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")), (header::LOCATION, "/settings".to_string()), ]), ) .into_response()) } #[derive(Deserialize)] pub struct ProfileForm { #[serde(rename = "_csrf")] csrf_token: String, display_name: String, email: String, #[serde(default)] bio: String, #[serde(default)] website: String, #[serde(default)] location: String, #[serde(default)] bluesky: String, #[serde(default)] mastodon: String, #[serde(default)] github: String, } pub async fn update_profile( State(app): State, viewer: Viewer, headers: HeaderMap, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/settings", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let website = f.website.trim(); if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 { return Ok(Page( SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."), )); } // Accounts elsewhere: checked and normalised, or refused by name. let link = |given: &str, what: &str, parse: fn(&str) -> Option| -> Result { if given.trim().is_empty() { Ok(String::new()) } else { parse(given).ok_or_else(|| format!("That is not a {what}.")) } }; let links = (|| { Ok::<_, String>(( link(&f.bluesky, "Bluesky handle (such as andy.bsky.social)", crate::social::bluesky)?, link(&f.mastodon, "Mastodon address (such as @andy@mastodon.social)", crate::social::mastodon)?, link(&f.github, "GitHub username", crate::social::github)?, )) })(); let (bluesky, mastodon, github) = match links { Ok(l) => l, Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(&msg))), }; let bio: String = f.bio.trim().chars().take(280).collect(); let location: String = f.location.trim().chars().take(80).collect(); accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim()) .await .map_err(|e| Error::from_db(shell.clone(), e))?; accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::set_links(&app.db, me.account.id, &bluesky, &mastodon, &github).await.map_err(|e| Error::from_db(shell.clone(), e))?; let changed = me.account.email != f.email.trim(); let updated = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); let mut notice = "Profile saved.".to_string(); if changed && !updated.email.is_empty() { accounts::audit(&app.db, Some(&updated), "email.changed", &updated.username, "").await.ok(); match super::recovery::send_verification(&app, &updated).await { Ok(()) => notice = format!("Profile saved. A verification link is on its way to {}.", updated.email), Err(e) => notice = format!("Profile saved. {e}"), } } Ok(Page(SettingsPage::new(shell, updated, &headers).with_mail(&app).notice(notice))) } #[derive(Deserialize)] pub struct PasswordForm { #[serde(rename = "_csrf")] csrf_token: String, current: String, password: String, } pub async fn change_password( State(app): State, viewer: Viewer, headers: HeaderMap, Form(f): Form, ) -> Result { let shell = Shell::new(&app, "/settings", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; if !auth::verify_password(&me.account.password_hash, &f.current) { return Ok( Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("Your current password is wrong.")).into_response() ); } if let Err(e) = auth::password_ok(&f.password) { return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(e)).into_response()); } let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; // A new session salt signs out every other session; this one is renewed. accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok(); 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); Ok(super::session::signed_in(&app, &fresh, "/settings/security").await) } #[derive(Template)] #[template(path = "settings_security.html")] pub struct SecurityPage { shell: Shell, me: accounts::Account, notice: Option, error: Option, /// Enrolment in progress: secret, URI and QR. enrol: Option<(String, String, String)>, recovery: Vec, /// Providers on this hub and the login connected, if any (#106). connected: Vec<(&'static str, &'static str, Option)>, /// Where the account is signed in (oxygenhub#223). sessions: Vec, } /// One signed-in browser, as Settings › Security lists it. pub struct SessionView { pub id: i64, pub current: bool, pub browser: String, pub address: String, pub seen_ms: i64, pub created_ms: i64, } /// "Firefox on Linux" from a User-Agent; "Unknown browser" when it says nothing useful. pub fn browser_of(agent: &str) -> String { let has = |s: &str| agent.contains(s); let browser = if has("Edg/") { "Edge" } else if has("OPR/") { "Opera" } else if has("Firefox/") { "Firefox" } else if has("Chrome/") || has("Chromium/") { "Chrome" } else if has("Safari/") { "Safari" } else if has("curl/") { "curl" } else { "" }; let os = if has("Android") { "Android" } else if has("iPhone") || has("iPad") { "iOS" } else if has("Windows") { "Windows" } else if has("Mac OS X") || has("Macintosh") { "macOS" } else if has("CrOS") { "ChromeOS" } else if has("Linux") { "Linux" } else { "" }; match (browser, os) { ("", "") => "Unknown browser".into(), (b, "") => b.into(), ("", o) => format!("A browser on {o}"), (b, o) => format!("{b} on {o}"), } } async fn sessions_of(app: &Shared, me: &crate::auth::ViewerAccount) -> sqlx::Result> { Ok(crate::db::sessions::of_account(&app.db, me.account.id, &me.account.session_salt) .await? .into_iter() .map(|s| SessionView { id: s.id, current: s.id == me.session_id, browser: browser_of(&s.agent), address: s.address, seen_ms: s.seen_ms, created_ms: s.created_ms, }) .collect()) } #[derive(Deserialize)] pub struct SecurityQuery { notice: Option, error: Option, } pub async fn security(State(app): State, viewer: Viewer, Query(q): Query) -> Result, Error> { let shell = Shell::new(&app, "/settings/security", &viewer); let me = require(&viewer, &shell)?; let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let sessions = sessions_of(&app, &me).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected, sessions })) } /// Each sign-in provider this hub offers, with the login connected to the account. pub async fn connected_of(app: &Shared, account_id: i64) -> sqlx::Result)>> { let links = crate::signin::links_of(&app.db, account_id).await?; let mut out = vec![]; for p in crate::signin::enabled(app).await { out.push((p.id, p.name, links.iter().find(|l| l.provider == p.id).map(|l| l.login.clone()))); } Ok(out) } #[derive(Deserialize)] pub struct TotpForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] secret: String, #[serde(default)] code: String, #[serde(default)] password: String, } /// Step one: show a fresh secret to scan. pub async fn totp_begin(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/security", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let secret = totp::new_secret(); let uri = totp::uri(&app.config.site.name, &me.account.username, &secret); let qr = totp::qr_svg(&uri).unwrap_or_default(); Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![], connected: vec![], sessions: vec![], })) } /// Step two: the first code proves the app holds the secret. pub async fn totp_enable(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/security", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let now = (db::now_ms() / 1000) as u64; if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) { let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret); let qr = totp::qr_svg(&uri).unwrap_or_default(); return Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: Some("That code did not match; scan again and enter the current code.".into()), enrol: Some((f.secret, uri, qr)), recovery: vec![], connected: vec![], sessions: vec![], })); } let (codes, stored) = totp::new_recovery_codes(); accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok(); 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); Ok(Page(SecurityPage { shell, me: fresh, notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()), error: None, enrol: None, recovery: codes, connected: vec![], sessions: vec![], })) } pub async fn totp_disable(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/security", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; if !auth::verify_password(&me.account.password_hash, &f.password) { return Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: Some("Your password is wrong.".into()), enrol: None, recovery: vec![], connected: vec![], sessions: vec![], })); } accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok(); 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); Ok(Page(SecurityPage { shell, me: fresh, notice: Some("Two-factor authentication is off.".into()), error: None, enrol: None, recovery: vec![], connected: vec![], sessions: vec![], })) } /// Sign out everywhere: a new session salt invalidates every cookie, then /// this browser gets a fresh one. pub async fn sessions_reset(State(app): State, viewer: Viewer, Form(f): Form) -> Result { let shell = Shell::new(&app, "/settings/security", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok(); 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); Ok(super::session::signed_in(&app, &fresh, "/settings/security").await) } /// `POST /settings/security/sessions/{id}/end`: sign one browser out. pub async fn session_end( State(app): State, viewer: Viewer, Path(id): Path, Form(f): Form, ) -> Result { let shell = Shell::new(&app, "/settings/security", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let ended = crate::db::sessions::end(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; if ended { accounts::audit(&app.db, Some(&me.account), "session.ended", &me.account.username, &id.to_string()).await.ok(); } let to = if id == me.session_id { "/" } else { "/settings/security?notice=That+browser+is+signed+out." }; Ok(Redirect::to(to).into_response()) } // ------------------------------------------------------------------- keys #[derive(Template)] #[template(path = "settings_keys.html")] pub struct KeysPage { shell: Shell, me: accounts::Account, keys: Vec, notice: Option, error: Option, /// A pending challenge: id, hex bytes, identity name. pending: Option<(i64, String, String)>, } async fn keys_page( app: &Shared, shell: Shell, me: accounts::Account, notice: Option, error: Option, pending: Option<(i64, String, String)>, ) -> Result, Error> { let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(KeysPage { shell, me, keys, notice, error, pending })) } pub async fn keys(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/settings/keys", &viewer); let me = require(&viewer, &shell)?; keys_page(&app, shell, me.account, None, None, None).await } /// Upload an identity object (`oxy identity export`): the hub issues a /// challenge to sign with that key. pub async fn keys_upload(State(app): State, viewer: Viewer, mut multipart: Multipart) -> Result, Error> { let shell = Shell::new(&app, "/settings/keys", &viewer); let me = require(&viewer, &shell)?; let mut token = String::new(); let mut object: Vec = Vec::new(); while let Ok(Some(field)) = multipart.next_field().await { match field.name() { Some("_csrf") => token = field.text().await.unwrap_or_default(), Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(), _ => {} } } csrf(&viewer, &token, &shell)?; let identity = match Identity::decode(&object) { Ok(i) => i, Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await, }; if identity.hash() == app.hub_key.key { 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."; return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await; } let key = identity.hash().to_hex(); if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? { let msg = if existing.account_id == me.account.id { "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." } else { "That key is linked to another account." }; return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await; } let bytes = auth::random_hex(32); let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?; sqlx::query("UPDATE challenges SET object = $1 WHERE id = $2") .bind(&object) .bind(id) .execute(&app.db) .await .map_err(|e| Error::from_db(shell.clone(), e))?; keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await } #[derive(Deserialize)] pub struct KeyVerifyForm { #[serde(rename = "_csrf")] csrf_token: String, challenge: i64, signature: String, } pub async fn keys_verify(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/keys", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let object: Option> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = $1 AND account_id = $2") .bind(f.challenge) .bind(me.account.id) .fetch_optional(&app.db) .await .map_err(|e| Error::from_db(shell.clone(), e))?; let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?; let (Some(object), Some(bytes)) = (object, bytes) else { return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await; }; let Ok(identity) = Identity::decode(&object) else { return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await; }; let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default(); let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default(); let challenge = hex::decode(&bytes).unwrap_or_default(); let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok()); if !ok { // Signed by another key than the one uploaded: say which, as it is // usually the wrong checkout or an `author.signer` set there. let msg = match sigs.first() { Some(sig) if sig.key != identity.hash() => format!( "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.", sig.key.short(12), identity.hash().short(12), identity.name ), _ => "The signature does not verify against that identity and challenge.".into(), }; return keys_page(&app, shell, me.account, None, Some(msg), None).await; } let key = identity.hash().to_hex(); accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object) .await .map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok(); keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await } #[derive(Deserialize)] pub struct KeyForm { #[serde(rename = "_csrf")] csrf_token: String, key: String, } pub async fn keys_unlink(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/keys", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok(); keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await } // ----------------------------------------------------------------- tokens #[derive(Template)] #[template(path = "settings_tokens.html")] pub struct TokensPage { shell: Shell, scopes: &'static [(&'static str, &'static str)], me: accounts::Account, tokens: Vec, /// A freshly created token, shown once. fresh: Option, notice: Option, error: Option, } async fn tokens_page( app: &Shared, shell: Shell, me: accounts::Account, fresh: Option, notice: Option, error: Option, ) -> Result, Error> { let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(TokensPage { shell, scopes: SCOPES, me, tokens, fresh, notice, error })) } pub async fn tokens(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/settings/tokens", &viewer); let me = require(&viewer, &shell)?; tokens_page(&app, shell, me.account, None, None, None).await } #[derive(Deserialize)] pub struct TokenForm { #[serde(rename = "_csrf")] csrf_token: String, name: String, #[serde(default)] scopes: Vec, #[serde(default)] repos: String, #[serde(default)] expires_days: String, /// An agent uses it: tell me what it does (oxygenhub#183). #[serde(default)] agent: String, } /// Every scope the hub checks, with what it grants. The tokens page lists /// exactly these, so a scope cannot exist without a checkbox. pub const SCOPES: &[(&str, &str)] = &[ ("repo:read", "read repositories you can see: clone, fetch, pull, and the read API"), ("repo:write", "push to repositories you can write to; also covers proposals, issues and checks"), ("repo:admin", "create repositories and change settings through the API"), ("proposals:write", "open, comment on and review proposals, without push access"), ("issues:write", "open, comment on and close issues, without push access"), ("checks:write", "report checks on states, for CI systems, without push access"), ("hooks:admin", "manage webhooks through the API"), ("user:read", "read your own profile through the API"), ]; pub async fn tokens_create( State(app): State, viewer: Viewer, axum_extra::extract::Form(f): axum_extra::extract::Form, ) -> Result, Error> { let shell = Shell::new(&app, "/settings/tokens", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let name = f.name.trim(); if name.is_empty() { return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await; } let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect(); if scopes.is_empty() { return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await; } let expires_ms = f.expires_days.trim().parse::().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000); let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::>().join(","); let (secret, hash) = auth::new_token(); accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms) .await .map_err(|e| Error::from_db(shell.clone(), e))?; if !f.agent.is_empty() { if let Ok(Some(t)) = accounts::token_by_hash(&app.db, &hash).await { accounts::set_token_agent(&app.db, me.account.id, t.id, true).await.map_err(|e| Error::from_db(shell.clone(), e))?; } } accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok(); tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await } /// Mark a token as an agent's, or not (oxygenhub#183): what is done with /// an agent's token is told to its owner, as anyone else's doing is. pub async fn tokens_agent( State(app): State, viewer: Viewer, Path(id): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/settings/tokens", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let tokens = accounts::tokens_of(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let Some(t) = tokens.iter().find(|t| t.id == id) else { return tokens_page(&app, shell, me.account, None, None, Some("No such token.".into())).await; }; accounts::set_token_agent(&app.db, me.account.id, id, !t.agent).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "token.agent", &t.name, if t.agent { "off" } else { "on" }).await.ok(); let notice = if t.agent { format!("{} is no longer marked as an agent's.", t.name) } else { format!("{} is an agent's: you will hear what it does.", t.name) }; tokens_page(&app, shell, me.account, None, Some(notice), None).await } pub async fn tokens_delete( State(app): State, viewer: Viewer, Path(id): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/settings/tokens", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok(); tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await } #[cfg(test)] mod tests { use super::browser_of; #[test] fn browsers_are_named_from_their_agent() { let ff = "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"; let chrome_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36"; 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"; 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"; assert_eq!(browser_of(ff), "Firefox on Linux"); assert_eq!(browser_of(chrome_mac), "Chrome on macOS"); assert_eq!(browser_of(edge), "Edge on Windows"); assert_eq!(browser_of(iphone), "Safari on iOS"); assert_eq!(browser_of(""), "Unknown browser"); } }