OXYGENHUB
# 26d0d091deb7 oxygenhub / crates / hub / src / db / accounts.rs
33.0 KiB · Rust · last changed Administration is a sectioned page with a left-hand menu 1 month ago source blame history raw
1
//! Accounts, organisations, teams, collaborators, identities, tokens,
2
//! invitations, settings and the audit log (hub spec §5).
3
4
use super::{now_ms, Pool};
5
use sqlx::FromRow;
6
7
#[derive(Clone, Debug, FromRow)]
8
pub struct Account {
9
    pub id: i64,
10
    pub username: String,
11
    pub display_name: String,
12
    pub email: String,
13
    pub email_verified: bool,
14
    pub password_hash: String,
15
    pub session_salt: String,
16
    pub totp_secret: Option<String>,
17
    pub recovery_codes: String,
18
    pub is_admin: bool,
19
    pub banned: bool,
20
    /// Disabled by an admin: refused like a ban, and enabled again (0031).
21
    #[sqlx(default)]
22
    pub disabled: bool,
23
    pub created_ms: i64,
24
    pub last_seen_ms: i64,
25
    pub email_notifications: bool,
26
    pub unsubscribe_key: String,
27
    /// Shown on the profile page (migration 0008).
28
    #[sqlx(default)]
29
    pub bio: String,
30
    #[sqlx(default)]
31
    pub website: String,
32
    #[sqlx(default)]
33
    pub location: String,
34
    /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded.
35
    pub avatar: String,
36
    /// Accounts elsewhere (migration 0039, #105): a Bluesky handle, a
37
    /// Mastodon `user@instance`, a GitHub username; empty when not set.
38
    #[sqlx(default)]
39
    pub bluesky: String,
40
    #[sqlx(default)]
41
    pub mastodon: String,
42
    #[sqlx(default)]
43
    pub github: String,
44
}
45
46
impl Account {
47
    /// Whether the account may not sign in or act: banned or disabled.
48
    pub fn blocked(&self) -> bool {
49
        self.banned || self.disabled
50
    }
51
52
    pub fn name(&self) -> &str {
53
        if self.display_name.is_empty() {
54
            &self.username
55
        } else {
56
            &self.display_name
57
        }
58
    }
59
60
    pub fn totp_enabled(&self) -> bool {
61
        self.totp_secret.is_some()
62
    }
63
}
64
65
#[derive(Clone, Debug, FromRow)]
66
pub struct Organisation {
67
    pub id: i64,
68
    pub name: String,
69
    pub display_name: String,
70
    pub description: String,
71
    pub default_role: String,
72
    pub created_ms: i64,
73
    /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded.
74
    pub avatar: String,
75
}
76
77
#[derive(Clone, Debug, FromRow)]
78
pub struct Team {
79
    pub id: i64,
80
    pub org_id: i64,
81
    pub name: String,
82
}
83
84
#[derive(Clone, Debug, FromRow)]
85
pub struct Token {
86
    pub id: i64,
87
    pub account_id: i64,
88
    pub name: String,
89
    pub hash: String,
90
    pub scopes: String,
91
    pub repos: String,
92
    pub created_ms: i64,
93
    pub expires_ms: Option<i64>,
94
    pub last_used_ms: Option<i64>,
95
    /// A renewing token's lifetime: each use extends it to now plus this.
96
    pub renew_ms: Option<i64>,
97
}
98
99
impl Token {
100
    pub fn scope_list(&self) -> Vec<&str> {
101
        self.scopes.split(',').filter(|s| !s.is_empty()).collect()
102
    }
103
104
    pub fn has_scope(&self, scope: &str) -> bool {
105
        self.scope_list().contains(&scope)
106
    }
107
108
    /// Whether the token may touch `owner/name`: every repository when the
109
    /// list is empty.
110
    pub fn covers(&self, slug: &str) -> bool {
111
        self.repos.is_empty() || self.repos.split(',').any(|r| r.trim().eq_ignore_ascii_case(slug))
112
    }
113
114
    pub fn expired(&self, now: i64) -> bool {
115
        self.expires_ms.is_some_and(|e| e < now)
116
    }
117
}
118
119
#[derive(Clone, Debug, FromRow)]
120
pub struct LinkedIdentity {
121
    pub key: String,
122
    pub account_id: i64,
123
    pub name: String,
124
    pub email: String,
125
    pub object: Vec<u8>,
126
    pub linked_ms: i64,
127
}
128
129
#[derive(Clone, Debug, FromRow)]
130
pub struct AuditRow {
131
    pub id: i64,
132
    pub at_ms: i64,
133
    pub actor: String,
134
    pub action: String,
135
    pub target: String,
136
    pub detail: String,
137
}
138
139
// ---------------------------------------------------------------- accounts
140
141
pub async fn account_by_name(pool: &Pool, username: &str) -> sqlx::Result<Option<Account>> {
142
    sqlx::query_as("SELECT * FROM accounts WHERE username = ?").bind(username).fetch_optional(pool).await
143
}
144
145
pub async fn account_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Account>> {
146
    sqlx::query_as("SELECT * FROM accounts WHERE id = ?").bind(id).fetch_optional(pool).await
147
}
148
149
pub async fn accounts(pool: &Pool) -> sqlx::Result<Vec<Account>> {
150
    sqlx::query_as("SELECT * FROM accounts ORDER BY username").fetch_all(pool).await
151
}
152
153
pub async fn account_count(pool: &Pool) -> sqlx::Result<i64> {
154
    sqlx::query_scalar("SELECT COUNT(*) FROM accounts").fetch_one(pool).await
155
}
156
157
pub async fn create_account(
158
    pool: &Pool,
159
    username: &str,
160
    email: &str,
161
    password_hash: &str,
162
    session_salt: &str,
163
    is_admin: bool,
164
) -> sqlx::Result<Account> {
165
    sqlx::query_as(
166
        "INSERT INTO accounts (username, email, password_hash, session_salt, is_admin, created_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
167
    )
168
    .bind(username)
169
    .bind(email)
170
    .bind(password_hash)
171
    .bind(session_salt)
172
    .bind(is_admin)
173
    .bind(now_ms())
174
    .fetch_one(pool)
175
    .await
176
}
177
178
pub async fn set_profile(pool: &Pool, id: i64, display_name: &str, email: &str) -> sqlx::Result<()> {
179
    // A changed address is unverified until its own link is used.
180
    sqlx::query(
181
        "UPDATE accounts SET display_name = ?, email_verified = CASE WHEN email = ? THEN email_verified ELSE 0 END, email = ? WHERE id = ?",
182
    )
183
    .bind(display_name)
184
    .bind(email)
185
    .bind(email)
186
    .bind(id)
187
    .execute(pool)
188
    .await?;
189
    Ok(())
190
}
191
192
/// The public profile fields.
193
/// The accounts elsewhere a profile shows (#105), already normalised.
194
pub async fn set_links(pool: &Pool, id: i64, bluesky: &str, mastodon: &str, github: &str) -> sqlx::Result<()> {
195
    sqlx::query("UPDATE accounts SET bluesky = ?, mastodon = ?, github = ? WHERE id = ?")
196
        .bind(bluesky)
197
        .bind(mastodon)
198
        .bind(github)
199
        .bind(id)
200
        .execute(pool)
201
        .await?;
202
    Ok(())
203
}
204
205
pub async fn set_bio(pool: &Pool, id: i64, bio: &str, website: &str, location: &str) -> sqlx::Result<()> {
206
    sqlx::query("UPDATE accounts SET bio = ?, website = ?, location = ? WHERE id = ?")
207
        .bind(bio)
208
        .bind(website)
209
        .bind(location)
210
        .bind(id)
211
        .execute(pool)
212
        .await?;
213
    Ok(())
214
}
215
216
pub async fn set_password(pool: &Pool, id: i64, password_hash: &str, session_salt: &str) -> sqlx::Result<()> {
217
    sqlx::query("UPDATE accounts SET password_hash = ?, session_salt = ? WHERE id = ?")
218
        .bind(password_hash)
219
        .bind(session_salt)
220
        .bind(id)
221
        .execute(pool)
222
        .await?;
223
    Ok(())
224
}
225
226
pub async fn set_session_salt(pool: &Pool, id: i64, session_salt: &str) -> sqlx::Result<()> {
227
    sqlx::query("UPDATE accounts SET session_salt = ? WHERE id = ?").bind(session_salt).bind(id).execute(pool).await?;
228
    Ok(())
229
}
230
231
pub async fn set_totp(pool: &Pool, id: i64, secret: Option<&str>, recovery_codes: &str) -> sqlx::Result<()> {
232
    sqlx::query("UPDATE accounts SET totp_secret = ?, recovery_codes = ? WHERE id = ?")
233
        .bind(secret)
234
        .bind(recovery_codes)
235
        .bind(id)
236
        .execute(pool)
237
        .await?;
238
    Ok(())
239
}
240
241
pub async fn set_admin(pool: &Pool, id: i64, is_admin: bool) -> sqlx::Result<()> {
242
    sqlx::query("UPDATE accounts SET is_admin = ? WHERE id = ?").bind(is_admin).bind(id).execute(pool).await?;
243
    Ok(())
244
}
245
246
pub async fn set_banned(pool: &Pool, id: i64, banned: bool) -> sqlx::Result<()> {
247
    sqlx::query("UPDATE accounts SET banned = ? WHERE id = ?").bind(banned).bind(id).execute(pool).await?;
248
    Ok(())
249
}
250
251
pub async fn set_disabled(pool: &Pool, id: i64, disabled: bool) -> sqlx::Result<()> {
252
    sqlx::query("UPDATE accounts SET disabled = ? WHERE id = ?").bind(disabled).bind(id).execute(pool).await?;
253
    Ok(())
254
}
255
256
/// Delete an account (oxygenhub#81): its row goes, and with it (by the
257
/// schema's cascades) its tokens, keys, sessions' salt, memberships, stars
258
/// and notifications; what it wrote keeps its author's name. The username
259
/// is retired so nobody can register it and pass for the account.
260
pub async fn delete_account(pool: &Pool, id: i64, username: &str) -> sqlx::Result<()> {
261
    let mut tx = super::begin_write(pool).await?;
262
    sqlx::query("INSERT OR IGNORE INTO retired_usernames (username, retired_ms) VALUES (?, ?)")
263
        .bind(username)
264
        .bind(now_ms())
265
        .execute(&mut *tx)
266
        .await?;
267
    sqlx::query("DELETE FROM accounts WHERE id = ?").bind(id).execute(&mut *tx).await?;
268
    tx.commit().await
269
}
270
271
/// The organisations whose only owner is this account: deleting it would
272
/// leave them with nobody to manage them.
273
pub async fn orgs_owned_only_by(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<String>> {
274
    sqlx::query_scalar(
275
        "SELECT o.name FROM organisations o JOIN org_members m ON m.org_id = o.id \
276
         WHERE m.account_id = ? AND m.role = 'owner' \
277
           AND (SELECT COUNT(*) FROM org_members x WHERE x.org_id = o.id AND x.role = 'owner') = 1 \
278
         ORDER BY o.name",
279
    )
280
    .bind(account_id)
281
    .fetch_all(pool)
282
    .await
283
}
284
285
/// Whether `name` belonged to a deleted account.
286
pub async fn username_retired(pool: &Pool, name: &str) -> sqlx::Result<bool> {
287
    Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM retired_usernames WHERE username = ?").bind(name).fetch_one(pool).await? > 0)
288
}
289
290
pub async fn touch_seen(pool: &Pool, id: i64) -> sqlx::Result<()> {
291
    sqlx::query("UPDATE accounts SET last_seen_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?;
292
    Ok(())
293
}
294
295
// ----------------------------------------------------------- organisations
296
297
pub async fn org_by_name(pool: &Pool, name: &str) -> sqlx::Result<Option<Organisation>> {
298
    sqlx::query_as("SELECT * FROM organisations WHERE name = ?").bind(name).fetch_optional(pool).await
299
}
300
301
pub async fn orgs(pool: &Pool) -> sqlx::Result<Vec<Organisation>> {
302
    sqlx::query_as("SELECT * FROM organisations ORDER BY name").fetch_all(pool).await
303
}
304
305
/// An organisation with the account's role in it.
306
#[derive(FromRow)]
307
struct Membership {
308
    #[sqlx(flatten)]
309
    org: Organisation,
310
    role: String,
311
}
312
313
pub async fn orgs_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<(Organisation, String)>> {
314
    let rows: Vec<Membership> = sqlx::query_as(
315
        "SELECT o.*, m.role FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = ? ORDER BY o.name",
316
    )
317
    .bind(account_id)
318
    .fetch_all(pool)
319
    .await?;
320
    Ok(rows.into_iter().map(|m| (m.org, m.role)).collect())
321
}
322
323
pub async fn create_org(pool: &Pool, name: &str, display_name: &str, owner: i64) -> sqlx::Result<Organisation> {
324
    let mut tx = super::begin_write(pool).await?;
325
    let org: Organisation = sqlx::query_as("INSERT INTO organisations (name, display_name, created_ms) VALUES (?, ?, ?) RETURNING *")
326
        .bind(name)
327
        .bind(display_name)
328
        .bind(now_ms())
329
        .fetch_one(&mut *tx)
330
        .await?;
331
    sqlx::query("INSERT INTO org_members (org_id, account_id, role) VALUES (?, ?, 'owner')")
332
        .bind(org.id)
333
        .bind(owner)
334
        .execute(&mut *tx)
335
        .await?;
336
    tx.commit().await?;
337
    Ok(org)
338
}
339
340
pub async fn set_org(pool: &Pool, id: i64, display_name: &str, description: &str, default_role: &str) -> sqlx::Result<()> {
341
    sqlx::query("UPDATE organisations SET display_name = ?, description = ?, default_role = ? WHERE id = ?")
342
        .bind(display_name)
343
        .bind(description)
344
        .bind(default_role)
345
        .bind(id)
346
        .execute(pool)
347
        .await?;
348
    Ok(())
349
}
350
351
/// `(account, role)` for every member.
352
pub async fn org_members(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<(Account, String)>> {
353
    let rows: Vec<(i64, String)> =
354
        sqlx::query_as("SELECT account_id, role FROM org_members WHERE org_id = ?").bind(org_id).fetch_all(pool).await?;
355
    let mut out = Vec::new();
356
    for (id, role) in rows {
357
        if let Some(a) = account_by_id(pool, id).await? {
358
            out.push((a, role));
359
        }
360
    }
361
    out.sort_by(|a, b| a.0.username.cmp(&b.0.username));
362
    Ok(out)
363
}
364
365
pub async fn org_role(pool: &Pool, org_id: i64, account_id: i64) -> sqlx::Result<Option<String>> {
366
    sqlx::query_scalar("SELECT role FROM org_members WHERE org_id = ? AND account_id = ?")
367
        .bind(org_id)
368
        .bind(account_id)
369
        .fetch_optional(pool)
370
        .await
371
}
372
373
pub async fn set_org_member(pool: &Pool, org_id: i64, account_id: i64, role: &str) -> sqlx::Result<()> {
374
    sqlx::query("INSERT INTO org_members (org_id, account_id, role) VALUES (?, ?, ?) ON CONFLICT (org_id, account_id) DO UPDATE SET role = excluded.role")
375
        .bind(org_id)
376
        .bind(account_id)
377
        .bind(role)
378
        .execute(pool)
379
        .await?;
380
    Ok(())
381
}
382
383
pub async fn remove_org_member(pool: &Pool, org_id: i64, account_id: i64) -> sqlx::Result<()> {
384
    sqlx::query("DELETE FROM org_members WHERE org_id = ? AND account_id = ?").bind(org_id).bind(account_id).execute(pool).await?;
385
    Ok(())
386
}
387
388
pub async fn teams(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<Team>> {
389
    sqlx::query_as("SELECT * FROM teams WHERE org_id = ? ORDER BY name").bind(org_id).fetch_all(pool).await
390
}
391
392
pub async fn team_by_name(pool: &Pool, org_id: i64, name: &str) -> sqlx::Result<Option<Team>> {
393
    sqlx::query_as("SELECT * FROM teams WHERE org_id = ? AND name = ?").bind(org_id).bind(name).fetch_optional(pool).await
394
}
395
396
pub async fn create_team(pool: &Pool, org_id: i64, name: &str) -> sqlx::Result<Team> {
397
    sqlx::query_as("INSERT INTO teams (org_id, name) VALUES (?, ?) RETURNING *").bind(org_id).bind(name).fetch_one(pool).await
398
}
399
400
pub async fn delete_team(pool: &Pool, id: i64) -> sqlx::Result<()> {
401
    sqlx::query("DELETE FROM teams WHERE id = ?").bind(id).execute(pool).await?;
402
    Ok(())
403
}
404
405
pub async fn team_members(pool: &Pool, team_id: i64) -> sqlx::Result<Vec<Account>> {
406
    sqlx::query_as("SELECT a.* FROM accounts a JOIN team_members t ON t.account_id = a.id WHERE t.team_id = ? ORDER BY a.username")
407
        .bind(team_id)
408
        .fetch_all(pool)
409
        .await
410
}
411
412
pub async fn add_team_member(pool: &Pool, team_id: i64, account_id: i64) -> sqlx::Result<()> {
413
    sqlx::query("INSERT OR IGNORE INTO team_members (team_id, account_id) VALUES (?, ?)")
414
        .bind(team_id)
415
        .bind(account_id)
416
        .execute(pool)
417
        .await?;
418
    Ok(())
419
}
420
421
pub async fn remove_team_member(pool: &Pool, team_id: i64, account_id: i64) -> sqlx::Result<()> {
422
    sqlx::query("DELETE FROM team_members WHERE team_id = ? AND account_id = ?").bind(team_id).bind(account_id).execute(pool).await?;
423
    Ok(())
424
}
425
426
// ----------------------------------------------------------- collaborators
427
428
/// Who an issue of this repository can be assigned to, by username: its
429
/// owner (an account), its collaborators (directly or through a team) and,
430
/// when an organisation owns it, the organisation's members. Blocked
431
/// accounts are left out.
432
pub async fn assignable(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<String>> {
433
    sqlx::query_scalar(
434
        "SELECT username FROM accounts WHERE banned = 0 AND disabled = 0 AND id IN (\
435
           SELECT id FROM accounts WHERE username = ?1 \
436
           UNION SELECT account_id FROM collaborators WHERE repo_id = ?2 AND account_id IS NOT NULL \
437
           UNION SELECT tm.account_id FROM team_members tm JOIN collaborators c ON c.team_id = tm.team_id WHERE c.repo_id = ?2 \
438
           UNION SELECT m.account_id FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = ?1) \
439
         ORDER BY username COLLATE NOCASE",
440
    )
441
    .bind(owner)
442
    .bind(repo_id)
443
    .fetch_all(pool)
444
    .await
445
}
446
447
#[derive(Clone, Debug)]
448
pub struct Collaborator {
449
    pub account: Option<Account>,
450
    pub team: Option<Team>,
451
    pub role: String,
452
}
453
454
pub async fn collaborators(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Collaborator>> {
455
    let rows: Vec<(Option<i64>, Option<i64>, String)> =
456
        sqlx::query_as("SELECT account_id, team_id, role FROM collaborators WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await?;
457
    let mut out = Vec::new();
458
    for (account_id, team_id, role) in rows {
459
        let account = match account_id {
460
            Some(id) => account_by_id(pool, id).await?,
461
            None => None,
462
        };
463
        let team = match team_id {
464
            Some(id) => sqlx::query_as("SELECT * FROM teams WHERE id = ?").bind(id).fetch_optional(pool).await?,
465
            None => None,
466
        };
467
        out.push(Collaborator { account, team, role });
468
    }
469
    Ok(out)
470
}
471
472
pub async fn set_collaborator(pool: &Pool, repo_id: i64, account_id: Option<i64>, team_id: Option<i64>, role: &str) -> sqlx::Result<()> {
473
    if let Some(a) = account_id {
474
        sqlx::query("INSERT INTO collaborators (repo_id, account_id, role) VALUES (?, ?, ?) ON CONFLICT (repo_id, account_id) WHERE account_id IS NOT NULL DO UPDATE SET role = excluded.role")
475
            .bind(repo_id)
476
            .bind(a)
477
            .bind(role)
478
            .execute(pool)
479
            .await?;
480
    } else if let Some(t) = team_id {
481
        sqlx::query("INSERT INTO collaborators (repo_id, team_id, role) VALUES (?, ?, ?) ON CONFLICT (repo_id, team_id) WHERE team_id IS NOT NULL DO UPDATE SET role = excluded.role")
482
            .bind(repo_id)
483
            .bind(t)
484
            .bind(role)
485
            .execute(pool)
486
            .await?;
487
    }
488
    Ok(())
489
}
490
491
pub async fn remove_collaborator(pool: &Pool, repo_id: i64, account_id: Option<i64>, team_id: Option<i64>) -> sqlx::Result<()> {
492
    sqlx::query("DELETE FROM collaborators WHERE repo_id = ? AND ((account_id IS NOT NULL AND account_id = ?) OR (team_id IS NOT NULL AND team_id = ?))")
493
        .bind(repo_id)
494
        .bind(account_id)
495
        .bind(team_id)
496
        .execute(pool)
497
        .await?;
498
    Ok(())
499
}
500
501
/// The direct or team role an account holds on a repository, the highest.
502
pub async fn collaborator_role(pool: &Pool, repo_id: i64, account_id: i64) -> sqlx::Result<Option<String>> {
503
    let rows: Vec<String> = sqlx::query_scalar(
504
        "SELECT role FROM collaborators WHERE repo_id = ? AND (account_id = ? OR team_id IN (SELECT team_id FROM team_members WHERE account_id = ?))",
505
    )
506
    .bind(repo_id)
507
    .bind(account_id)
508
    .bind(account_id)
509
    .fetch_all(pool)
510
    .await?;
511
    Ok(rows.into_iter().max_by_key(|r| crate::auth::Role::parse(r).map(|r| r as u8).unwrap_or(0)))
512
}
513
514
// ------------------------------------------------------------------ tokens
515
516
pub async fn tokens_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Token>> {
517
    sqlx::query_as("SELECT * FROM tokens WHERE account_id = ? ORDER BY created_ms DESC").bind(account_id).fetch_all(pool).await
518
}
519
520
pub async fn token_by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Token>> {
521
    sqlx::query_as("SELECT * FROM tokens WHERE hash = ?").bind(hash).fetch_optional(pool).await
522
}
523
524
pub async fn create_token(
525
    pool: &Pool,
526
    account_id: i64,
527
    name: &str,
528
    hash: &str,
529
    scopes: &str,
530
    repos: &str,
531
    expires_ms: Option<i64>,
532
) -> sqlx::Result<Token> {
533
    sqlx::query_as(
534
        "INSERT INTO tokens (account_id, name, hash, scopes, repos, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *",
535
    )
536
    .bind(account_id)
537
    .bind(name)
538
    .bind(hash)
539
    .bind(scopes)
540
    .bind(repos)
541
    .bind(now_ms())
542
    .bind(expires_ms)
543
    .fetch_one(pool)
544
    .await
545
}
546
547
pub async fn delete_token(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<()> {
548
    sqlx::query("DELETE FROM tokens WHERE id = ? AND account_id = ?").bind(id).bind(account_id).execute(pool).await?;
549
    Ok(())
550
}
551
552
/// Record a use; a renewing token's expiry moves to now plus its lifetime.
553
pub async fn touch_token(pool: &Pool, id: i64) -> sqlx::Result<()> {
554
    sqlx::query(
555
        "UPDATE tokens SET last_used_ms = ?1, \
556
         expires_ms = CASE WHEN renew_ms IS NULL THEN expires_ms ELSE MAX(COALESCE(expires_ms, 0), ?1 + renew_ms) END \
557
         WHERE id = ?2",
558
    )
559
    .bind(now_ms())
560
    .bind(id)
561
    .execute(pool)
562
    .await?;
563
    Ok(())
564
}
565
566
/// Make a token renewing: it lasts `renew_ms` past its last use.
567
pub async fn set_token_renewal(pool: &Pool, id: i64, renew_ms: i64) -> sqlx::Result<()> {
568
    sqlx::query("UPDATE tokens SET renew_ms = ? WHERE id = ?").bind(renew_ms).bind(id).execute(pool).await?;
569
    Ok(())
570
}
571
572
// ------------------------------------------------------- device sign-in
573
574
/// A device code an app asked for (oxygenhub#67): pending until the user
575
/// approves or denies it on the web.
576
#[derive(Clone, Debug, FromRow)]
577
pub struct DeviceCode {
578
    pub id: i64,
579
    pub device_hash: String,
580
    pub user_code: String,
581
    pub client: String,
582
    pub scopes: String,
583
    pub created_ms: i64,
584
    pub expires_ms: i64,
585
    pub last_poll_ms: i64,
586
    pub account_id: Option<i64>,
587
    pub approved_scopes: String,
588
    pub denied: bool,
589
    /// The address that asked for the code, as the hub saw it.
590
    pub requester: String,
591
}
592
593
impl DeviceCode {
594
    pub fn scope_list(&self) -> Vec<&str> {
595
        self.scopes.split(',').filter(|s| !s.is_empty()).collect()
596
    }
597
}
598
599
pub async fn create_device_code(
600
    pool: &Pool,
601
    device_hash: &str,
602
    user_code: &str,
603
    client: &str,
604
    scopes: &str,
605
    expires_ms: i64,
606
    requester: &str,
607
) -> sqlx::Result<DeviceCode> {
608
    // Expired codes go whenever a new one is made.
609
    sqlx::query("DELETE FROM device_codes WHERE expires_ms < ?").bind(now_ms()).execute(pool).await?;
610
    sqlx::query_as(
611
        "INSERT INTO device_codes (device_hash, user_code, client, scopes, created_ms, expires_ms, requester) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *",
612
    )
613
    .bind(device_hash)
614
    .bind(user_code)
615
    .bind(client)
616
    .bind(scopes)
617
    .bind(now_ms())
618
    .bind(expires_ms)
619
    .bind(requester)
620
    .fetch_one(pool)
621
    .await
622
}
623
624
pub async fn device_by_user_code(pool: &Pool, user_code: &str) -> sqlx::Result<Option<DeviceCode>> {
625
    sqlx::query_as("SELECT * FROM device_codes WHERE user_code = ?").bind(user_code).fetch_optional(pool).await
626
}
627
628
pub async fn device_by_hash(pool: &Pool, device_hash: &str) -> sqlx::Result<Option<DeviceCode>> {
629
    sqlx::query_as("SELECT * FROM device_codes WHERE device_hash = ?").bind(device_hash).fetch_optional(pool).await
630
}
631
632
/// Approve a pending code for an account with these scopes; false when it
633
/// was no longer pending.
634
pub async fn approve_device(pool: &Pool, id: i64, account_id: i64, scopes: &str) -> sqlx::Result<bool> {
635
    let r = sqlx::query(
636
        "UPDATE device_codes SET account_id = ?, approved_scopes = ? WHERE id = ? AND account_id IS NULL AND denied = 0 AND expires_ms >= ?",
637
    )
638
    .bind(account_id)
639
    .bind(scopes)
640
    .bind(id)
641
    .bind(now_ms())
642
    .execute(pool)
643
    .await?;
644
    Ok(r.rows_affected() == 1)
645
}
646
647
pub async fn deny_device(pool: &Pool, id: i64) -> sqlx::Result<()> {
648
    sqlx::query("UPDATE device_codes SET denied = 1 WHERE id = ? AND account_id IS NULL").bind(id).execute(pool).await?;
649
    Ok(())
650
}
651
652
pub async fn device_polled(pool: &Pool, id: i64) -> sqlx::Result<()> {
653
    sqlx::query("UPDATE device_codes SET last_poll_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?;
654
    Ok(())
655
}
656
657
/// Remove a code; true when this call removed it (so one poll gets the
658
/// token, however many race).
659
pub async fn delete_device(pool: &Pool, id: i64) -> sqlx::Result<bool> {
660
    let r = sqlx::query("DELETE FROM device_codes WHERE id = ?").bind(id).execute(pool).await?;
661
    Ok(r.rows_affected() == 1)
662
}
663
664
// -------------------------------------------------------------- identities
665
666
pub async fn identities_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<LinkedIdentity>> {
667
    sqlx::query_as("SELECT * FROM identities WHERE account_id = ? ORDER BY linked_ms DESC").bind(account_id).fetch_all(pool).await
668
}
669
670
pub async fn identity_by_key(pool: &Pool, key: &str) -> sqlx::Result<Option<LinkedIdentity>> {
671
    sqlx::query_as("SELECT * FROM identities WHERE key = ?").bind(key).fetch_optional(pool).await
672
}
673
674
/// Every linked key with its account's username, for attribution.
675
pub async fn identity_accounts(pool: &Pool) -> sqlx::Result<Vec<(String, String)>> {
676
    sqlx::query_as("SELECT i.key, a.username FROM identities i JOIN accounts a ON a.id = i.account_id").fetch_all(pool).await
677
}
678
679
pub async fn link_identity(pool: &Pool, account_id: i64, key: &str, name: &str, email: &str, object: &[u8]) -> sqlx::Result<()> {
680
    sqlx::query("INSERT INTO identities (key, account_id, name, email, object, linked_ms) VALUES (?, ?, ?, ?, ?, ?)")
681
        .bind(key)
682
        .bind(account_id)
683
        .bind(name)
684
        .bind(email)
685
        .bind(object)
686
        .bind(now_ms())
687
        .execute(pool)
688
        .await?;
689
    Ok(())
690
}
691
692
pub async fn unlink_identity(pool: &Pool, account_id: i64, key: &str) -> sqlx::Result<()> {
693
    sqlx::query("DELETE FROM identities WHERE key = ? AND account_id = ?").bind(key).bind(account_id).execute(pool).await?;
694
    Ok(())
695
}
696
697
pub async fn create_challenge(pool: &Pool, account_id: i64, bytes_hex: &str) -> sqlx::Result<i64> {
698
    sqlx::query("DELETE FROM challenges WHERE created_ms < ?").bind(now_ms() - 10 * 60 * 1000).execute(pool).await?;
699
    let id: i64 = sqlx::query_scalar("INSERT INTO challenges (account_id, bytes, created_ms) VALUES (?, ?, ?) RETURNING id")
700
        .bind(account_id)
701
        .bind(bytes_hex)
702
        .bind(now_ms())
703
        .fetch_one(pool)
704
        .await?;
705
    Ok(id)
706
}
707
708
/// Take (and delete) a pending challenge of this account.
709
pub async fn take_challenge(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<Option<String>> {
710
    let bytes: Option<String> = sqlx::query_scalar("SELECT bytes FROM challenges WHERE id = ? AND account_id = ? AND created_ms >= ?")
711
        .bind(id)
712
        .bind(account_id)
713
        .bind(now_ms() - 10 * 60 * 1000)
714
        .fetch_optional(pool)
715
        .await?;
716
    sqlx::query("DELETE FROM challenges WHERE id = ?").bind(id).execute(pool).await?;
717
    Ok(bytes)
718
}
719
720
// ------------------------------------------------------------- invitations
721
722
pub async fn create_invitation(pool: &Pool, code: &str, created_by: Option<i64>) -> sqlx::Result<()> {
723
    sqlx::query("INSERT INTO invitations (code, created_by, created_ms) VALUES (?, ?, ?)")
724
        .bind(code)
725
        .bind(created_by)
726
        .bind(now_ms())
727
        .execute(pool)
728
        .await?;
729
    Ok(())
730
}
731
732
pub async fn invitation_unused(pool: &Pool, code: &str) -> sqlx::Result<bool> {
733
    let n: i64 =
734
        sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE code = ? AND used_by IS NULL").bind(code).fetch_one(pool).await?;
735
    Ok(n > 0)
736
}
737
738
pub async fn use_invitation(pool: &Pool, code: &str, account_id: i64) -> sqlx::Result<()> {
739
    sqlx::query("UPDATE invitations SET used_by = ? WHERE code = ?").bind(account_id).bind(code).execute(pool).await?;
740
    Ok(())
741
}
742
743
pub async fn invitations(pool: &Pool) -> sqlx::Result<Vec<(String, Option<i64>, i64)>> {
744
    sqlx::query_as("SELECT code, used_by, created_ms FROM invitations ORDER BY created_ms DESC").fetch_all(pool).await
745
}
746
747
// ---------------------------------------------------------------- settings
748
749
pub async fn setting(pool: &Pool, key: &str) -> sqlx::Result<Option<String>> {
750
    sqlx::query_scalar("SELECT value FROM settings WHERE key = ?").bind(key).fetch_optional(pool).await
751
}
752
753
pub async fn set_setting(pool: &Pool, key: &str, value: &str) -> sqlx::Result<()> {
754
    sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value")
755
        .bind(key)
756
        .bind(value)
757
        .execute(pool)
758
        .await?;
759
    Ok(())
760
}
761
762
// ------------------------------------------------------------------- audit
763
764
pub async fn audit(pool: &Pool, actor: Option<&Account>, action: &str, target: &str, detail: &str) -> sqlx::Result<()> {
765
    sqlx::query("INSERT INTO audit (at_ms, actor_id, actor, action, target, detail) VALUES (?, ?, ?, ?, ?, ?)")
766
        .bind(now_ms())
767
        .bind(actor.map(|a| a.id))
768
        .bind(actor.map(|a| a.username.as_str()).unwrap_or(""))
769
        .bind(action)
770
        .bind(target)
771
        .bind(detail)
772
        .execute(pool)
773
        .await?;
774
    Ok(())
775
}
776
777
/// A page of the audit log, newest first, older than `before` (an id) when given.
778
pub async fn audit_page(pool: &Pool, before: Option<i64>, limit: i64) -> sqlx::Result<Vec<AuditRow>> {
779
    sqlx::query_as("SELECT id, at_ms, actor, action, target, detail FROM audit WHERE (? IS NULL OR id < ?) ORDER BY id DESC LIMIT ?")
780
        .bind(before)
781
        .bind(before)
782
        .bind(limit)
783
        .fetch_all(pool)
784
        .await
785
}
786
787
pub async fn audit_rows(pool: &Pool, limit: i64) -> sqlx::Result<Vec<AuditRow>> {
788
    sqlx::query_as("SELECT id, at_ms, actor, action, target, detail FROM audit ORDER BY at_ms DESC, id DESC LIMIT ?")
789
        .bind(limit)
790
        .fetch_all(pool)
791
        .await
792
}
793
794
/// The repositories an account can see beyond the public ones: its own,
795
/// its organisations', and those it collaborates on.
796
pub async fn visible_repo_ids(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<i64>> {
797
    sqlx::query_scalar(
798
        "SELECT r.id FROM repositories r WHERE r.owner IN (SELECT username FROM accounts WHERE id = ?) \
799
         OR r.owner IN (SELECT o.name FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = ?) \
800
         OR r.id IN (SELECT repo_id FROM collaborators WHERE account_id = ? OR team_id IN (SELECT team_id FROM team_members WHERE account_id = ?))",
801
    )
802
    .bind(account_id)
803
    .bind(account_id)
804
    .bind(account_id)
805
    .bind(account_id)
806
    .fetch_all(pool)
807
    .await
808
}
809
810
// ------------------------------------------------------ account links
811
812
/// A verification or reset link (hub spec §5.1.1).
813
#[derive(Clone, Debug, FromRow)]
814
pub struct AccountLink {
815
    pub id: i64,
816
    pub account_id: i64,
817
    pub kind: String,
818
    pub hash: String,
819
    pub email: String,
820
    pub created_by: Option<i64>,
821
    pub created_ms: i64,
822
    pub expires_ms: i64,
823
    pub used_ms: Option<i64>,
824
}
825
826
/// Make a link; returns its secret (shown or sent once, stored as a hash).
827
pub async fn create_link(
828
    pool: &Pool,
829
    account_id: i64,
830
    kind: &str,
831
    email: &str,
832
    created_by: Option<i64>,
833
    ttl_ms: i64,
834
) -> sqlx::Result<String> {
835
    let secret = crate::auth::random_hex(32);
836
    let now = now_ms();
837
    sqlx::query(
838
        "INSERT INTO account_links (account_id, kind, hash, email, created_by, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?)",
839
    )
840
    .bind(account_id)
841
    .bind(kind)
842
    .bind(crate::auth::token_hash(&secret))
843
    .bind(email)
844
    .bind(created_by)
845
    .bind(now)
846
    .bind(now + ttl_ms)
847
    .execute(pool)
848
    .await?;
849
    Ok(secret)
850
}
851
852
/// An unused, unexpired link of this kind with this secret.
853
pub async fn live_link(pool: &Pool, kind: &str, secret: &str) -> sqlx::Result<Option<AccountLink>> {
854
    sqlx::query_as("SELECT * FROM account_links WHERE kind = ? AND hash = ? AND used_ms IS NULL AND expires_ms > ?")
855
        .bind(kind)
856
        .bind(crate::auth::token_hash(secret))
857
        .bind(now_ms())
858
        .fetch_optional(pool)
859
        .await
860
}
861
862
/// Consume a link; false when it was already used (a race lost).
863
pub async fn use_link(pool: &Pool, id: i64) -> sqlx::Result<bool> {
864
    let r =
865
        sqlx::query("UPDATE account_links SET used_ms = ? WHERE id = ? AND used_ms IS NULL").bind(now_ms()).bind(id).execute(pool).await?;
866
    Ok(r.rows_affected() == 1)
867
}
868
869
/// Void every unused link of a kind for an account.
870
pub async fn void_links(pool: &Pool, account_id: i64, kind: &str) -> sqlx::Result<()> {
871
    sqlx::query("UPDATE account_links SET used_ms = ? WHERE account_id = ? AND kind = ? AND used_ms IS NULL")
872
        .bind(now_ms())
873
        .bind(account_id)
874
        .bind(kind)
875
        .execute(pool)
876
        .await?;
877
    Ok(())
878
}
879
880
/// When the account's newest link of a kind (for this address, when given) was made, for rate limits.
881
pub async fn last_link_ms(pool: &Pool, account_id: i64, kind: &str, email: Option<&str>) -> sqlx::Result<Option<i64>> {
882
    match email {
883
        Some(e) => {
884
            sqlx::query_scalar("SELECT MAX(created_ms) FROM account_links WHERE account_id = ? AND kind = ? AND email = ?")
885
                .bind(account_id)
886
                .bind(kind)
887
                .bind(e)
888
                .fetch_one(pool)
889
                .await
890
        }
891
        None => {
892
            sqlx::query_scalar("SELECT MAX(created_ms) FROM account_links WHERE account_id = ? AND kind = ?")
893
                .bind(account_id)
894
                .bind(kind)
895
                .fetch_one(pool)
896
                .await
897
        }
898
    }
899
}
900
901
/// How many links of a kind the account got since `since_ms`.
902
pub async fn links_since(pool: &Pool, account_id: i64, kind: &str, since_ms: i64) -> sqlx::Result<i64> {
903
    sqlx::query_scalar("SELECT COUNT(*) FROM account_links WHERE account_id = ? AND kind = ? AND created_ms >= ?")
904
        .bind(account_id)
905
        .bind(kind)
906
        .bind(since_ms)
907
        .fetch_one(pool)
908
        .await
909
}
910
911
pub async fn set_email_verified(pool: &Pool, id: i64, verified: bool) -> sqlx::Result<()> {
912
    sqlx::query("UPDATE accounts SET email_verified = ? WHERE id = ?").bind(verified).bind(id).execute(pool).await?;
913
    Ok(())
914
}
915
916
/// The account with this verified email (case-insensitive), if exactly one.
917
pub async fn account_by_verified_email(pool: &Pool, email: &str) -> sqlx::Result<Option<Account>> {
918
    let found: Vec<Account> = sqlx::query_as("SELECT * FROM accounts WHERE email_verified = 1 AND lower(email) = lower(?) LIMIT 2")
919
        .bind(email)
920
        .fetch_all(pool)
921
        .await?;
922
    Ok(if found.len() == 1 { found.into_iter().next() } else { None })
923
}
924
925
/// Set or clear the picture of an account (`account`) or organisation (`org`).
926
pub async fn set_avatar(pool: &Pool, kind: &str, id: i64, value: &str) -> sqlx::Result<()> {
927
    let table = if kind == "org" { "organisations" } else { "accounts" };
928
    sqlx::query(&format!("UPDATE {table} SET avatar = ? WHERE id = ?")).bind(value).bind(id).execute(pool).await?;
929
    Ok(())
930
}