OXYGENHUB
# 756fe44a0f4d oxygenhub / crates / hub / src / web / settings.rs
24.0 KiB · Rust · last changed Uploaded profile pictures and organisation logos: PNG, JPEG or WebP checked by content, cropped, scaled and re-encoded before storing, served at /avatar/{name} with the identicon as fallback, removable 1 month ago source blame history raw
1
//! Account settings (hub spec §6.3 `/settings/*`): profile, security,
2
//! linked identities, tokens.
3
4
use super::{filters, Error, Page, Shell};
5
use crate::auth::{self, totp, Viewer, ViewerAccount};
6
use crate::db::{self, accounts};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Multipart, Path, State};
10
use axum::http::{header, HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use oxy_core::identity::{Identity, Signature};
13
use serde::Deserialize;
14
15
/// The signed-in account, or a redirect to sign in.
16
pub fn require(viewer: &Viewer, shell: &Shell) -> Result<ViewerAccount, Error> {
17
    viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path))))
18
}
19
20
pub fn urlencode(s: &str) -> String {
21
    let mut out = String::new();
22
    for b in s.bytes() {
23
        match b {
24
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char),
25
            _ => out.push_str(&format!("%{b:02X}")),
26
        }
27
    }
28
    out
29
}
30
31
pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> {
32
    if viewer.csrf_ok(token) {
33
        Ok(())
34
    } else {
35
        Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again."))
36
    }
37
}
38
39
#[derive(Template)]
40
#[template(path = "settings.html")]
41
pub struct SettingsPage {
42
    shell: Shell,
43
    me: accounts::Account,
44
    notice: Option<String>,
45
    error: Option<String>,
46
    /// The theme cookie's value, for the Appearance form.
47
    theme: &'static str,
48
    themes: &'static [(&'static str, &'static str)],
49
    /// Whether the hub sends mail, for the "send a new link" button.
50
    mail: bool,
51
}
52
53
/// The themes (hub spec §6.1): the cookie value and its label.
54
const THEMES: &[(&str, &str)] = &[("dark", "Dark"), ("light", "Light"), ("auto", "Match the system")];
55
56
/// The theme the request's cookie asks for; dark when unset or unknown.
57
fn theme_of(headers: &HeaderMap) -> &'static str {
58
    let v = auth::viewer::cookie(headers, "theme").unwrap_or_default();
59
    THEMES.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("dark")
60
}
61
62
impl SettingsPage {
63
    fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage {
64
        SettingsPage { shell, me, notice: None, error: None, theme: theme_of(headers), themes: THEMES, mail: false }
65
    }
66
67
    fn with_mail(mut self, app: &Shared) -> Self {
68
        self.mail = crate::mail::enabled(app);
69
        self
70
    }
71
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
    }
81
}
82
83
pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> {
84
    let shell = Shell::new(&app, "/settings", &viewer);
85
    let me = require(&viewer, &shell)?;
86
    Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app)))
87
}
88
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
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
    }))
128
}
129
130
#[derive(Deserialize)]
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)]
156
pub struct ProfileForm {
157
    #[serde(rename = "_csrf")]
158
    csrf_token: String,
159
    display_name: String,
160
    email: String,
161
    #[serde(default)]
162
    bio: String,
163
    #[serde(default)]
164
    website: String,
165
    #[serde(default)]
166
    location: String,
167
}
168
169
pub async fn update_profile(
170
    State(app): State<Shared>,
171
    viewer: Viewer,
172
    headers: HeaderMap,
173
    Form(f): Form<ProfileForm>,
174
) -> Result<Page<SettingsPage>, Error> {
175
    let shell = Shell::new(&app, "/settings", &viewer);
176
    let me = require(&viewer, &shell)?;
177
    csrf(&viewer, &f.csrf_token, &shell)?;
178
    let website = f.website.trim();
179
    if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 {
180
        return Ok(Page(
181
            SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."),
182
        ));
183
    }
184
    let bio: String = f.bio.trim().chars().take(280).collect();
185
    let location: String = f.location.trim().chars().take(80).collect();
186
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
187
        .await
188
        .map_err(|e| Error::from_db(shell.clone(), e))?;
189
    accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?;
190
    let changed = me.account.email != f.email.trim();
191
    let updated =
192
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
193
    let mut notice = "Profile saved.".to_string();
194
    if changed && !updated.email.is_empty() {
195
        accounts::audit(&app.db, Some(&updated), "email.changed", &updated.username, "").await.ok();
196
        match super::recovery::send_verification(&app, &updated).await {
197
            Ok(()) => notice = format!("Profile saved. A verification link is on its way to {}.", updated.email),
198
            Err(e) => notice = format!("Profile saved. {e}"),
199
        }
200
    }
201
    Ok(Page(SettingsPage::new(shell, updated, &headers).with_mail(&app).notice(notice)))
202
}
203
204
#[derive(Deserialize)]
205
pub struct PasswordForm {
206
    #[serde(rename = "_csrf")]
207
    csrf_token: String,
208
    current: String,
209
    password: String,
210
}
211
212
pub async fn change_password(
213
    State(app): State<Shared>,
214
    viewer: Viewer,
215
    headers: HeaderMap,
216
    Form(f): Form<PasswordForm>,
217
) -> Result<Response, Error> {
218
    let shell = Shell::new(&app, "/settings", &viewer);
219
    let me = require(&viewer, &shell)?;
220
    csrf(&viewer, &f.csrf_token, &shell)?;
221
    if !auth::verify_password(&me.account.password_hash, &f.current) {
222
        return Ok(
223
            Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("Your current password is wrong.")).into_response()
224
        );
225
    }
226
    if let Err(e) = auth::password_ok(&f.password) {
227
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(e)).into_response());
228
    }
229
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
230
    // A new session salt signs out every other session; this one is renewed.
231
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
232
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
233
    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);
234
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
235
}
236
237
#[derive(Template)]
238
#[template(path = "settings_security.html")]
239
pub struct SecurityPage {
240
    shell: Shell,
241
    me: accounts::Account,
242
    notice: Option<String>,
243
    error: Option<String>,
244
    /// Enrolment in progress: secret, URI and QR.
245
    enrol: Option<(String, String, String)>,
246
    recovery: Vec<String>,
247
}
248
249
pub async fn security(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SecurityPage>, Error> {
250
    let shell = Shell::new(&app, "/settings/security", &viewer);
251
    let me = require(&viewer, &shell)?;
252
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: None, recovery: vec![] }))
253
}
254
255
#[derive(Deserialize)]
256
pub struct TotpForm {
257
    #[serde(rename = "_csrf")]
258
    csrf_token: String,
259
    #[serde(default)]
260
    secret: String,
261
    #[serde(default)]
262
    code: String,
263
    #[serde(default)]
264
    password: String,
265
}
266
267
/// Step one: show a fresh secret to scan.
268
pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
269
    let shell = Shell::new(&app, "/settings/security", &viewer);
270
    let me = require(&viewer, &shell)?;
271
    csrf(&viewer, &f.csrf_token, &shell)?;
272
    let secret = totp::new_secret();
273
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
274
    let qr = totp::qr_svg(&uri).unwrap_or_default();
275
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![] }))
276
}
277
278
/// Step two: the first code proves the app holds the secret.
279
pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
280
    let shell = Shell::new(&app, "/settings/security", &viewer);
281
    let me = require(&viewer, &shell)?;
282
    csrf(&viewer, &f.csrf_token, &shell)?;
283
    let now = (db::now_ms() / 1000) as u64;
284
    if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) {
285
        let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret);
286
        let qr = totp::qr_svg(&uri).unwrap_or_default();
287
        return Ok(Page(SecurityPage {
288
            shell,
289
            me: me.account,
290
            notice: None,
291
            error: Some("That code did not match; scan again and enter the current code.".into()),
292
            enrol: Some((f.secret, uri, qr)),
293
            recovery: vec![],
294
        }));
295
    }
296
    let (codes, stored) = totp::new_recovery_codes();
297
    accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?;
298
    accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok();
299
    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);
300
    Ok(Page(SecurityPage {
301
        shell,
302
        me: fresh,
303
        notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()),
304
        error: None,
305
        enrol: None,
306
        recovery: codes,
307
    }))
308
}
309
310
pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
311
    let shell = Shell::new(&app, "/settings/security", &viewer);
312
    let me = require(&viewer, &shell)?;
313
    csrf(&viewer, &f.csrf_token, &shell)?;
314
    if !auth::verify_password(&me.account.password_hash, &f.password) {
315
        return Ok(Page(SecurityPage {
316
            shell,
317
            me: me.account,
318
            notice: None,
319
            error: Some("Your password is wrong.".into()),
320
            enrol: None,
321
            recovery: vec![],
322
        }));
323
    }
324
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
325
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
326
    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);
327
    Ok(Page(SecurityPage {
328
        shell,
329
        me: fresh,
330
        notice: Some("Two-factor authentication is off.".into()),
331
        error: None,
332
        enrol: None,
333
        recovery: vec![],
334
    }))
335
}
336
337
/// Sign out everywhere: a new session salt invalidates every cookie, then
338
/// this browser gets a fresh one.
339
pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> {
340
    let shell = Shell::new(&app, "/settings/security", &viewer);
341
    let me = require(&viewer, &shell)?;
342
    csrf(&viewer, &f.csrf, &shell)?;
343
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
344
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
345
    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);
346
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
347
}
348
349
// ------------------------------------------------------------------- keys
350
351
#[derive(Template)]
352
#[template(path = "settings_keys.html")]
353
pub struct KeysPage {
354
    shell: Shell,
355
    me: accounts::Account,
356
    keys: Vec<accounts::LinkedIdentity>,
357
    notice: Option<String>,
358
    error: Option<String>,
359
    /// A pending challenge: id, hex bytes, identity name.
360
    pending: Option<(i64, String, String)>,
361
}
362
363
async fn keys_page(
364
    app: &Shared,
365
    shell: Shell,
366
    me: accounts::Account,
367
    notice: Option<String>,
368
    error: Option<String>,
369
    pending: Option<(i64, String, String)>,
370
) -> Result<Page<KeysPage>, Error> {
371
    let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
372
    Ok(Page(KeysPage { shell, me, keys, notice, error, pending }))
373
}
374
375
pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> {
376
    let shell = Shell::new(&app, "/settings/keys", &viewer);
377
    let me = require(&viewer, &shell)?;
378
    keys_page(&app, shell, me.account, None, None, None).await
379
}
380
381
/// Upload an identity object (`oxy identity export`): the hub issues a
382
/// challenge to sign with that key.
383
pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> {
384
    let shell = Shell::new(&app, "/settings/keys", &viewer);
385
    let me = require(&viewer, &shell)?;
386
    let mut token = String::new();
387
    let mut object: Vec<u8> = Vec::new();
388
    while let Ok(Some(field)) = multipart.next_field().await {
389
        match field.name() {
390
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
391
            Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
392
            _ => {}
393
        }
394
    }
395
    csrf(&viewer, &token, &shell)?;
396
    let identity = match Identity::decode(&object) {
397
        Ok(i) => i,
398
        Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await,
399
    };
400
    let key = identity.hash().to_hex();
401
    if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? {
402
        let msg = if existing.account_id == me.account.id {
403
            "That key is already linked to you."
404
        } else {
405
            "That key is linked to another account."
406
        };
407
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
408
    }
409
    let bytes = auth::random_hex(32);
410
    let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?;
411
    sqlx::query("UPDATE challenges SET object = ? WHERE id = ?")
412
        .bind(&object)
413
        .bind(id)
414
        .execute(&app.db)
415
        .await
416
        .map_err(|e| Error::from_db(shell.clone(), e))?;
417
    keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await
418
}
419
420
#[derive(Deserialize)]
421
pub struct KeyVerifyForm {
422
    #[serde(rename = "_csrf")]
423
    csrf_token: String,
424
    challenge: i64,
425
    signature: String,
426
}
427
428
pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> {
429
    let shell = Shell::new(&app, "/settings/keys", &viewer);
430
    let me = require(&viewer, &shell)?;
431
    csrf(&viewer, &f.csrf_token, &shell)?;
432
    let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = ? AND account_id = ?")
433
        .bind(f.challenge)
434
        .bind(me.account.id)
435
        .fetch_optional(&app.db)
436
        .await
437
        .map_err(|e| Error::from_db(shell.clone(), e))?;
438
    let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?;
439
    let (Some(object), Some(bytes)) = (object, bytes) else {
440
        return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await;
441
    };
442
    let Ok(identity) = Identity::decode(&object) else {
443
        return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await;
444
    };
445
    let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default();
446
    let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default();
447
    let challenge = hex::decode(&bytes).unwrap_or_default();
448
    let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok());
449
    if !ok {
450
        return keys_page(
451
            &app,
452
            shell,
453
            me.account,
454
            None,
455
            Some("The signature does not verify against that identity and challenge.".into()),
456
            None,
457
        )
458
        .await;
459
    }
460
    let key = identity.hash().to_hex();
461
    accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object)
462
        .await
463
        .map_err(|e| Error::from_db(shell.clone(), e))?;
464
    accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok();
465
    keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await
466
}
467
468
#[derive(Deserialize)]
469
pub struct KeyForm {
470
    #[serde(rename = "_csrf")]
471
    csrf_token: String,
472
    key: String,
473
}
474
475
pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> {
476
    let shell = Shell::new(&app, "/settings/keys", &viewer);
477
    let me = require(&viewer, &shell)?;
478
    csrf(&viewer, &f.csrf_token, &shell)?;
479
    accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?;
480
    accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok();
481
    keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await
482
}
483
484
// ----------------------------------------------------------------- tokens
485
486
#[derive(Template)]
487
#[template(path = "settings_tokens.html")]
488
pub struct TokensPage {
489
    shell: Shell,
490
    scopes: &'static [(&'static str, &'static str)],
491
    me: accounts::Account,
492
    tokens: Vec<accounts::Token>,
493
    /// A freshly created token, shown once.
494
    fresh: Option<String>,
495
    notice: Option<String>,
496
    error: Option<String>,
497
}
498
499
async fn tokens_page(
500
    app: &Shared,
501
    shell: Shell,
502
    me: accounts::Account,
503
    fresh: Option<String>,
504
    notice: Option<String>,
505
    error: Option<String>,
506
) -> Result<Page<TokensPage>, Error> {
507
    let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
508
    Ok(Page(TokensPage { shell, scopes: SCOPES, me, tokens, fresh, notice, error }))
509
}
510
511
pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> {
512
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
513
    let me = require(&viewer, &shell)?;
514
    tokens_page(&app, shell, me.account, None, None, None).await
515
}
516
517
#[derive(Deserialize)]
518
pub struct TokenForm {
519
    #[serde(rename = "_csrf")]
520
    csrf_token: String,
521
    name: String,
522
    #[serde(default)]
523
    scopes: Vec<String>,
524
    #[serde(default)]
525
    repos: String,
526
    #[serde(default)]
527
    expires_days: String,
528
}
529
530
/// Every scope the hub checks, with what it grants. The tokens page lists
531
/// exactly these, so a scope cannot exist without a checkbox.
532
pub const SCOPES: &[(&str, &str)] = &[
533
    ("repo:read", "read repositories you can see: clone, fetch, pull, and the read API"),
534
    ("repo:write", "push to repositories you can write to; also covers proposals, issues and checks"),
535
    ("repo:admin", "create repositories and change settings through the API"),
536
    ("proposals:write", "open, comment on and review proposals, without push access"),
537
    ("issues:write", "open, comment on and close issues, without push access"),
538
    ("checks:write", "report checks on states, for CI systems, without push access"),
539
    ("hooks:admin", "manage webhooks through the API"),
540
    ("user:read", "read your own profile through the API"),
541
];
542
543
pub async fn tokens_create(
544
    State(app): State<Shared>,
545
    viewer: Viewer,
546
    axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>,
547
) -> Result<Page<TokensPage>, Error> {
548
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
549
    let me = require(&viewer, &shell)?;
550
    csrf(&viewer, &f.csrf_token, &shell)?;
551
    let name = f.name.trim();
552
    if name.is_empty() {
553
        return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await;
554
    }
555
    let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect();
556
    if scopes.is_empty() {
557
        return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await;
558
    }
559
    let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000);
560
    let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(",");
561
    let (secret, hash) = auth::new_token();
562
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
563
        .await
564
        .map_err(|e| Error::from_db(shell.clone(), e))?;
565
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
566
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
567
}
568
569
pub async fn tokens_delete(
570
    State(app): State<Shared>,
571
    viewer: Viewer,
572
    Path(id): Path<i64>,
573
    Form(f): Form<super::session::CsrfForm>,
574
) -> Result<Page<TokensPage>, Error> {
575
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
576
    let me = require(&viewer, &shell)?;
577
    csrf(&viewer, &f.csrf, &shell)?;
578
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
579
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
580
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
581
}