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