Milestone 0.2: accounts and access 19bb31ff1f5c
Accounts with Argon2id passwords, signed session cookies, CSRF tokens and a same-origin check, TOTP with recovery codes; registration modes and invitations; organisations, teams and collaborator roles; visibility enforced for every viewer and for the wire protocol, where an unreadable repository is a 404; scoped personal access tokens; linked Oxygen identities proven by challenge signatures, with authors attributed to accounts; settings, organisation, repository-settings and admin pages; admin user commands; an end-to-end test of the whole flow.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 18:26 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
19bb31ff1f5c8ff9ba3d735ab52243e72182fbad1cfd352eaf986d65c9daee12- Depends on
- 5c6eb1feaa71 62d6e969f180 9ff27da517fe d23464c0ac60
- Depended on by
- 1062a0f8e985 40a43921ef42 50928fab5ffc a22e4aec2e28 1ec29691b67a 351cf57e5608 0468394734e9 668b2ad7495c 15d3f6f7871b dc5c64296b0a 8501fd5815b9 8f0efa0729b0 03bfb945f5c1 ba0a55bb5b3c 8ad8e656a3a6 dd327d76062d 5588bb3631d2 737de0fac722 b349e15a3eab 8597fb5ca16c 367f34b8e590 787632570bf9 166f6e9bf4f1 c7067c7bda56 cb7b3cd45dc0 0ee5c3fb32eb a9f1c0bf403a 4a68f1064b08 86a69b53aff0 7c06f5b28ade 16ee5d19d220 a10446c68043 8a48dd830178 8dcaeeb848c6 a7aa76ac52d7 e5b97ac399e7 559c39204234 8aa5ef384ef1 76f93eaefe45 107eaba52c9d c8684d44d549 3b1bc27fe805 97b18869acf5 ea14c4ec5c3f d1bf2d698777 5d90e0e045e2 f59e9680d880 2cbf6f348595 3ff52534f69b 7b17d570d7ee 0024ccb1b050 3dd6582de060 d74ce22fa80e aa0bfbd11f02 9a777fec4ed9 9a8e3b4419e0 1870e4fa9808 c0095389312b a2fbad481dd7 2a0c09c09716 810896d41be7 ffb67a81188c 00ef7b83b340 b91e2d81b3f1 bff9106edcba cd46e56b1709 d3b048b27277 1e8a6e8bb91b f4d6ef418a96 da8f7826a645 35fea4237cc9 d7d182467382 6246d1e51f4f 17ca6cddc534 c9aacc0e7dc1 757195ad7a5d 3776b70d9b17 e71583501b1d 6ca0b971ff07 c8df33a6829f 0b203bbf7cc3 7edaff01d506 8ddebaa7e856 1f78655a0466 62ceb2eec18c 155cab142b63 e8e7158bbc73 206c1fc1b06a 83940d53664f 66fd9d6b1452 d0fbea2eb46e 89e89836cae7 4ac01a732552 2a83f5303458 e71846aa07b9 3556d08e17f9 8933faff2043 5dead1f4f26d ea85398a51bc cb71e18eb6f9 6a1780558353 d98a93c10a0d 8e820b4ae875 ae138eea7a71 2bbe88a85860 60f11b71a740 7974adb0be66 df71c7892d27 ff6569530173 462f88d161ce e9bfea8e26db 1b3a00986a5a b6b7e4cf6b24 c5f3bde466d1 760a7dd30d6f e271a37b629a 9008bf6c2b8c 941abdd6bb4c fd3db644df22 83d5052bf76c ef20fdc14e8b 9756c67d3aec e1ec9fbf538e 8fb35df7d66e 756fe44a0f4d a160b98fb922 34beb1a7e376 736f6813543d 42fa95540468 8d2caf122517 64caed18236a 1ac84c62ba53 1553bfc9ae53 07b1a5bbf481 9df855f29948 101740454617 c40c2d3edb51 25ab8c73514a b45384c909c1 3f4b0ea6eabf 145306044896 b64e2537d702 18e381ea88c2 de505df5cf8d
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 193 atoms, 25 tree operations, 48 files
Browse the tree at this change
added crates/hub/migrations/0003_accounts.sql +115 −0
| 1 | + | -- Accounts, organisations, roles, tokens, linked identities and the audit |
|
| 2 | + | -- log (hub spec §5). Repository content stays in the object stores. |
|
| 3 | + | ||
| 4 | + | CREATE TABLE accounts ( |
|
| 5 | + | id INTEGER PRIMARY KEY, |
|
| 6 | + | username TEXT NOT NULL UNIQUE COLLATE NOCASE, |
|
| 7 | + | display_name TEXT NOT NULL DEFAULT '', |
|
| 8 | + | email TEXT NOT NULL DEFAULT '', |
|
| 9 | + | email_verified INTEGER NOT NULL DEFAULT 0, |
|
| 10 | + | password_hash TEXT NOT NULL, |
|
| 11 | + | session_salt TEXT NOT NULL, |
|
| 12 | + | totp_secret TEXT, |
|
| 13 | + | recovery_codes TEXT NOT NULL DEFAULT '', |
|
| 14 | + | is_admin INTEGER NOT NULL DEFAULT 0, |
|
| 15 | + | banned INTEGER NOT NULL DEFAULT 0, |
|
| 16 | + | created_ms INTEGER NOT NULL, |
|
| 17 | + | last_seen_ms INTEGER NOT NULL DEFAULT 0 |
|
| 18 | + | ); |
|
| 19 | + | ||
| 20 | + | CREATE TABLE organisations ( |
|
| 21 | + | id INTEGER PRIMARY KEY, |
|
| 22 | + | name TEXT NOT NULL UNIQUE COLLATE NOCASE, |
|
| 23 | + | display_name TEXT NOT NULL DEFAULT '', |
|
| 24 | + | description TEXT NOT NULL DEFAULT '', |
|
| 25 | + | default_role TEXT NOT NULL DEFAULT 'read' CHECK (default_role IN ('none', 'read', 'triage', 'write', 'maintain', 'admin')), |
|
| 26 | + | created_ms INTEGER NOT NULL |
|
| 27 | + | ); |
|
| 28 | + | ||
| 29 | + | CREATE TABLE org_members ( |
|
| 30 | + | org_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, |
|
| 31 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 32 | + | role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')), |
|
| 33 | + | PRIMARY KEY (org_id, account_id) |
|
| 34 | + | ); |
|
| 35 | + | ||
| 36 | + | CREATE TABLE teams ( |
|
| 37 | + | id INTEGER PRIMARY KEY, |
|
| 38 | + | org_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, |
|
| 39 | + | name TEXT NOT NULL COLLATE NOCASE, |
|
| 40 | + | UNIQUE (org_id, name) |
|
| 41 | + | ); |
|
| 42 | + | ||
| 43 | + | CREATE TABLE team_members ( |
|
| 44 | + | team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, |
|
| 45 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 46 | + | PRIMARY KEY (team_id, account_id) |
|
| 47 | + | ); |
|
| 48 | + | ||
| 49 | + | -- A collaborator is an account or a team with a role on one repository. |
|
| 50 | + | CREATE TABLE collaborators ( |
|
| 51 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 52 | + | account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 53 | + | team_id INTEGER REFERENCES teams(id) ON DELETE CASCADE, |
|
| 54 | + | role TEXT NOT NULL CHECK (role IN ('read', 'triage', 'write', 'maintain', 'admin')), |
|
| 55 | + | CHECK ((account_id IS NULL) <> (team_id IS NULL)) |
|
| 56 | + | ); |
|
| 57 | + | CREATE UNIQUE INDEX collaborators_account ON collaborators (repo_id, account_id) WHERE account_id IS NOT NULL; |
|
| 58 | + | CREATE UNIQUE INDEX collaborators_team ON collaborators (repo_id, team_id) WHERE team_id IS NOT NULL; |
|
| 59 | + | ||
| 60 | + | CREATE TABLE tokens ( |
|
| 61 | + | id INTEGER PRIMARY KEY, |
|
| 62 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 63 | + | name TEXT NOT NULL, |
|
| 64 | + | hash TEXT NOT NULL UNIQUE, |
|
| 65 | + | scopes TEXT NOT NULL, |
|
| 66 | + | repos TEXT NOT NULL DEFAULT '', |
|
| 67 | + | created_ms INTEGER NOT NULL, |
|
| 68 | + | expires_ms INTEGER, |
|
| 69 | + | last_used_ms INTEGER |
|
| 70 | + | ); |
|
| 71 | + | ||
| 72 | + | -- Oxygen identities an account has proven it holds. |
|
| 73 | + | CREATE TABLE identities ( |
|
| 74 | + | key TEXT PRIMARY KEY, |
|
| 75 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 76 | + | name TEXT NOT NULL, |
|
| 77 | + | email TEXT NOT NULL, |
|
| 78 | + | object BLOB NOT NULL, |
|
| 79 | + | linked_ms INTEGER NOT NULL |
|
| 80 | + | ); |
|
| 81 | + | ||
| 82 | + | CREATE TABLE challenges ( |
|
| 83 | + | id INTEGER PRIMARY KEY, |
|
| 84 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 85 | + | bytes TEXT NOT NULL, |
|
| 86 | + | object BLOB NOT NULL DEFAULT X'', |
|
| 87 | + | created_ms INTEGER NOT NULL |
|
| 88 | + | ); |
|
| 89 | + | ||
| 90 | + | CREATE TABLE invitations ( |
|
| 91 | + | code TEXT PRIMARY KEY, |
|
| 92 | + | created_by INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 93 | + | used_by INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 94 | + | created_ms INTEGER NOT NULL |
|
| 95 | + | ); |
|
| 96 | + | ||
| 97 | + | CREATE TABLE audit ( |
|
| 98 | + | id INTEGER PRIMARY KEY, |
|
| 99 | + | at_ms INTEGER NOT NULL, |
|
| 100 | + | actor_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 101 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 102 | + | action TEXT NOT NULL, |
|
| 103 | + | target TEXT NOT NULL DEFAULT '', |
|
| 104 | + | detail TEXT NOT NULL DEFAULT '' |
|
| 105 | + | ); |
|
| 106 | + | CREATE INDEX audit_by_time ON audit (at_ms DESC); |
|
| 107 | + | ||
| 108 | + | -- Site settings that an administrator changes at run time. |
|
| 109 | + | CREATE TABLE settings ( |
|
| 110 | + | key TEXT PRIMARY KEY, |
|
| 111 | + | value TEXT NOT NULL |
|
| 112 | + | ); |
|
| 113 | + | ||
| 114 | + | ALTER TABLE pushes ADD COLUMN account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL; |
|
| 115 | + | ALTER TABLE pushes ADD COLUMN pusher TEXT NOT NULL DEFAULT ''; |
added crates/hub/src/auth/mod.rs +132 −0
| 1 | + | //! Authentication and authorisation (hub spec §5, §15.3): passwords, |
|
| 2 | + | //! session cookies, CSRF tokens, TOTP, personal access tokens, roles, and |
|
| 3 | + | //! the viewer extractor pages use. |
|
| 4 | + | ||
| 5 | + | pub mod roles; |
|
| 6 | + | pub mod totp; |
|
| 7 | + | pub mod viewer; |
|
| 8 | + | ||
| 9 | + | pub use roles::{Access, Role}; |
|
| 10 | + | pub use viewer::{Viewer, ViewerAccount}; |
|
| 11 | + | ||
| 12 | + | use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; |
|
| 13 | + | use argon2::Argon2; |
|
| 14 | + | use rand::RngCore; |
|
| 15 | + | use std::path::Path; |
|
| 16 | + | use subtle::ConstantTimeEq; |
|
| 17 | + | ||
| 18 | + | /// The key that signs cookies and CSRF tokens, kept in `<data>/keys/cookie.key`. |
|
| 19 | + | #[derive(Clone)] |
|
| 20 | + | pub struct CookieKey([u8; 32]); |
|
| 21 | + | ||
| 22 | + | impl CookieKey { |
|
| 23 | + | pub fn load_or_create(keys: &Path) -> anyhow::Result<CookieKey> { |
|
| 24 | + | std::fs::create_dir_all(keys)?; |
|
| 25 | + | let path = keys.join("cookie.key"); |
|
| 26 | + | if let Ok(bytes) = std::fs::read(&path) { |
|
| 27 | + | if bytes.len() == 32 { |
|
| 28 | + | return Ok(CookieKey(bytes.try_into().unwrap())); |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | let mut k = [0u8; 32]; |
|
| 32 | + | rand::rngs::OsRng.fill_bytes(&mut k); |
|
| 33 | + | std::fs::write(&path, k)?; |
|
| 34 | + | #[cfg(unix)] |
|
| 35 | + | { |
|
| 36 | + | use std::os::unix::fs::PermissionsExt; |
|
| 37 | + | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; |
|
| 38 | + | } |
|
| 39 | + | Ok(CookieKey(k)) |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | /// Keyed BLAKE3 over `message`, as hex. |
|
| 43 | + | pub fn mac(&self, message: &[u8]) -> String { |
|
| 44 | + | hex::encode(blake3::keyed_hash(&self.0, message).as_bytes()) |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /// Constant-time equality of two hex MACs. |
|
| 49 | + | pub fn mac_equal(a: &str, b: &str) -> bool { |
|
| 50 | + | a.len() == b.len() && a.as_bytes().ct_eq(b.as_bytes()).into() |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | pub fn hash_password(password: &str) -> anyhow::Result<String> { |
|
| 54 | + | let salt = SaltString::generate(&mut rand::rngs::OsRng); |
|
| 55 | + | Ok(Argon2::default().hash_password(password.as_bytes(), &salt).map_err(|e| anyhow::anyhow!("{e}"))?.to_string()) |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | pub fn verify_password(hash: &str, password: &str) -> bool { |
|
| 59 | + | match PasswordHash::new(hash) { |
|
| 60 | + | Ok(parsed) => Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok(), |
|
| 61 | + | Err(_) => false, |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | /// A password policy that is honest rather than theatrical: length only. |
|
| 66 | + | pub fn password_ok(password: &str) -> Result<(), &'static str> { |
|
| 67 | + | if password.chars().count() < 10 { |
|
| 68 | + | return Err("a password is at least 10 characters"); |
|
| 69 | + | } |
|
| 70 | + | if password.len() > 256 { |
|
| 71 | + | return Err("a password is at most 256 bytes"); |
|
| 72 | + | } |
|
| 73 | + | Ok(()) |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | pub fn random_hex(bytes: usize) -> String { |
|
| 77 | + | let mut b = vec![0u8; bytes]; |
|
| 78 | + | rand::rngs::OsRng.fill_bytes(&mut b); |
|
| 79 | + | hex::encode(b) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// A personal access token: `oxyhub_` plus 32 random bytes as hex. Stored |
|
| 83 | + | /// as its BLAKE3 hash. |
|
| 84 | + | pub fn new_token() -> (String, String) { |
|
| 85 | + | let secret = format!("oxyhub_{}", random_hex(32)); |
|
| 86 | + | (secret.clone(), token_hash(&secret)) |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | pub fn token_hash(secret: &str) -> String { |
|
| 90 | + | hex::encode(blake3::hash(secret.as_bytes()).as_bytes()) |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /// The session cookie's value for an account: `username.mac`. |
|
| 94 | + | pub fn session_value(key: &CookieKey, username: &str, session_salt: &str) -> String { |
|
| 95 | + | format!("{username}.{}", key.mac(format!("session:{username}:{session_salt}").as_bytes())) |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | /// The CSRF token bound to a session value. |
|
| 99 | + | pub fn csrf_token(key: &CookieKey, session_value: &str) -> String { |
|
| 100 | + | key.mac(format!("csrf:{session_value}").as_bytes()) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | pub const SESSION_COOKIE: &str = "oxyhub"; |
|
| 104 | + | ||
| 105 | + | #[cfg(test)] |
|
| 106 | + | mod tests { |
|
| 107 | + | use super::*; |
|
| 108 | + | ||
| 109 | + | #[test] |
|
| 110 | + | fn passwords_and_tokens() { |
|
| 111 | + | let h = hash_password("correct horse battery").unwrap(); |
|
| 112 | + | assert!(verify_password(&h, "correct horse battery")); |
|
| 113 | + | assert!(!verify_password(&h, "wrong")); |
|
| 114 | + | assert!(!verify_password("garbage", "x")); |
|
| 115 | + | assert!(password_ok("short").is_err()); |
|
| 116 | + | let (secret, hash) = new_token(); |
|
| 117 | + | assert!(secret.starts_with("oxyhub_")); |
|
| 118 | + | assert_eq!(token_hash(&secret), hash); |
|
| 119 | + | assert!(mac_equal("ab", "ab") && !mac_equal("ab", "ac") && !mac_equal("a", "ab")); |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | #[test] |
|
| 123 | + | fn cookie_key_persists() { |
|
| 124 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 125 | + | let a = CookieKey::load_or_create(dir.path()).unwrap(); |
|
| 126 | + | let b = CookieKey::load_or_create(dir.path()).unwrap(); |
|
| 127 | + | assert_eq!(a.mac(b"x"), b.mac(b"x")); |
|
| 128 | + | let v = session_value(&a, "andy", "salt"); |
|
| 129 | + | assert!(v.starts_with("andy.")); |
|
| 130 | + | assert_ne!(csrf_token(&a, &v), csrf_token(&a, "other")); |
|
| 131 | + | } |
|
| 132 | + | } |
added crates/hub/src/auth/roles.rs +118 −0
| 1 | + | //! Roles on repositories and organisations (hub spec §5.2, §5.5). The hub's |
|
| 2 | + | //! roles decide who may reach a repository and attempt a push; the |
|
| 3 | + | //! repository's trust policy decides which keys may move which references. |
|
| 4 | + | ||
| 5 | + | use crate::db::{self, accounts::Account, repos::RepoRow, Pool}; |
|
| 6 | + | use hub_api::Visibility; |
|
| 7 | + | ||
| 8 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] |
|
| 9 | + | #[repr(u8)] |
|
| 10 | + | pub enum Role { |
|
| 11 | + | Read = 1, |
|
| 12 | + | Triage = 2, |
|
| 13 | + | Write = 3, |
|
| 14 | + | Maintain = 4, |
|
| 15 | + | Admin = 5, |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | impl Role { |
|
| 19 | + | pub fn parse(s: &str) -> Option<Role> { |
|
| 20 | + | Some(match s { |
|
| 21 | + | "read" => Role::Read, |
|
| 22 | + | "triage" => Role::Triage, |
|
| 23 | + | "write" => Role::Write, |
|
| 24 | + | "maintain" => Role::Maintain, |
|
| 25 | + | "admin" => Role::Admin, |
|
| 26 | + | _ => return None, |
|
| 27 | + | }) |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | pub fn as_str(self) -> &'static str { |
|
| 31 | + | match self { |
|
| 32 | + | Role::Read => "read", |
|
| 33 | + | Role::Triage => "triage", |
|
| 34 | + | Role::Write => "write", |
|
| 35 | + | Role::Maintain => "maintain", |
|
| 36 | + | Role::Admin => "admin", |
|
| 37 | + | } |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | pub const ALL: [Role; 5] = [Role::Read, Role::Triage, Role::Write, Role::Maintain, Role::Admin]; |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | /// What a request wants to do with a repository. |
|
| 44 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 45 | + | pub enum Access { |
|
| 46 | + | Read, |
|
| 47 | + | Write, |
|
| 48 | + | Maintain, |
|
| 49 | + | Admin, |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | impl Access { |
|
| 53 | + | fn needs(self) -> Role { |
|
| 54 | + | match self { |
|
| 55 | + | Access::Read => Role::Read, |
|
| 56 | + | Access::Write => Role::Write, |
|
| 57 | + | Access::Maintain => Role::Maintain, |
|
| 58 | + | Access::Admin => Role::Admin, |
|
| 59 | + | } |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /// The role `account` (or nobody) holds on `repo`, from ownership, |
|
| 64 | + | /// organisation membership, collaborator entries and visibility. |
|
| 65 | + | pub async fn role_for(pool: &Pool, account: Option<&Account>, repo: &RepoRow) -> sqlx::Result<Option<Role>> { |
|
| 66 | + | let visibility = repo.visibility(); |
|
| 67 | + | let Some(account) = account else { |
|
| 68 | + | return Ok(if visibility == Visibility::Public { Some(Role::Read) } else { None }); |
|
| 69 | + | }; |
|
| 70 | + | if account.banned { |
|
| 71 | + | return Ok(None); |
|
| 72 | + | } |
|
| 73 | + | if account.is_admin || account.username.eq_ignore_ascii_case(&repo.owner) { |
|
| 74 | + | return Ok(Some(Role::Admin)); |
|
| 75 | + | } |
|
| 76 | + | let mut best: Option<Role> = None; |
|
| 77 | + | if let Some(org) = db::accounts::org_by_name(pool, &repo.owner).await? { |
|
| 78 | + | match db::accounts::org_role(pool, org.id, account.id).await?.as_deref() { |
|
| 79 | + | Some("owner") => return Ok(Some(Role::Admin)), |
|
| 80 | + | Some(_) => best = Role::parse(&org.default_role), |
|
| 81 | + | None => {} |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | if let Some(r) = db::accounts::collaborator_role(pool, repo.id, account.id).await?.as_deref().and_then(Role::parse) { |
|
| 85 | + | best = Some(best.map_or(r, |b| b.max(r))); |
|
| 86 | + | } |
|
| 87 | + | if best.is_none() && visibility != Visibility::Private { |
|
| 88 | + | best = Some(Role::Read); |
|
| 89 | + | } |
|
| 90 | + | Ok(best) |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /// Whether `role` permits `access`. |
|
| 94 | + | pub fn allows(role: Option<Role>, access: Access) -> bool { |
|
| 95 | + | role.is_some_and(|r| r >= access.needs()) |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | /// Whether an account may create repositories under `owner`. |
|
| 99 | + | pub async fn may_create_under(pool: &Pool, account: &Account, owner: &str) -> sqlx::Result<bool> { |
|
| 100 | + | if account.banned { |
|
| 101 | + | return Ok(false); |
|
| 102 | + | } |
|
| 103 | + | if account.username.eq_ignore_ascii_case(owner) || account.is_admin { |
|
| 104 | + | return Ok(true); |
|
| 105 | + | } |
|
| 106 | + | match db::accounts::org_by_name(pool, owner).await? { |
|
| 107 | + | Some(org) => Ok(db::accounts::org_role(pool, org.id, account.id).await?.is_some()), |
|
| 108 | + | None => Ok(false), |
|
| 109 | + | } |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | /// Whether an account administers an organisation. |
|
| 113 | + | pub async fn is_org_owner(pool: &Pool, account: &Account, org_id: i64) -> sqlx::Result<bool> { |
|
| 114 | + | if account.is_admin { |
|
| 115 | + | return Ok(true); |
|
| 116 | + | } |
|
| 117 | + | Ok(db::accounts::org_role(pool, org_id, account.id).await?.as_deref() == Some("owner")) |
|
| 118 | + | } |
added crates/hub/src/auth/totp.rs +91 −0
| 1 | + | //! Time-based one-time passwords (RFC 6238, SHA-1, six digits, 30 s) and |
|
| 2 | + | //! recovery codes (hub spec §15.3). |
|
| 3 | + | ||
| 4 | + | use hmac::{Hmac, Mac}; |
|
| 5 | + | use sha1::Sha1; |
|
| 6 | + | ||
| 7 | + | pub fn new_secret() -> String { |
|
| 8 | + | let mut b = [0u8; 20]; |
|
| 9 | + | rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut b); |
|
| 10 | + | base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &b) |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | fn code_at(secret: &str, counter: u64) -> Option<String> { |
|
| 14 | + | let key = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, secret)?; |
|
| 15 | + | let mut mac = Hmac::<Sha1>::new_from_slice(&key).ok()?; |
|
| 16 | + | mac.update(&counter.to_be_bytes()); |
|
| 17 | + | let digest = mac.finalize().into_bytes(); |
|
| 18 | + | let offset = (digest[19] & 0x0f) as usize; |
|
| 19 | + | let bin = ((digest[offset] as u32 & 0x7f) << 24) |
|
| 20 | + | | ((digest[offset + 1] as u32) << 16) |
|
| 21 | + | | ((digest[offset + 2] as u32) << 8) |
|
| 22 | + | | digest[offset + 3] as u32; |
|
| 23 | + | Some(format!("{:06}", bin % 1_000_000)) |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// The current code, for tests and the enrolment check. |
|
| 27 | + | pub fn current(secret: &str, now_s: u64) -> Option<String> { |
|
| 28 | + | code_at(secret, now_s / 30) |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// Accept the code for this step and its neighbours (one step of drift). |
|
| 32 | + | pub fn verify(secret: &str, code: &str, now_s: u64) -> bool { |
|
| 33 | + | let code = code.trim().replace(' ', ""); |
|
| 34 | + | let step = now_s / 30; |
|
| 35 | + | [step.wrapping_sub(1), step, step + 1].iter().any(|c| code_at(secret, *c).is_some_and(|x| x == code)) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | /// `otpauth://` URI for authenticator apps. |
|
| 39 | + | pub fn uri(site: &str, username: &str, secret: &str) -> String { |
|
| 40 | + | let label = format!("{site}:{username}").replace(' ', "%20"); |
|
| 41 | + | format!("otpauth://totp/{label}?secret={secret}&issuer={}&algorithm=SHA1&digits=6&period=30", site.replace(' ', "%20")) |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | /// The provisioning URI as an SVG QR code. |
|
| 45 | + | pub fn qr_svg(uri: &str) -> Option<String> { |
|
| 46 | + | let code = qrcode::QrCode::new(uri.as_bytes()).ok()?; |
|
| 47 | + | Some(code.render::<qrcode::render::svg::Color>().min_dimensions(180, 180).quiet_zone(true).build()) |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// Ten recovery codes; stored as BLAKE3 hashes separated by commas. |
|
| 51 | + | pub fn new_recovery_codes() -> (Vec<String>, String) { |
|
| 52 | + | let codes: Vec<String> = (0..10).map(|_| super::random_hex(5)).collect(); |
|
| 53 | + | let stored = codes.iter().map(|c| hex::encode(blake3::hash(c.as_bytes()).as_bytes())).collect::<Vec<_>>().join(","); |
|
| 54 | + | (codes, stored) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | /// Use a recovery code: the remaining stored codes if it matched. |
|
| 58 | + | pub fn use_recovery_code(stored: &str, code: &str) -> Option<String> { |
|
| 59 | + | let hash = hex::encode(blake3::hash(code.trim().as_bytes()).as_bytes()); |
|
| 60 | + | let all: Vec<&str> = stored.split(',').filter(|s| !s.is_empty()).collect(); |
|
| 61 | + | if !all.contains(&hash.as_str()) { |
|
| 62 | + | return None; |
|
| 63 | + | } |
|
| 64 | + | Some(all.into_iter().filter(|h| *h != hash).collect::<Vec<_>>().join(",")) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | #[cfg(test)] |
|
| 68 | + | mod tests { |
|
| 69 | + | use super::*; |
|
| 70 | + | ||
| 71 | + | #[test] |
|
| 72 | + | fn rfc_6238_vector() { |
|
| 73 | + | // RFC 6238 appendix B, SHA-1, secret "12345678901234567890", 59 s → 94287082 (8 digits); 6 digits are the last six. |
|
| 74 | + | let secret = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, b"12345678901234567890"); |
|
| 75 | + | assert_eq!(current(&secret, 59).unwrap(), "287082"); |
|
| 76 | + | assert_eq!(current(&secret, 1111111109).unwrap(), "081804"); |
|
| 77 | + | assert!(verify(&secret, "287082", 59)); |
|
| 78 | + | assert!(verify(&secret, "287 082", 75), "drift of one step"); |
|
| 79 | + | assert!(!verify(&secret, "000000", 59)); |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | #[test] |
|
| 83 | + | fn recovery_codes() { |
|
| 84 | + | let (codes, stored) = new_recovery_codes(); |
|
| 85 | + | assert_eq!(codes.len(), 10); |
|
| 86 | + | let rest = use_recovery_code(&stored, &codes[3]).unwrap(); |
|
| 87 | + | assert!(use_recovery_code(&rest, &codes[3]).is_none(), "a code is single use"); |
|
| 88 | + | assert!(use_recovery_code(&rest, &codes[4]).is_some()); |
|
| 89 | + | assert!(qr_svg(&uri("Oxygenhub", "andy", &new_secret())).unwrap().contains("<svg")); |
|
| 90 | + | } |
|
| 91 | + | } |
added crates/hub/src/auth/viewer.rs +86 −0
| 1 | + | //! Who is asking: the session cookie resolved to an account, as an axum |
|
| 2 | + | //! extractor every page handler takes. |
|
| 3 | + | ||
| 4 | + | use super::{csrf_token, mac_equal, session_value, SESSION_COOKIE}; |
|
| 5 | + | use crate::db::{self, accounts::Account}; |
|
| 6 | + | use crate::Shared; |
|
| 7 | + | use axum::extract::FromRequestParts; |
|
| 8 | + | use axum::http::request::Parts; |
|
| 9 | + | use axum::http::{header, HeaderMap}; |
|
| 10 | + | use std::convert::Infallible; |
|
| 11 | + | ||
| 12 | + | #[derive(Clone, Debug)] |
|
| 13 | + | pub struct ViewerAccount { |
|
| 14 | + | pub account: Account, |
|
| 15 | + | /// The cookie value, which CSRF tokens are bound to. |
|
| 16 | + | pub session: String, |
|
| 17 | + | pub csrf: String, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | /// The signed-in account, if any. Never fails: anonymous is a valid viewer. |
|
| 21 | + | #[derive(Clone, Debug, Default)] |
|
| 22 | + | pub struct Viewer(pub Option<ViewerAccount>); |
|
| 23 | + | ||
| 24 | + | impl Viewer { |
|
| 25 | + | pub fn account(&self) -> Option<&Account> { |
|
| 26 | + | self.0.as_ref().map(|v| &v.account) |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | pub fn signed_in(&self) -> bool { |
|
| 30 | + | self.0.is_some() |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | pub fn is_admin(&self) -> bool { |
|
| 34 | + | self.account().is_some_and(|a| a.is_admin) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | pub fn csrf(&self) -> &str { |
|
| 38 | + | self.0.as_ref().map(|v| v.csrf.as_str()).unwrap_or("") |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// Whether a submitted CSRF token belongs to this session. |
|
| 42 | + | pub fn csrf_ok(&self, token: &str) -> bool { |
|
| 43 | + | self.0.as_ref().is_some_and(|v| mac_equal(&v.csrf, token)) |
|
| 44 | + | } |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | /// The value of a named cookie in the request. |
|
| 48 | + | pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> { |
|
| 49 | + | headers.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok()).flat_map(|line| line.split(';')).find_map(|kv| { |
|
| 50 | + | let (k, v) = kv.trim().split_once('=')?; |
|
| 51 | + | (k == name).then(|| v.trim().to_string()) |
|
| 52 | + | }) |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | /// Resolve a session cookie value to its account. |
|
| 56 | + | pub async fn resolve(app: &Shared, value: &str) -> Option<ViewerAccount> { |
|
| 57 | + | let (username, mac) = value.rsplit_once('.')?; |
|
| 58 | + | let account = db::accounts::account_by_name(&app.db, username).await.ok()??; |
|
| 59 | + | if account.banned { |
|
| 60 | + | return None; |
|
| 61 | + | } |
|
| 62 | + | let expected = session_value(&app.cookie_key, &account.username, &account.session_salt); |
|
| 63 | + | let (_, expected_mac) = expected.rsplit_once('.')?; |
|
| 64 | + | if !mac_equal(expected_mac, mac) { |
|
| 65 | + | return None; |
|
| 66 | + | } |
|
| 67 | + | let csrf = csrf_token(&app.cookie_key, &expected); |
|
| 68 | + | Some(ViewerAccount { account, session: expected, csrf }) |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | impl FromRequestParts<Shared> for Viewer { |
|
| 72 | + | type Rejection = Infallible; |
|
| 73 | + | ||
| 74 | + | async fn from_request_parts(parts: &mut Parts, app: &Shared) -> Result<Viewer, Infallible> { |
|
| 75 | + | let Some(value) = cookie(&parts.headers, SESSION_COOKIE) else { return Ok(Viewer(None)) }; |
|
| 76 | + | let viewer = resolve(app, &value).await; |
|
| 77 | + | if let Some(v) = &viewer { |
|
| 78 | + | let pool = app.db.clone(); |
|
| 79 | + | let id = v.account.id; |
|
| 80 | + | tokio::spawn(async move { |
|
| 81 | + | let _ = db::accounts::touch_seen(&pool, id).await; |
|
| 82 | + | }); |
|
| 83 | + | } |
|
| 84 | + | Ok(Viewer(viewer)) |
|
| 85 | + | } |
|
| 86 | + | } |
added crates/hub/src/db/accounts.rs +571 −0
| 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 | + | pub created_ms: i64, |
|
| 21 | + | pub last_seen_ms: i64, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl Account { |
|
| 25 | + | pub fn name(&self) -> &str { |
|
| 26 | + | if self.display_name.is_empty() { |
|
| 27 | + | &self.username |
|
| 28 | + | } else { |
|
| 29 | + | &self.display_name |
|
| 30 | + | } |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | pub fn totp_enabled(&self) -> bool { |
|
| 34 | + | self.totp_secret.is_some() |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | #[derive(Clone, Debug, FromRow)] |
|
| 39 | + | pub struct Organisation { |
|
| 40 | + | pub id: i64, |
|
| 41 | + | pub name: String, |
|
| 42 | + | pub display_name: String, |
|
| 43 | + | pub description: String, |
|
| 44 | + | pub default_role: String, |
|
| 45 | + | pub created_ms: i64, |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | #[derive(Clone, Debug, FromRow)] |
|
| 49 | + | pub struct Team { |
|
| 50 | + | pub id: i64, |
|
| 51 | + | pub org_id: i64, |
|
| 52 | + | pub name: String, |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | #[derive(Clone, Debug, FromRow)] |
|
| 56 | + | pub struct Token { |
|
| 57 | + | pub id: i64, |
|
| 58 | + | pub account_id: i64, |
|
| 59 | + | pub name: String, |
|
| 60 | + | pub hash: String, |
|
| 61 | + | pub scopes: String, |
|
| 62 | + | pub repos: String, |
|
| 63 | + | pub created_ms: i64, |
|
| 64 | + | pub expires_ms: Option<i64>, |
|
| 65 | + | pub last_used_ms: Option<i64>, |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | impl Token { |
|
| 69 | + | pub fn scope_list(&self) -> Vec<&str> { |
|
| 70 | + | self.scopes.split(',').filter(|s| !s.is_empty()).collect() |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | pub fn has_scope(&self, scope: &str) -> bool { |
|
| 74 | + | self.scope_list().contains(&scope) |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | /// Whether the token may touch `owner/name`: every repository when the |
|
| 78 | + | /// list is empty. |
|
| 79 | + | pub fn covers(&self, slug: &str) -> bool { |
|
| 80 | + | self.repos.is_empty() || self.repos.split(',').any(|r| r.trim().eq_ignore_ascii_case(slug)) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | pub fn expired(&self, now: i64) -> bool { |
|
| 84 | + | self.expires_ms.is_some_and(|e| e < now) |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | #[derive(Clone, Debug, FromRow)] |
|
| 89 | + | pub struct LinkedIdentity { |
|
| 90 | + | pub key: String, |
|
| 91 | + | pub account_id: i64, |
|
| 92 | + | pub name: String, |
|
| 93 | + | pub email: String, |
|
| 94 | + | pub object: Vec<u8>, |
|
| 95 | + | pub linked_ms: i64, |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | #[derive(Clone, Debug, FromRow)] |
|
| 99 | + | pub struct AuditRow { |
|
| 100 | + | pub id: i64, |
|
| 101 | + | pub at_ms: i64, |
|
| 102 | + | pub actor: String, |
|
| 103 | + | pub action: String, |
|
| 104 | + | pub target: String, |
|
| 105 | + | pub detail: String, |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | // ---------------------------------------------------------------- accounts |
|
| 109 | + | ||
| 110 | + | pub async fn account_by_name(pool: &Pool, username: &str) -> sqlx::Result<Option<Account>> { |
|
| 111 | + | sqlx::query_as("SELECT * FROM accounts WHERE username = ?").bind(username).fetch_optional(pool).await |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | pub async fn account_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Account>> { |
|
| 115 | + | sqlx::query_as("SELECT * FROM accounts WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | pub async fn accounts(pool: &Pool) -> sqlx::Result<Vec<Account>> { |
|
| 119 | + | sqlx::query_as("SELECT * FROM accounts ORDER BY username").fetch_all(pool).await |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | pub async fn account_count(pool: &Pool) -> sqlx::Result<i64> { |
|
| 123 | + | sqlx::query_scalar("SELECT COUNT(*) FROM accounts").fetch_one(pool).await |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | pub async fn create_account( |
|
| 127 | + | pool: &Pool, |
|
| 128 | + | username: &str, |
|
| 129 | + | email: &str, |
|
| 130 | + | password_hash: &str, |
|
| 131 | + | session_salt: &str, |
|
| 132 | + | is_admin: bool, |
|
| 133 | + | ) -> sqlx::Result<Account> { |
|
| 134 | + | sqlx::query_as( |
|
| 135 | + | "INSERT INTO accounts (username, email, password_hash, session_salt, is_admin, created_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *", |
|
| 136 | + | ) |
|
| 137 | + | .bind(username) |
|
| 138 | + | .bind(email) |
|
| 139 | + | .bind(password_hash) |
|
| 140 | + | .bind(session_salt) |
|
| 141 | + | .bind(is_admin) |
|
| 142 | + | .bind(now_ms()) |
|
| 143 | + | .fetch_one(pool) |
|
| 144 | + | .await |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | pub async fn set_profile(pool: &Pool, id: i64, display_name: &str, email: &str) -> sqlx::Result<()> { |
|
| 148 | + | sqlx::query("UPDATE accounts SET display_name = ?, email = ? WHERE id = ?") |
|
| 149 | + | .bind(display_name) |
|
| 150 | + | .bind(email) |
|
| 151 | + | .bind(id) |
|
| 152 | + | .execute(pool) |
|
| 153 | + | .await?; |
|
| 154 | + | Ok(()) |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | pub async fn set_password(pool: &Pool, id: i64, password_hash: &str, session_salt: &str) -> sqlx::Result<()> { |
|
| 158 | + | sqlx::query("UPDATE accounts SET password_hash = ?, session_salt = ? WHERE id = ?") |
|
| 159 | + | .bind(password_hash) |
|
| 160 | + | .bind(session_salt) |
|
| 161 | + | .bind(id) |
|
| 162 | + | .execute(pool) |
|
| 163 | + | .await?; |
|
| 164 | + | Ok(()) |
|
| 165 | + | } |
|
| 166 | + | ||
| 167 | + | pub async fn set_session_salt(pool: &Pool, id: i64, session_salt: &str) -> sqlx::Result<()> { |
|
| 168 | + | sqlx::query("UPDATE accounts SET session_salt = ? WHERE id = ?").bind(session_salt).bind(id).execute(pool).await?; |
|
| 169 | + | Ok(()) |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | pub async fn set_totp(pool: &Pool, id: i64, secret: Option<&str>, recovery_codes: &str) -> sqlx::Result<()> { |
|
| 173 | + | sqlx::query("UPDATE accounts SET totp_secret = ?, recovery_codes = ? WHERE id = ?") |
|
| 174 | + | .bind(secret) |
|
| 175 | + | .bind(recovery_codes) |
|
| 176 | + | .bind(id) |
|
| 177 | + | .execute(pool) |
|
| 178 | + | .await?; |
|
| 179 | + | Ok(()) |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | pub async fn set_admin(pool: &Pool, id: i64, is_admin: bool) -> sqlx::Result<()> { |
|
| 183 | + | sqlx::query("UPDATE accounts SET is_admin = ? WHERE id = ?").bind(is_admin).bind(id).execute(pool).await?; |
|
| 184 | + | Ok(()) |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | pub async fn set_banned(pool: &Pool, id: i64, banned: bool) -> sqlx::Result<()> { |
|
| 188 | + | sqlx::query("UPDATE accounts SET banned = ? WHERE id = ?").bind(banned).bind(id).execute(pool).await?; |
|
| 189 | + | Ok(()) |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | pub async fn touch_seen(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 193 | + | sqlx::query("UPDATE accounts SET last_seen_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?; |
|
| 194 | + | Ok(()) |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | // ----------------------------------------------------------- organisations |
|
| 198 | + | ||
| 199 | + | pub async fn org_by_name(pool: &Pool, name: &str) -> sqlx::Result<Option<Organisation>> { |
|
| 200 | + | sqlx::query_as("SELECT * FROM organisations WHERE name = ?").bind(name).fetch_optional(pool).await |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | pub async fn orgs(pool: &Pool) -> sqlx::Result<Vec<Organisation>> { |
|
| 204 | + | sqlx::query_as("SELECT * FROM organisations ORDER BY name").fetch_all(pool).await |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | pub async fn orgs_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<(Organisation, String)>> { |
|
| 208 | + | let rows: Vec<(i64, String, String, String, String, i64, String)> = sqlx::query_as( |
|
| 209 | + | "SELECT o.id, o.name, o.display_name, o.description, o.default_role, o.created_ms, m.role FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = ? ORDER BY o.name", |
|
| 210 | + | ) |
|
| 211 | + | .bind(account_id) |
|
| 212 | + | .fetch_all(pool) |
|
| 213 | + | .await?; |
|
| 214 | + | Ok(rows |
|
| 215 | + | .into_iter() |
|
| 216 | + | .map(|(id, name, display_name, description, default_role, created_ms, role)| { |
|
| 217 | + | (Organisation { id, name, display_name, description, default_role, created_ms }, role) |
|
| 218 | + | }) |
|
| 219 | + | .collect()) |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | pub async fn create_org(pool: &Pool, name: &str, display_name: &str, owner: i64) -> sqlx::Result<Organisation> { |
|
| 223 | + | let mut tx = pool.begin().await?; |
|
| 224 | + | let org: Organisation = sqlx::query_as("INSERT INTO organisations (name, display_name, created_ms) VALUES (?, ?, ?) RETURNING *") |
|
| 225 | + | .bind(name) |
|
| 226 | + | .bind(display_name) |
|
| 227 | + | .bind(now_ms()) |
|
| 228 | + | .fetch_one(&mut *tx) |
|
| 229 | + | .await?; |
|
| 230 | + | sqlx::query("INSERT INTO org_members (org_id, account_id, role) VALUES (?, ?, 'owner')") |
|
| 231 | + | .bind(org.id) |
|
| 232 | + | .bind(owner) |
|
| 233 | + | .execute(&mut *tx) |
|
| 234 | + | .await?; |
|
| 235 | + | tx.commit().await?; |
|
| 236 | + | Ok(org) |
|
| 237 | + | } |
|
| 238 | + | ||
| 239 | + | pub async fn set_org(pool: &Pool, id: i64, display_name: &str, description: &str, default_role: &str) -> sqlx::Result<()> { |
|
| 240 | + | sqlx::query("UPDATE organisations SET display_name = ?, description = ?, default_role = ? WHERE id = ?") |
|
| 241 | + | .bind(display_name) |
|
| 242 | + | .bind(description) |
|
| 243 | + | .bind(default_role) |
|
| 244 | + | .bind(id) |
|
| 245 | + | .execute(pool) |
|
| 246 | + | .await?; |
|
| 247 | + | Ok(()) |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | /// `(account, role)` for every member. |
|
| 251 | + | pub async fn org_members(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<(Account, String)>> { |
|
| 252 | + | let rows: Vec<(i64, String)> = |
|
| 253 | + | sqlx::query_as("SELECT account_id, role FROM org_members WHERE org_id = ?").bind(org_id).fetch_all(pool).await?; |
|
| 254 | + | let mut out = Vec::new(); |
|
| 255 | + | for (id, role) in rows { |
|
| 256 | + | if let Some(a) = account_by_id(pool, id).await? { |
|
| 257 | + | out.push((a, role)); |
|
| 258 | + | } |
|
| 259 | + | } |
|
| 260 | + | out.sort_by(|a, b| a.0.username.cmp(&b.0.username)); |
|
| 261 | + | Ok(out) |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | pub async fn org_role(pool: &Pool, org_id: i64, account_id: i64) -> sqlx::Result<Option<String>> { |
|
| 265 | + | sqlx::query_scalar("SELECT role FROM org_members WHERE org_id = ? AND account_id = ?") |
|
| 266 | + | .bind(org_id) |
|
| 267 | + | .bind(account_id) |
|
| 268 | + | .fetch_optional(pool) |
|
| 269 | + | .await |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | pub async fn set_org_member(pool: &Pool, org_id: i64, account_id: i64, role: &str) -> sqlx::Result<()> { |
|
| 273 | + | sqlx::query("INSERT INTO org_members (org_id, account_id, role) VALUES (?, ?, ?) ON CONFLICT (org_id, account_id) DO UPDATE SET role = excluded.role") |
|
| 274 | + | .bind(org_id) |
|
| 275 | + | .bind(account_id) |
|
| 276 | + | .bind(role) |
|
| 277 | + | .execute(pool) |
|
| 278 | + | .await?; |
|
| 279 | + | Ok(()) |
|
| 280 | + | } |
|
| 281 | + | ||
| 282 | + | pub async fn remove_org_member(pool: &Pool, org_id: i64, account_id: i64) -> sqlx::Result<()> { |
|
| 283 | + | sqlx::query("DELETE FROM org_members WHERE org_id = ? AND account_id = ?").bind(org_id).bind(account_id).execute(pool).await?; |
|
| 284 | + | Ok(()) |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | pub async fn teams(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<Team>> { |
|
| 288 | + | sqlx::query_as("SELECT * FROM teams WHERE org_id = ? ORDER BY name").bind(org_id).fetch_all(pool).await |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | pub async fn team_by_name(pool: &Pool, org_id: i64, name: &str) -> sqlx::Result<Option<Team>> { |
|
| 292 | + | sqlx::query_as("SELECT * FROM teams WHERE org_id = ? AND name = ?").bind(org_id).bind(name).fetch_optional(pool).await |
|
| 293 | + | } |
|
| 294 | + | ||
| 295 | + | pub async fn create_team(pool: &Pool, org_id: i64, name: &str) -> sqlx::Result<Team> { |
|
| 296 | + | sqlx::query_as("INSERT INTO teams (org_id, name) VALUES (?, ?) RETURNING *").bind(org_id).bind(name).fetch_one(pool).await |
|
| 297 | + | } |
|
| 298 | + | ||
| 299 | + | pub async fn delete_team(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 300 | + | sqlx::query("DELETE FROM teams WHERE id = ?").bind(id).execute(pool).await?; |
|
| 301 | + | Ok(()) |
|
| 302 | + | } |
|
| 303 | + | ||
| 304 | + | pub async fn team_members(pool: &Pool, team_id: i64) -> sqlx::Result<Vec<Account>> { |
|
| 305 | + | 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") |
|
| 306 | + | .bind(team_id) |
|
| 307 | + | .fetch_all(pool) |
|
| 308 | + | .await |
|
| 309 | + | } |
|
| 310 | + | ||
| 311 | + | pub async fn add_team_member(pool: &Pool, team_id: i64, account_id: i64) -> sqlx::Result<()> { |
|
| 312 | + | sqlx::query("INSERT OR IGNORE INTO team_members (team_id, account_id) VALUES (?, ?)") |
|
| 313 | + | .bind(team_id) |
|
| 314 | + | .bind(account_id) |
|
| 315 | + | .execute(pool) |
|
| 316 | + | .await?; |
|
| 317 | + | Ok(()) |
|
| 318 | + | } |
|
| 319 | + | ||
| 320 | + | pub async fn remove_team_member(pool: &Pool, team_id: i64, account_id: i64) -> sqlx::Result<()> { |
|
| 321 | + | sqlx::query("DELETE FROM team_members WHERE team_id = ? AND account_id = ?").bind(team_id).bind(account_id).execute(pool).await?; |
|
| 322 | + | Ok(()) |
|
| 323 | + | } |
|
| 324 | + | ||
| 325 | + | // ----------------------------------------------------------- collaborators |
|
| 326 | + | ||
| 327 | + | #[derive(Clone, Debug)] |
|
| 328 | + | pub struct Collaborator { |
|
| 329 | + | pub account: Option<Account>, |
|
| 330 | + | pub team: Option<Team>, |
|
| 331 | + | pub role: String, |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | pub async fn collaborators(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Collaborator>> { |
|
| 335 | + | let rows: Vec<(Option<i64>, Option<i64>, String)> = |
|
| 336 | + | sqlx::query_as("SELECT account_id, team_id, role FROM collaborators WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await?; |
|
| 337 | + | let mut out = Vec::new(); |
|
| 338 | + | for (account_id, team_id, role) in rows { |
|
| 339 | + | let account = match account_id { |
|
| 340 | + | Some(id) => account_by_id(pool, id).await?, |
|
| 341 | + | None => None, |
|
| 342 | + | }; |
|
| 343 | + | let team = match team_id { |
|
| 344 | + | Some(id) => sqlx::query_as("SELECT * FROM teams WHERE id = ?").bind(id).fetch_optional(pool).await?, |
|
| 345 | + | None => None, |
|
| 346 | + | }; |
|
| 347 | + | out.push(Collaborator { account, team, role }); |
|
| 348 | + | } |
|
| 349 | + | Ok(out) |
|
| 350 | + | } |
|
| 351 | + | ||
| 352 | + | pub async fn set_collaborator(pool: &Pool, repo_id: i64, account_id: Option<i64>, team_id: Option<i64>, role: &str) -> sqlx::Result<()> { |
|
| 353 | + | if let Some(a) = account_id { |
|
| 354 | + | 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") |
|
| 355 | + | .bind(repo_id) |
|
| 356 | + | .bind(a) |
|
| 357 | + | .bind(role) |
|
| 358 | + | .execute(pool) |
|
| 359 | + | .await?; |
|
| 360 | + | } else if let Some(t) = team_id { |
|
| 361 | + | 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") |
|
| 362 | + | .bind(repo_id) |
|
| 363 | + | .bind(t) |
|
| 364 | + | .bind(role) |
|
| 365 | + | .execute(pool) |
|
| 366 | + | .await?; |
|
| 367 | + | } |
|
| 368 | + | Ok(()) |
|
| 369 | + | } |
|
| 370 | + | ||
| 371 | + | pub async fn remove_collaborator(pool: &Pool, repo_id: i64, account_id: Option<i64>, team_id: Option<i64>) -> sqlx::Result<()> { |
|
| 372 | + | 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 = ?))") |
|
| 373 | + | .bind(repo_id) |
|
| 374 | + | .bind(account_id) |
|
| 375 | + | .bind(team_id) |
|
| 376 | + | .execute(pool) |
|
| 377 | + | .await?; |
|
| 378 | + | Ok(()) |
|
| 379 | + | } |
|
| 380 | + | ||
| 381 | + | /// The direct or team role an account holds on a repository, the highest. |
|
| 382 | + | pub async fn collaborator_role(pool: &Pool, repo_id: i64, account_id: i64) -> sqlx::Result<Option<String>> { |
|
| 383 | + | let rows: Vec<String> = sqlx::query_scalar( |
|
| 384 | + | "SELECT role FROM collaborators WHERE repo_id = ? AND (account_id = ? OR team_id IN (SELECT team_id FROM team_members WHERE account_id = ?))", |
|
| 385 | + | ) |
|
| 386 | + | .bind(repo_id) |
|
| 387 | + | .bind(account_id) |
|
| 388 | + | .bind(account_id) |
|
| 389 | + | .fetch_all(pool) |
|
| 390 | + | .await?; |
|
| 391 | + | Ok(rows.into_iter().max_by_key(|r| crate::auth::Role::parse(r).map(|r| r as u8).unwrap_or(0))) |
|
| 392 | + | } |
|
| 393 | + | ||
| 394 | + | // ------------------------------------------------------------------ tokens |
|
| 395 | + | ||
| 396 | + | pub async fn tokens_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Token>> { |
|
| 397 | + | sqlx::query_as("SELECT * FROM tokens WHERE account_id = ? ORDER BY created_ms DESC").bind(account_id).fetch_all(pool).await |
|
| 398 | + | } |
|
| 399 | + | ||
| 400 | + | pub async fn token_by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Token>> { |
|
| 401 | + | sqlx::query_as("SELECT * FROM tokens WHERE hash = ?").bind(hash).fetch_optional(pool).await |
|
| 402 | + | } |
|
| 403 | + | ||
| 404 | + | pub async fn create_token( |
|
| 405 | + | pool: &Pool, |
|
| 406 | + | account_id: i64, |
|
| 407 | + | name: &str, |
|
| 408 | + | hash: &str, |
|
| 409 | + | scopes: &str, |
|
| 410 | + | repos: &str, |
|
| 411 | + | expires_ms: Option<i64>, |
|
| 412 | + | ) -> sqlx::Result<Token> { |
|
| 413 | + | sqlx::query_as( |
|
| 414 | + | "INSERT INTO tokens (account_id, name, hash, scopes, repos, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *", |
|
| 415 | + | ) |
|
| 416 | + | .bind(account_id) |
|
| 417 | + | .bind(name) |
|
| 418 | + | .bind(hash) |
|
| 419 | + | .bind(scopes) |
|
| 420 | + | .bind(repos) |
|
| 421 | + | .bind(now_ms()) |
|
| 422 | + | .bind(expires_ms) |
|
| 423 | + | .fetch_one(pool) |
|
| 424 | + | .await |
|
| 425 | + | } |
|
| 426 | + | ||
| 427 | + | pub async fn delete_token(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<()> { |
|
| 428 | + | sqlx::query("DELETE FROM tokens WHERE id = ? AND account_id = ?").bind(id).bind(account_id).execute(pool).await?; |
|
| 429 | + | Ok(()) |
|
| 430 | + | } |
|
| 431 | + | ||
| 432 | + | pub async fn touch_token(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 433 | + | sqlx::query("UPDATE tokens SET last_used_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?; |
|
| 434 | + | Ok(()) |
|
| 435 | + | } |
|
| 436 | + | ||
| 437 | + | // -------------------------------------------------------------- identities |
|
| 438 | + | ||
| 439 | + | pub async fn identities_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<LinkedIdentity>> { |
|
| 440 | + | sqlx::query_as("SELECT * FROM identities WHERE account_id = ? ORDER BY linked_ms DESC").bind(account_id).fetch_all(pool).await |
|
| 441 | + | } |
|
| 442 | + | ||
| 443 | + | pub async fn identity_by_key(pool: &Pool, key: &str) -> sqlx::Result<Option<LinkedIdentity>> { |
|
| 444 | + | sqlx::query_as("SELECT * FROM identities WHERE key = ?").bind(key).fetch_optional(pool).await |
|
| 445 | + | } |
|
| 446 | + | ||
| 447 | + | /// Every linked key with its account's username, for attribution. |
|
| 448 | + | pub async fn identity_accounts(pool: &Pool) -> sqlx::Result<Vec<(String, String)>> { |
|
| 449 | + | sqlx::query_as("SELECT i.key, a.username FROM identities i JOIN accounts a ON a.id = i.account_id").fetch_all(pool).await |
|
| 450 | + | } |
|
| 451 | + | ||
| 452 | + | pub async fn link_identity(pool: &Pool, account_id: i64, key: &str, name: &str, email: &str, object: &[u8]) -> sqlx::Result<()> { |
|
| 453 | + | sqlx::query("INSERT INTO identities (key, account_id, name, email, object, linked_ms) VALUES (?, ?, ?, ?, ?, ?)") |
|
| 454 | + | .bind(key) |
|
| 455 | + | .bind(account_id) |
|
| 456 | + | .bind(name) |
|
| 457 | + | .bind(email) |
|
| 458 | + | .bind(object) |
|
| 459 | + | .bind(now_ms()) |
|
| 460 | + | .execute(pool) |
|
| 461 | + | .await?; |
|
| 462 | + | Ok(()) |
|
| 463 | + | } |
|
| 464 | + | ||
| 465 | + | pub async fn unlink_identity(pool: &Pool, account_id: i64, key: &str) -> sqlx::Result<()> { |
|
| 466 | + | sqlx::query("DELETE FROM identities WHERE key = ? AND account_id = ?").bind(key).bind(account_id).execute(pool).await?; |
|
| 467 | + | Ok(()) |
|
| 468 | + | } |
|
| 469 | + | ||
| 470 | + | pub async fn create_challenge(pool: &Pool, account_id: i64, bytes_hex: &str) -> sqlx::Result<i64> { |
|
| 471 | + | sqlx::query("DELETE FROM challenges WHERE created_ms < ?").bind(now_ms() - 10 * 60 * 1000).execute(pool).await?; |
|
| 472 | + | let id: i64 = sqlx::query_scalar("INSERT INTO challenges (account_id, bytes, created_ms) VALUES (?, ?, ?) RETURNING id") |
|
| 473 | + | .bind(account_id) |
|
| 474 | + | .bind(bytes_hex) |
|
| 475 | + | .bind(now_ms()) |
|
| 476 | + | .fetch_one(pool) |
|
| 477 | + | .await?; |
|
| 478 | + | Ok(id) |
|
| 479 | + | } |
|
| 480 | + | ||
| 481 | + | /// Take (and delete) a pending challenge of this account. |
|
| 482 | + | pub async fn take_challenge(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<Option<String>> { |
|
| 483 | + | let bytes: Option<String> = sqlx::query_scalar("SELECT bytes FROM challenges WHERE id = ? AND account_id = ? AND created_ms >= ?") |
|
| 484 | + | .bind(id) |
|
| 485 | + | .bind(account_id) |
|
| 486 | + | .bind(now_ms() - 10 * 60 * 1000) |
|
| 487 | + | .fetch_optional(pool) |
|
| 488 | + | .await?; |
|
| 489 | + | sqlx::query("DELETE FROM challenges WHERE id = ?").bind(id).execute(pool).await?; |
|
| 490 | + | Ok(bytes) |
|
| 491 | + | } |
|
| 492 | + | ||
| 493 | + | // ------------------------------------------------------------- invitations |
|
| 494 | + | ||
| 495 | + | pub async fn create_invitation(pool: &Pool, code: &str, created_by: Option<i64>) -> sqlx::Result<()> { |
|
| 496 | + | sqlx::query("INSERT INTO invitations (code, created_by, created_ms) VALUES (?, ?, ?)") |
|
| 497 | + | .bind(code) |
|
| 498 | + | .bind(created_by) |
|
| 499 | + | .bind(now_ms()) |
|
| 500 | + | .execute(pool) |
|
| 501 | + | .await?; |
|
| 502 | + | Ok(()) |
|
| 503 | + | } |
|
| 504 | + | ||
| 505 | + | pub async fn invitation_unused(pool: &Pool, code: &str) -> sqlx::Result<bool> { |
|
| 506 | + | let n: i64 = |
|
| 507 | + | sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE code = ? AND used_by IS NULL").bind(code).fetch_one(pool).await?; |
|
| 508 | + | Ok(n > 0) |
|
| 509 | + | } |
|
| 510 | + | ||
| 511 | + | pub async fn use_invitation(pool: &Pool, code: &str, account_id: i64) -> sqlx::Result<()> { |
|
| 512 | + | sqlx::query("UPDATE invitations SET used_by = ? WHERE code = ?").bind(account_id).bind(code).execute(pool).await?; |
|
| 513 | + | Ok(()) |
|
| 514 | + | } |
|
| 515 | + | ||
| 516 | + | pub async fn invitations(pool: &Pool) -> sqlx::Result<Vec<(String, Option<i64>, i64)>> { |
|
| 517 | + | sqlx::query_as("SELECT code, used_by, created_ms FROM invitations ORDER BY created_ms DESC").fetch_all(pool).await |
|
| 518 | + | } |
|
| 519 | + | ||
| 520 | + | // ---------------------------------------------------------------- settings |
|
| 521 | + | ||
| 522 | + | pub async fn setting(pool: &Pool, key: &str) -> sqlx::Result<Option<String>> { |
|
| 523 | + | sqlx::query_scalar("SELECT value FROM settings WHERE key = ?").bind(key).fetch_optional(pool).await |
|
| 524 | + | } |
|
| 525 | + | ||
| 526 | + | pub async fn set_setting(pool: &Pool, key: &str, value: &str) -> sqlx::Result<()> { |
|
| 527 | + | sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value") |
|
| 528 | + | .bind(key) |
|
| 529 | + | .bind(value) |
|
| 530 | + | .execute(pool) |
|
| 531 | + | .await?; |
|
| 532 | + | Ok(()) |
|
| 533 | + | } |
|
| 534 | + | ||
| 535 | + | // ------------------------------------------------------------------- audit |
|
| 536 | + | ||
| 537 | + | pub async fn audit(pool: &Pool, actor: Option<&Account>, action: &str, target: &str, detail: &str) -> sqlx::Result<()> { |
|
| 538 | + | sqlx::query("INSERT INTO audit (at_ms, actor_id, actor, action, target, detail) VALUES (?, ?, ?, ?, ?, ?)") |
|
| 539 | + | .bind(now_ms()) |
|
| 540 | + | .bind(actor.map(|a| a.id)) |
|
| 541 | + | .bind(actor.map(|a| a.username.as_str()).unwrap_or("")) |
|
| 542 | + | .bind(action) |
|
| 543 | + | .bind(target) |
|
| 544 | + | .bind(detail) |
|
| 545 | + | .execute(pool) |
|
| 546 | + | .await?; |
|
| 547 | + | Ok(()) |
|
| 548 | + | } |
|
| 549 | + | ||
| 550 | + | pub async fn audit_rows(pool: &Pool, limit: i64) -> sqlx::Result<Vec<AuditRow>> { |
|
| 551 | + | sqlx::query_as("SELECT id, at_ms, actor, action, target, detail FROM audit ORDER BY at_ms DESC, id DESC LIMIT ?") |
|
| 552 | + | .bind(limit) |
|
| 553 | + | .fetch_all(pool) |
|
| 554 | + | .await |
|
| 555 | + | } |
|
| 556 | + | ||
| 557 | + | /// The repositories an account can see beyond the public ones: its own, |
|
| 558 | + | /// its organisations', and those it collaborates on. |
|
| 559 | + | pub async fn visible_repo_ids(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<i64>> { |
|
| 560 | + | sqlx::query_scalar( |
|
| 561 | + | "SELECT r.id FROM repositories r WHERE r.owner IN (SELECT username FROM accounts WHERE id = ?) \ |
|
| 562 | + | OR r.owner IN (SELECT o.name FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = ?) \ |
|
| 563 | + | 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 = ?))", |
|
| 564 | + | ) |
|
| 565 | + | .bind(account_id) |
|
| 566 | + | .bind(account_id) |
|
| 567 | + | .bind(account_id) |
|
| 568 | + | .bind(account_id) |
|
| 569 | + | .fetch_all(pool) |
|
| 570 | + | .await |
|
| 571 | + | } |
added crates/hub/src/web/admin.rs +107 −0
| 1 | + | //! Site administration (hub spec §6.3 `/admin/*`): accounts, registration |
|
| 2 | + | //! mode and invitations, the audit log. |
|
| 3 | + | ||
| 4 | + | use super::settings::csrf; |
|
| 5 | + | use super::{filters, Error, Page, Shell}; |
|
| 6 | + | use crate::auth::{self, Viewer}; |
|
| 7 | + | use crate::db::accounts; |
|
| 8 | + | use crate::Shared; |
|
| 9 | + | use askama::Template; |
|
| 10 | + | use axum::extract::{Form, State}; |
|
| 11 | + | use serde::Deserialize; |
|
| 12 | + | ||
| 13 | + | #[derive(Template)] |
|
| 14 | + | #[template(path = "admin.html")] |
|
| 15 | + | pub struct AdminPage { |
|
| 16 | + | shell: Shell, |
|
| 17 | + | accounts: Vec<accounts::Account>, |
|
| 18 | + | orgs: Vec<accounts::Organisation>, |
|
| 19 | + | mode: String, |
|
| 20 | + | invitations: Vec<(String, Option<i64>, i64)>, |
|
| 21 | + | audit: Vec<accounts::AuditRow>, |
|
| 22 | + | repos: usize, |
|
| 23 | + | notice: Option<String>, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| 27 | + | match viewer.account() { |
|
| 28 | + | Some(a) if a.is_admin => Ok(a.clone()), |
|
| 29 | + | _ => Err(Error::not_found(shell.clone())), |
|
| 30 | + | } |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> { |
|
| 34 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 35 | + | let accounts_list = accounts::accounts(&app.db).await.map_err(e)?; |
|
| 36 | + | let orgs = accounts::orgs(&app.db).await.map_err(e)?; |
|
| 37 | + | let mode = super::session::registration_mode(app).await; |
|
| 38 | + | let invitations = accounts::invitations(&app.db).await.map_err(e)?; |
|
| 39 | + | let audit = accounts::audit_rows(&app.db, 100).await.map_err(e)?; |
|
| 40 | + | let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len(); |
|
| 41 | + | Ok(Page(AdminPage { shell, accounts: accounts_list, orgs, mode, invitations, audit, repos, notice })) |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> { |
|
| 45 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 46 | + | require_admin(&viewer, &shell)?; |
|
| 47 | + | admin_page(&app, shell, None).await |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | #[derive(Deserialize)] |
|
| 51 | + | pub struct ModeForm { |
|
| 52 | + | #[serde(rename = "_csrf")] |
|
| 53 | + | csrf_token: String, |
|
| 54 | + | mode: String, |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> { |
|
| 58 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 59 | + | let me = require_admin(&viewer, &shell)?; |
|
| 60 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 61 | + | if !matches!(f.mode.as_str(), "open" | "invite" | "closed") { |
|
| 62 | + | return Err(Error::bad(shell, "mode is open, invite or closed")); |
|
| 63 | + | } |
|
| 64 | + | accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 65 | + | accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok(); |
|
| 66 | + | admin_page(&app, shell, Some(format!("Registration is {}.", f.mode))).await |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> { |
|
| 70 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 71 | + | let me = require_admin(&viewer, &shell)?; |
|
| 72 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 73 | + | let code = auth::random_hex(12); |
|
| 74 | + | accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 75 | + | accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok(); |
|
| 76 | + | admin_page(&app, shell, Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url()))).await |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | #[derive(Deserialize)] |
|
| 80 | + | pub struct AccountForm { |
|
| 81 | + | #[serde(rename = "_csrf")] |
|
| 82 | + | csrf_token: String, |
|
| 83 | + | username: String, |
|
| 84 | + | action: String, |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> { |
|
| 88 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 89 | + | let me = require_admin(&viewer, &shell)?; |
|
| 90 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 91 | + | let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
|
| 92 | + | return admin_page(&app, shell, Some("No such account.".into())).await; |
|
| 93 | + | }; |
|
| 94 | + | if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote") { |
|
| 95 | + | return admin_page(&app, shell, Some("Not on yourself.".into())).await; |
|
| 96 | + | } |
|
| 97 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 98 | + | match f.action.as_str() { |
|
| 99 | + | "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?, |
|
| 100 | + | "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?, |
|
| 101 | + | "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?, |
|
| 102 | + | "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?, |
|
| 103 | + | _ => return Err(Error::bad(shell, "unknown action")), |
|
| 104 | + | } |
|
| 105 | + | accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok(); |
|
| 106 | + | admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await |
|
| 107 | + | } |
added crates/hub/src/web/orgs.rs +232 −0
| 1 | + | //! Organisations and teams (hub spec §5.2). |
|
| 2 | + | ||
| 3 | + | use super::settings::{csrf, require}; |
|
| 4 | + | use super::{Error, Page, Shell}; |
|
| 5 | + | use crate::auth::{roles, Viewer}; |
|
| 6 | + | use crate::db::accounts; |
|
| 7 | + | use crate::Shared; |
|
| 8 | + | use askama::Template; |
|
| 9 | + | use axum::extract::{Form, Path, State}; |
|
| 10 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 11 | + | use serde::Deserialize; |
|
| 12 | + | ||
| 13 | + | #[derive(Template)] |
|
| 14 | + | #[template(path = "org_new.html")] |
|
| 15 | + | pub struct OrgNewPage { |
|
| 16 | + | shell: Shell, |
|
| 17 | + | error: Option<String>, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<OrgNewPage>, Error> { |
|
| 21 | + | let shell = Shell::new(&app, "/orgs/new", &viewer); |
|
| 22 | + | require(&viewer, &shell)?; |
|
| 23 | + | Ok(Page(OrgNewPage { shell, error: None })) |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | #[derive(Deserialize)] |
|
| 27 | + | pub struct OrgNewForm { |
|
| 28 | + | #[serde(rename = "_csrf")] |
|
| 29 | + | csrf_token: String, |
|
| 30 | + | name: String, |
|
| 31 | + | display_name: String, |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<OrgNewForm>) -> Result<Response, Error> { |
|
| 35 | + | let shell = Shell::new(&app, "/orgs/new", &viewer); |
|
| 36 | + | let me = require(&viewer, &shell)?; |
|
| 37 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 38 | + | let name = f.name.trim().to_string(); |
|
| 39 | + | if !crate::repos::names::valid_owner(&name) { |
|
| 40 | + | return Ok(Page(OrgNewPage { |
|
| 41 | + | shell, |
|
| 42 | + | error: Some("An organisation name is letters, digits, dots, dashes and underscores.".into()), |
|
| 43 | + | }) |
|
| 44 | + | .into_response()); |
|
| 45 | + | } |
|
| 46 | + | let taken = accounts::account_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 47 | + | || accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some(); |
|
| 48 | + | if taken { |
|
| 49 | + | return Ok(Page(OrgNewPage { shell, error: Some("That name is taken.".into()) }).into_response()); |
|
| 50 | + | } |
|
| 51 | + | let org = |
|
| 52 | + | accounts::create_org(&app.db, &name, f.display_name.trim(), me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 53 | + | accounts::audit(&app.db, Some(&me.account), "org.created", &org.name, "").await.ok(); |
|
| 54 | + | Ok(Redirect::to(&format!("/orgs/{}/settings", org.name)).into_response()) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | #[derive(Template)] |
|
| 58 | + | #[template(path = "org_settings.html")] |
|
| 59 | + | pub struct OrgSettingsPage { |
|
| 60 | + | shell: Shell, |
|
| 61 | + | org: accounts::Organisation, |
|
| 62 | + | members: Vec<(accounts::Account, String)>, |
|
| 63 | + | teams: Vec<(accounts::Team, Vec<accounts::Account>)>, |
|
| 64 | + | notice: Option<String>, |
|
| 65 | + | error: Option<String>, |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | async fn org_page( |
|
| 69 | + | app: &Shared, |
|
| 70 | + | shell: Shell, |
|
| 71 | + | org: accounts::Organisation, |
|
| 72 | + | notice: Option<String>, |
|
| 73 | + | error: Option<String>, |
|
| 74 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 75 | + | let members = accounts::org_members(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 76 | + | let mut teams = Vec::new(); |
|
| 77 | + | for t in accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 78 | + | let members = accounts::team_members(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 79 | + | teams.push((t, members)); |
|
| 80 | + | } |
|
| 81 | + | Ok(Page(OrgSettingsPage { shell, org, members, teams, notice, error })) |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> { |
|
| 85 | + | let me = require(viewer, shell)?; |
|
| 86 | + | let org = accounts::org_by_name(&app.db, name) |
|
| 87 | + | .await |
|
| 88 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 89 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 90 | + | if !roles::is_org_owner(&app.db, &me.account, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 91 | + | return Err(Error::not_found(shell.clone())); |
|
| 92 | + | } |
|
| 93 | + | Ok((me.account, org)) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | pub async fn settings(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 97 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 98 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 99 | + | org_page(&app, shell, org, None, None).await |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | #[derive(Deserialize)] |
|
| 103 | + | pub struct OrgForm { |
|
| 104 | + | #[serde(rename = "_csrf")] |
|
| 105 | + | csrf_token: String, |
|
| 106 | + | display_name: String, |
|
| 107 | + | description: String, |
|
| 108 | + | default_role: String, |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | pub async fn update( |
|
| 112 | + | State(app): State<Shared>, |
|
| 113 | + | viewer: Viewer, |
|
| 114 | + | Path(name): Path<String>, |
|
| 115 | + | Form(f): Form<OrgForm>, |
|
| 116 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 117 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 118 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 119 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 120 | + | let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" }; |
|
| 121 | + | accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role) |
|
| 122 | + | .await |
|
| 123 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 124 | + | accounts::audit(&app.db, Some(&me), "org.updated", &org.name, "").await.ok(); |
|
| 125 | + | let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org); |
|
| 126 | + | org_page(&app, shell, org, Some("Saved.".into()), None).await |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | #[derive(Deserialize)] |
|
| 130 | + | pub struct MemberForm { |
|
| 131 | + | #[serde(rename = "_csrf")] |
|
| 132 | + | csrf_token: String, |
|
| 133 | + | username: String, |
|
| 134 | + | #[serde(default)] |
|
| 135 | + | role: String, |
|
| 136 | + | #[serde(default)] |
|
| 137 | + | remove: Option<String>, |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | pub async fn member( |
|
| 141 | + | State(app): State<Shared>, |
|
| 142 | + | viewer: Viewer, |
|
| 143 | + | Path(name): Path<String>, |
|
| 144 | + | Form(f): Form<MemberForm>, |
|
| 145 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 146 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 147 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 148 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 149 | + | let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
|
| 150 | + | return org_page(&app, shell, org, None, Some("No such account.".into())).await; |
|
| 151 | + | }; |
|
| 152 | + | if f.remove.is_some() { |
|
| 153 | + | let owners = accounts::org_members(&app.db, org.id) |
|
| 154 | + | .await |
|
| 155 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 156 | + | .iter() |
|
| 157 | + | .filter(|(_, r)| r == "owner") |
|
| 158 | + | .count(); |
|
| 159 | + | let is_owner = |
|
| 160 | + | accounts::org_role(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.as_deref() == Some("owner"); |
|
| 161 | + | if is_owner && owners <= 1 { |
|
| 162 | + | return org_page(&app, shell, org, None, Some("An organisation keeps at least one owner.".into())).await; |
|
| 163 | + | } |
|
| 164 | + | accounts::remove_org_member(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 165 | + | accounts::audit(&app.db, Some(&me), "org.member.removed", &org.name, &target.username).await.ok(); |
|
| 166 | + | return org_page(&app, shell, org, Some(format!("Removed {}.", target.username)), None).await; |
|
| 167 | + | } |
|
| 168 | + | let role = if f.role == "owner" { "owner" } else { "member" }; |
|
| 169 | + | accounts::set_org_member(&app.db, org.id, target.id, role).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 170 | + | accounts::audit(&app.db, Some(&me), "org.member.set", &org.name, &format!("{} {role}", target.username)).await.ok(); |
|
| 171 | + | org_page(&app, shell, org, Some(format!("{} is now {role}.", target.username)), None).await |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | #[derive(Deserialize)] |
|
| 175 | + | pub struct TeamForm { |
|
| 176 | + | #[serde(rename = "_csrf")] |
|
| 177 | + | csrf_token: String, |
|
| 178 | + | team: String, |
|
| 179 | + | #[serde(default)] |
|
| 180 | + | username: String, |
|
| 181 | + | #[serde(default)] |
|
| 182 | + | action: String, |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | pub async fn team( |
|
| 186 | + | State(app): State<Shared>, |
|
| 187 | + | viewer: Viewer, |
|
| 188 | + | Path(name): Path<String>, |
|
| 189 | + | Form(f): Form<TeamForm>, |
|
| 190 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 191 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 192 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 193 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 194 | + | let team_name = f.team.trim().to_string(); |
|
| 195 | + | if !crate::repos::names::valid_segment(&team_name) { |
|
| 196 | + | return org_page(&app, shell, org, None, Some("A team name is letters, digits, dots, dashes and underscores.".into())).await; |
|
| 197 | + | } |
|
| 198 | + | let existing = accounts::team_by_name(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 199 | + | let notice = match f.action.as_str() { |
|
| 200 | + | "create" => { |
|
| 201 | + | if existing.is_some() { |
|
| 202 | + | return org_page(&app, shell, org, None, Some("That team exists.".into())).await; |
|
| 203 | + | } |
|
| 204 | + | accounts::create_team(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 205 | + | format!("Team {team_name} created.") |
|
| 206 | + | } |
|
| 207 | + | "delete" => { |
|
| 208 | + | if let Some(t) = existing { |
|
| 209 | + | accounts::delete_team(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 210 | + | } |
|
| 211 | + | format!("Team {team_name} deleted.") |
|
| 212 | + | } |
|
| 213 | + | "add" | "remove" => { |
|
| 214 | + | let Some(t) = existing else { return org_page(&app, shell, org, None, Some("No such team.".into())).await }; |
|
| 215 | + | let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 216 | + | else { |
|
| 217 | + | return org_page(&app, shell, org, None, Some("No such account.".into())).await; |
|
| 218 | + | }; |
|
| 219 | + | if f.action == "add" { |
|
| 220 | + | accounts::set_org_member(&app.db, org.id, target.id, "member").await.ok(); |
|
| 221 | + | accounts::add_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 222 | + | format!("{} added to {team_name}.", target.username) |
|
| 223 | + | } else { |
|
| 224 | + | accounts::remove_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 225 | + | format!("{} removed from {team_name}.", target.username) |
|
| 226 | + | } |
|
| 227 | + | } |
|
| 228 | + | _ => return Err(Error::bad(shell, "unknown team action")), |
|
| 229 | + | }; |
|
| 230 | + | accounts::audit(&app.db, Some(&me), "org.team", &org.name, &format!("{} {team_name} {}", f.action, f.username.trim())).await.ok(); |
|
| 231 | + | org_page(&app, shell, org, Some(notice), None).await |
|
| 232 | + | } |
added crates/hub/src/web/repo_settings.rs +242 −0
| 1 | + | //! Creating repositories and their settings pages (hub spec §6.3). |
|
| 2 | + | ||
| 3 | + | use super::settings::{csrf, require}; |
|
| 4 | + | use super::{lookup_role, Error, Page, RepoShell, Shell}; |
|
| 5 | + | use crate::auth::{roles, Access, Role, Viewer}; |
|
| 6 | + | use crate::db::{self, accounts}; |
|
| 7 | + | use crate::repos::RepoName; |
|
| 8 | + | use crate::Shared; |
|
| 9 | + | use askama::Template; |
|
| 10 | + | use axum::extract::{Form, Path, State}; |
|
| 11 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 12 | + | use hub_api::Visibility; |
|
| 13 | + | use serde::Deserialize; |
|
| 14 | + | ||
| 15 | + | #[derive(Template)] |
|
| 16 | + | #[template(path = "repo_new.html")] |
|
| 17 | + | pub struct NewRepoPage { |
|
| 18 | + | shell: Shell, |
|
| 19 | + | owners: Vec<String>, |
|
| 20 | + | error: Option<String>, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> { |
|
| 24 | + | let mut owners = vec![me.username.clone()]; |
|
| 25 | + | if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await { |
|
| 26 | + | owners.extend(orgs.into_iter().map(|(o, _)| o.name)); |
|
| 27 | + | } |
|
| 28 | + | owners |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> { |
|
| 32 | + | let shell = Shell::new(&app, "/new", &viewer); |
|
| 33 | + | let me = require(&viewer, &shell)?; |
|
| 34 | + | let owners = owners_for(&app, &me.account).await; |
|
| 35 | + | Ok(Page(NewRepoPage { shell, owners, error: None })) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | #[derive(Deserialize)] |
|
| 39 | + | pub struct NewRepoForm { |
|
| 40 | + | #[serde(rename = "_csrf")] |
|
| 41 | + | csrf_token: String, |
|
| 42 | + | owner: String, |
|
| 43 | + | name: String, |
|
| 44 | + | #[serde(default)] |
|
| 45 | + | description: String, |
|
| 46 | + | #[serde(default = "default_visibility")] |
|
| 47 | + | visibility: String, |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | fn default_visibility() -> String { |
|
| 51 | + | "public".into() |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> { |
|
| 55 | + | let shell = Shell::new(&app, "/new", &viewer); |
|
| 56 | + | let me = require(&viewer, &shell)?; |
|
| 57 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 58 | + | let owners = owners_for(&app, &me.account).await; |
|
| 59 | + | let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response(); |
|
| 60 | + | let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else { |
|
| 61 | + | return Ok(fail("A repository name is letters, digits, dots, dashes and underscores.")); |
|
| 62 | + | }; |
|
| 63 | + | if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 64 | + | return Ok(fail("You cannot create repositories under that owner.")); |
|
| 65 | + | } |
|
| 66 | + | let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) }; |
|
| 67 | + | if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 68 | + | || app.repos.exists(&name) |
|
| 69 | + | { |
|
| 70 | + | return Ok(fail("That repository exists.")); |
|
| 71 | + | } |
|
| 72 | + | app.repos.create(&name, None).map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 73 | + | db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility) |
|
| 74 | + | .await |
|
| 75 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 76 | + | accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok(); |
|
| 77 | + | Ok(Redirect::to(&name.url()).into_response()) |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | #[derive(Template)] |
|
| 81 | + | #[template(path = "repo_settings.html")] |
|
| 82 | + | pub struct RepoSettingsPage { |
|
| 83 | + | shell: Shell, |
|
| 84 | + | repo: RepoShell, |
|
| 85 | + | row: db::repos::RepoRow, |
|
| 86 | + | collaborators: Vec<accounts::Collaborator>, |
|
| 87 | + | teams: Vec<accounts::Team>, |
|
| 88 | + | role: Role, |
|
| 89 | + | notice: Option<String>, |
|
| 90 | + | error: Option<String>, |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | async fn settings_page( |
|
| 94 | + | app: &Shared, |
|
| 95 | + | shell: Shell, |
|
| 96 | + | row: db::repos::RepoRow, |
|
| 97 | + | role: Role, |
|
| 98 | + | notice: Option<String>, |
|
| 99 | + | error: Option<String>, |
|
| 100 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 101 | + | let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 102 | + | let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 103 | + | Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?, |
|
| 104 | + | None => vec![], |
|
| 105 | + | }; |
|
| 106 | + | let repo = RepoShell::new(app, &row, "settings"); |
|
| 107 | + | Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, notice, error })) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | pub async fn settings( |
|
| 111 | + | State(app): State<Shared>, |
|
| 112 | + | viewer: Viewer, |
|
| 113 | + | Path((owner, name)): Path<(String, String)>, |
|
| 114 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 115 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 116 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 117 | + | settings_page(&app, shell, row, role, None, None).await |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | #[derive(Deserialize)] |
|
| 121 | + | pub struct GeneralForm { |
|
| 122 | + | #[serde(rename = "_csrf")] |
|
| 123 | + | csrf_token: String, |
|
| 124 | + | description: String, |
|
| 125 | + | default_bookmark: String, |
|
| 126 | + | visibility: String, |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | pub async fn update( |
|
| 130 | + | State(app): State<Shared>, |
|
| 131 | + | viewer: Viewer, |
|
| 132 | + | Path((owner, name)): Path<(String, String)>, |
|
| 133 | + | Form(f): Form<GeneralForm>, |
|
| 134 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 135 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 136 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 137 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 138 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 139 | + | db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?; |
|
| 140 | + | if crate::repos::names::valid_segment(f.default_bookmark.trim()) { |
|
| 141 | + | db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?; |
|
| 142 | + | } |
|
| 143 | + | if role >= Role::Admin { |
|
| 144 | + | if let Some(v) = Visibility::parse(&f.visibility) { |
|
| 145 | + | db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?; |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok(); |
|
| 149 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row); |
|
| 150 | + | settings_page(&app, shell, row, role, Some("Saved.".into()), None).await |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | #[derive(Deserialize)] |
|
| 154 | + | pub struct CollaboratorForm { |
|
| 155 | + | #[serde(rename = "_csrf")] |
|
| 156 | + | csrf_token: String, |
|
| 157 | + | #[serde(default)] |
|
| 158 | + | username: String, |
|
| 159 | + | #[serde(default)] |
|
| 160 | + | team: String, |
|
| 161 | + | #[serde(default)] |
|
| 162 | + | role: String, |
|
| 163 | + | #[serde(default)] |
|
| 164 | + | remove: Option<String>, |
|
| 165 | + | } |
|
| 166 | + | ||
| 167 | + | pub async fn collaborator( |
|
| 168 | + | State(app): State<Shared>, |
|
| 169 | + | viewer: Viewer, |
|
| 170 | + | Path((owner, name)): Path<(String, String)>, |
|
| 171 | + | Form(f): Form<CollaboratorForm>, |
|
| 172 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 173 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 174 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 175 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 176 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 177 | + | let (account_id, team_id, label) = if !f.username.trim().is_empty() { |
|
| 178 | + | match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? { |
|
| 179 | + | Some(a) => (Some(a.id), None, a.username), |
|
| 180 | + | None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await, |
|
| 181 | + | } |
|
| 182 | + | } else if !f.team.trim().is_empty() { |
|
| 183 | + | let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?; |
|
| 184 | + | let team = match org { |
|
| 185 | + | Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?, |
|
| 186 | + | None => None, |
|
| 187 | + | }; |
|
| 188 | + | match team { |
|
| 189 | + | Some(t) => (None, Some(t.id), format!("team {}", t.name)), |
|
| 190 | + | None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await, |
|
| 191 | + | } |
|
| 192 | + | } else { |
|
| 193 | + | return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await; |
|
| 194 | + | }; |
|
| 195 | + | if f.remove.is_some() { |
|
| 196 | + | accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?; |
|
| 197 | + | accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok(); |
|
| 198 | + | return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await; |
|
| 199 | + | } |
|
| 200 | + | let Some(new_role) = Role::parse(&f.role) else { |
|
| 201 | + | return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await; |
|
| 202 | + | }; |
|
| 203 | + | accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?; |
|
| 204 | + | accounts::audit( |
|
| 205 | + | &app.db, |
|
| 206 | + | viewer.account(), |
|
| 207 | + | "repo.collaborator.set", |
|
| 208 | + | &format!("{owner}/{name}"), |
|
| 209 | + | &format!("{label} {}", new_role.as_str()), |
|
| 210 | + | ) |
|
| 211 | + | .await |
|
| 212 | + | .ok(); |
|
| 213 | + | settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await |
|
| 214 | + | } |
|
| 215 | + | ||
| 216 | + | #[derive(Deserialize)] |
|
| 217 | + | pub struct DeleteForm { |
|
| 218 | + | #[serde(rename = "_csrf")] |
|
| 219 | + | csrf_token: String, |
|
| 220 | + | confirm: String, |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | pub async fn delete( |
|
| 224 | + | State(app): State<Shared>, |
|
| 225 | + | viewer: Viewer, |
|
| 226 | + | Path((owner, name)): Path<(String, String)>, |
|
| 227 | + | Form(f): Form<DeleteForm>, |
|
| 228 | + | ) -> Result<Response, Error> { |
|
| 229 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 230 | + | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 231 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 232 | + | if f.confirm.trim() != repo.to_string() { |
|
| 233 | + | return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into())) |
|
| 234 | + | .await? |
|
| 235 | + | .into_response()); |
|
| 236 | + | } |
|
| 237 | + | let _guard = app.repos.write_guard(&repo).await; |
|
| 238 | + | app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 239 | + | db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 240 | + | accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok(); |
|
| 241 | + | Ok(Redirect::to(&format!("/{owner}")).into_response()) |
|
| 242 | + | } |
added crates/hub/src/web/session.rs +194 −0
| 1 | + | //! Sign in, sign out and registration (hub spec §5.1). |
|
| 2 | + | ||
| 3 | + | use super::{Error, Page, Shell}; |
|
| 4 | + | use crate::auth::{self, totp, Viewer}; |
|
| 5 | + | use crate::db::{self, accounts}; |
|
| 6 | + | use crate::Shared; |
|
| 7 | + | use askama::Template; |
|
| 8 | + | use axum::extract::{Form, Query, State}; |
|
| 9 | + | use axum::http::header; |
|
| 10 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 11 | + | use serde::Deserialize; |
|
| 12 | + | ||
| 13 | + | #[derive(Template)] |
|
| 14 | + | #[template(path = "login.html")] |
|
| 15 | + | pub struct LoginPage { |
|
| 16 | + | shell: Shell, |
|
| 17 | + | error: Option<String>, |
|
| 18 | + | next: String, |
|
| 19 | + | /// Second step: the password was right, a one-time code is needed. |
|
| 20 | + | totp_for: Option<String>, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | #[derive(Deserialize)] |
|
| 24 | + | pub struct NextQuery { |
|
| 25 | + | next: Option<String>, |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | fn safe_next(next: Option<String>) -> String { |
|
| 29 | + | match next { |
|
| 30 | + | Some(n) if n.starts_with('/') && !n.starts_with("//") => n, |
|
| 31 | + | _ => "/".into(), |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | pub async fn login_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Response { |
|
| 36 | + | if viewer.signed_in() { |
|
| 37 | + | return Redirect::to(&safe_next(q.next)).into_response(); |
|
| 38 | + | } |
|
| 39 | + | Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: None, next: safe_next(q.next), totp_for: None }).into_response() |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | #[derive(Deserialize)] |
|
| 43 | + | pub struct LoginForm { |
|
| 44 | + | username: String, |
|
| 45 | + | password: String, |
|
| 46 | + | #[serde(default)] |
|
| 47 | + | code: String, |
|
| 48 | + | #[serde(default)] |
|
| 49 | + | next: String, |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | pub async fn login(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LoginForm>) -> Result<Response, Error> { |
|
| 53 | + | let shell = Shell::new(&app, "/login", &viewer); |
|
| 54 | + | let next = safe_next(Some(f.next.clone())); |
|
| 55 | + | let fail = |msg: &str, totp_for: Option<String>| { |
|
| 56 | + | Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for }).into_response() |
|
| 57 | + | }; |
|
| 58 | + | let username = f.username.trim().to_string(); |
|
| 59 | + | let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 60 | + | let Some(account) = account.filter(|a| auth::verify_password(&a.password_hash, &f.password)) else { |
|
| 61 | + | accounts::audit(&app.db, None, "login.failed", &username, "").await.ok(); |
|
| 62 | + | return Ok(fail("Wrong username or password.", None)); |
|
| 63 | + | }; |
|
| 64 | + | if account.banned { |
|
| 65 | + | return Ok(fail("This account is banned.", None)); |
|
| 66 | + | } |
|
| 67 | + | if let Some(secret) = &account.totp_secret { |
|
| 68 | + | if f.code.trim().is_empty() { |
|
| 69 | + | return Ok(fail("Enter the code from your authenticator app.", Some(username))); |
|
| 70 | + | } |
|
| 71 | + | let now = (db::now_ms() / 1000) as u64; |
|
| 72 | + | if !totp::verify(secret, &f.code, now) { |
|
| 73 | + | match totp::use_recovery_code(&account.recovery_codes, &f.code) { |
|
| 74 | + | Some(rest) => { |
|
| 75 | + | accounts::set_totp(&app.db, account.id, Some(secret), &rest).await.map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 76 | + | } |
|
| 77 | + | None => { |
|
| 78 | + | accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok(); |
|
| 79 | + | return Ok(fail("That code is not right.", Some(username))); |
|
| 80 | + | } |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok(); |
|
| 85 | + | Ok(signed_in(&app, &account, &next)) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | /// A redirect that sets the session cookie. |
|
| 89 | + | pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response { |
|
| 90 | + | let value = auth::session_value(&app.cookie_key, &account.username, &account.session_salt); |
|
| 91 | + | let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; |
|
| 92 | + | let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE); |
|
| 93 | + | ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response() |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | #[derive(Deserialize)] |
|
| 97 | + | pub struct CsrfForm { |
|
| 98 | + | #[serde(rename = "_csrf")] |
|
| 99 | + | pub csrf: String, |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | pub async fn logout(State(app): State<Shared>, viewer: Viewer, Form(f): Form<CsrfForm>) -> Response { |
|
| 103 | + | if !viewer.csrf_ok(&f.csrf) { |
|
| 104 | + | return Redirect::to("/").into_response(); |
|
| 105 | + | } |
|
| 106 | + | if let Some(a) = viewer.account() { |
|
| 107 | + | accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok(); |
|
| 108 | + | } |
|
| 109 | + | let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", auth::SESSION_COOKIE); |
|
| 110 | + | ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response() |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | #[derive(Template)] |
|
| 114 | + | #[template(path = "register.html")] |
|
| 115 | + | pub struct RegisterPage { |
|
| 116 | + | shell: Shell, |
|
| 117 | + | error: Option<String>, |
|
| 118 | + | mode: String, |
|
| 119 | + | first: bool, |
|
| 120 | + | invite: String, |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | /// The registration mode: the settings table overrides `hub.toml`. |
|
| 124 | + | pub async fn registration_mode(app: &Shared) -> String { |
|
| 125 | + | accounts::setting(&app.db, "registration_mode").await.ok().flatten().unwrap_or_else(|| app.config.registration.mode.clone()) |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | #[derive(Deserialize)] |
|
| 129 | + | pub struct InviteQuery { |
|
| 130 | + | invite: Option<String>, |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | pub async fn register_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InviteQuery>) -> Result<Response, Error> { |
|
| 134 | + | if viewer.signed_in() { |
|
| 135 | + | return Ok(Redirect::to("/").into_response()); |
|
| 136 | + | } |
|
| 137 | + | let shell = Shell::new(&app, "/register", &viewer); |
|
| 138 | + | let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0; |
|
| 139 | + | Ok(Page(RegisterPage { shell, error: None, mode: registration_mode(&app).await, first, invite: q.invite.unwrap_or_default() }) |
|
| 140 | + | .into_response()) |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | #[derive(Deserialize)] |
|
| 144 | + | pub struct RegisterForm { |
|
| 145 | + | username: String, |
|
| 146 | + | email: String, |
|
| 147 | + | password: String, |
|
| 148 | + | #[serde(default)] |
|
| 149 | + | invite: String, |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> { |
|
| 153 | + | let shell = Shell::new(&app, "/register", &viewer); |
|
| 154 | + | let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0; |
|
| 155 | + | let mode = registration_mode(&app).await; |
|
| 156 | + | let fail = |msg: &str| { |
|
| 157 | + | Page(RegisterPage { shell: shell.clone(), error: Some(msg.into()), mode: mode.clone(), first, invite: f.invite.clone() }) |
|
| 158 | + | .into_response() |
|
| 159 | + | }; |
|
| 160 | + | let username = f.username.trim().to_string(); |
|
| 161 | + | if !crate::repos::names::valid_owner(&username) { |
|
| 162 | + | return Ok(fail("A username is letters, digits, dots, dashes and underscores, and is not a reserved word.")); |
|
| 163 | + | } |
|
| 164 | + | if let Err(e) = auth::password_ok(&f.password) { |
|
| 165 | + | return Ok(fail(e)); |
|
| 166 | + | } |
|
| 167 | + | if !first { |
|
| 168 | + | match mode.as_str() { |
|
| 169 | + | "open" => {} |
|
| 170 | + | "invite" => { |
|
| 171 | + | let ok = !f.invite.trim().is_empty() |
|
| 172 | + | && accounts::invitation_unused(&app.db, f.invite.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 173 | + | if !ok { |
|
| 174 | + | return Ok(fail("Registration needs a valid invitation code.")); |
|
| 175 | + | } |
|
| 176 | + | } |
|
| 177 | + | _ => return Ok(fail("Registration is closed.")), |
|
| 178 | + | } |
|
| 179 | + | } |
|
| 180 | + | if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 181 | + | || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 182 | + | { |
|
| 183 | + | return Ok(fail("That name is taken.")); |
|
| 184 | + | } |
|
| 185 | + | let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 186 | + | let account = accounts::create_account(&app.db, &username, f.email.trim(), &hash, &auth::random_hex(16), first) |
|
| 187 | + | .await |
|
| 188 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 189 | + | if !first && mode == "invite" { |
|
| 190 | + | accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok(); |
|
| 191 | + | } |
|
| 192 | + | accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok(); |
|
| 193 | + | Ok(signed_in(&app, &account, "/")) |
|
| 194 | + | } |
added crates/hub/src/web/settings.rs +437 −0
| 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::response::{IntoResponse, Response}; |
|
| 11 | + | use oxy_core::identity::{Identity, Signature}; |
|
| 12 | + | use serde::Deserialize; |
|
| 13 | + | ||
| 14 | + | /// The signed-in account, or a redirect to sign in. |
|
| 15 | + | pub fn require(viewer: &Viewer, shell: &Shell) -> Result<ViewerAccount, Error> { |
|
| 16 | + | viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path)))) |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | pub fn urlencode(s: &str) -> String { |
|
| 20 | + | let mut out = String::new(); |
|
| 21 | + | for b in s.bytes() { |
|
| 22 | + | match b { |
|
| 23 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char), |
|
| 24 | + | _ => out.push_str(&format!("%{b:02X}")), |
|
| 25 | + | } |
|
| 26 | + | } |
|
| 27 | + | out |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> { |
|
| 31 | + | if viewer.csrf_ok(token) { |
|
| 32 | + | Ok(()) |
|
| 33 | + | } else { |
|
| 34 | + | Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again.")) |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | #[derive(Template)] |
|
| 39 | + | #[template(path = "settings.html")] |
|
| 40 | + | pub struct SettingsPage { |
|
| 41 | + | shell: Shell, |
|
| 42 | + | me: accounts::Account, |
|
| 43 | + | notice: Option<String>, |
|
| 44 | + | error: Option<String>, |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | pub async fn profile(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SettingsPage>, Error> { |
|
| 48 | + | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 49 | + | let me = require(&viewer, &shell)?; |
|
| 50 | + | Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: None })) |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | #[derive(Deserialize)] |
|
| 54 | + | pub struct ProfileForm { |
|
| 55 | + | #[serde(rename = "_csrf")] |
|
| 56 | + | csrf_token: String, |
|
| 57 | + | display_name: String, |
|
| 58 | + | email: String, |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | pub async fn update_profile(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ProfileForm>) -> Result<Page<SettingsPage>, Error> { |
|
| 62 | + | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 63 | + | let me = require(&viewer, &shell)?; |
|
| 64 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 65 | + | accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim()) |
|
| 66 | + | .await |
|
| 67 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 68 | + | let updated = |
|
| 69 | + | accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); |
|
| 70 | + | Ok(Page(SettingsPage { shell, me: updated, notice: Some("Profile saved.".into()), error: None })) |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | #[derive(Deserialize)] |
|
| 74 | + | pub struct PasswordForm { |
|
| 75 | + | #[serde(rename = "_csrf")] |
|
| 76 | + | csrf_token: String, |
|
| 77 | + | current: String, |
|
| 78 | + | password: String, |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub async fn change_password(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PasswordForm>) -> Result<Response, Error> { |
|
| 82 | + | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 83 | + | let me = require(&viewer, &shell)?; |
|
| 84 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 85 | + | if !auth::verify_password(&me.account.password_hash, &f.current) { |
|
| 86 | + | return Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: Some("Your current password is wrong.".into()) }) |
|
| 87 | + | .into_response()); |
|
| 88 | + | } |
|
| 89 | + | if let Err(e) = auth::password_ok(&f.password) { |
|
| 90 | + | return Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: Some(e.into()) }).into_response()); |
|
| 91 | + | } |
|
| 92 | + | let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 93 | + | // A new session salt signs out every other session; this one is renewed. |
|
| 94 | + | accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 95 | + | accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok(); |
|
| 96 | + | 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); |
|
| 97 | + | Ok(super::session::signed_in(&app, &fresh, "/settings/security")) |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | #[derive(Template)] |
|
| 101 | + | #[template(path = "settings_security.html")] |
|
| 102 | + | pub struct SecurityPage { |
|
| 103 | + | shell: Shell, |
|
| 104 | + | me: accounts::Account, |
|
| 105 | + | notice: Option<String>, |
|
| 106 | + | error: Option<String>, |
|
| 107 | + | /// Enrolment in progress: secret, URI and QR. |
|
| 108 | + | enrol: Option<(String, String, String)>, |
|
| 109 | + | recovery: Vec<String>, |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | pub async fn security(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SecurityPage>, Error> { |
|
| 113 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 114 | + | let me = require(&viewer, &shell)?; |
|
| 115 | + | Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: None, recovery: vec![] })) |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | #[derive(Deserialize)] |
|
| 119 | + | pub struct TotpForm { |
|
| 120 | + | #[serde(rename = "_csrf")] |
|
| 121 | + | csrf_token: String, |
|
| 122 | + | #[serde(default)] |
|
| 123 | + | secret: String, |
|
| 124 | + | #[serde(default)] |
|
| 125 | + | code: String, |
|
| 126 | + | #[serde(default)] |
|
| 127 | + | password: String, |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// Step one: show a fresh secret to scan. |
|
| 131 | + | pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> { |
|
| 132 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 133 | + | let me = require(&viewer, &shell)?; |
|
| 134 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 135 | + | let secret = totp::new_secret(); |
|
| 136 | + | let uri = totp::uri(&app.config.site.name, &me.account.username, &secret); |
|
| 137 | + | let qr = totp::qr_svg(&uri).unwrap_or_default(); |
|
| 138 | + | Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![] })) |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | /// Step two: the first code proves the app holds the secret. |
|
| 142 | + | pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> { |
|
| 143 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 144 | + | let me = require(&viewer, &shell)?; |
|
| 145 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 146 | + | let now = (db::now_ms() / 1000) as u64; |
|
| 147 | + | if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) { |
|
| 148 | + | let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret); |
|
| 149 | + | let qr = totp::qr_svg(&uri).unwrap_or_default(); |
|
| 150 | + | return Ok(Page(SecurityPage { |
|
| 151 | + | shell, |
|
| 152 | + | me: me.account, |
|
| 153 | + | notice: None, |
|
| 154 | + | error: Some("That code did not match; scan again and enter the current code.".into()), |
|
| 155 | + | enrol: Some((f.secret, uri, qr)), |
|
| 156 | + | recovery: vec![], |
|
| 157 | + | })); |
|
| 158 | + | } |
|
| 159 | + | let (codes, stored) = totp::new_recovery_codes(); |
|
| 160 | + | accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 161 | + | accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok(); |
|
| 162 | + | 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); |
|
| 163 | + | Ok(Page(SecurityPage { |
|
| 164 | + | shell, |
|
| 165 | + | me: fresh, |
|
| 166 | + | notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()), |
|
| 167 | + | error: None, |
|
| 168 | + | enrol: None, |
|
| 169 | + | recovery: codes, |
|
| 170 | + | })) |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> { |
|
| 174 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 175 | + | let me = require(&viewer, &shell)?; |
|
| 176 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 177 | + | if !auth::verify_password(&me.account.password_hash, &f.password) { |
|
| 178 | + | return Ok(Page(SecurityPage { |
|
| 179 | + | shell, |
|
| 180 | + | me: me.account, |
|
| 181 | + | notice: None, |
|
| 182 | + | error: Some("Your password is wrong.".into()), |
|
| 183 | + | enrol: None, |
|
| 184 | + | recovery: vec![], |
|
| 185 | + | })); |
|
| 186 | + | } |
|
| 187 | + | accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 188 | + | accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok(); |
|
| 189 | + | 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); |
|
| 190 | + | Ok(Page(SecurityPage { |
|
| 191 | + | shell, |
|
| 192 | + | me: fresh, |
|
| 193 | + | notice: Some("Two-factor authentication is off.".into()), |
|
| 194 | + | error: None, |
|
| 195 | + | enrol: None, |
|
| 196 | + | recovery: vec![], |
|
| 197 | + | })) |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | /// Sign out everywhere: a new session salt invalidates every cookie, then |
|
| 201 | + | /// this browser gets a fresh one. |
|
| 202 | + | pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> { |
|
| 203 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 204 | + | let me = require(&viewer, &shell)?; |
|
| 205 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 206 | + | accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 207 | + | accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok(); |
|
| 208 | + | 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); |
|
| 209 | + | Ok(super::session::signed_in(&app, &fresh, "/settings/security")) |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | // ------------------------------------------------------------------- keys |
|
| 213 | + | ||
| 214 | + | #[derive(Template)] |
|
| 215 | + | #[template(path = "settings_keys.html")] |
|
| 216 | + | pub struct KeysPage { |
|
| 217 | + | shell: Shell, |
|
| 218 | + | me: accounts::Account, |
|
| 219 | + | keys: Vec<accounts::LinkedIdentity>, |
|
| 220 | + | notice: Option<String>, |
|
| 221 | + | error: Option<String>, |
|
| 222 | + | /// A pending challenge: id, hex bytes, identity name. |
|
| 223 | + | pending: Option<(i64, String, String)>, |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | async fn keys_page( |
|
| 227 | + | app: &Shared, |
|
| 228 | + | shell: Shell, |
|
| 229 | + | me: accounts::Account, |
|
| 230 | + | notice: Option<String>, |
|
| 231 | + | error: Option<String>, |
|
| 232 | + | pending: Option<(i64, String, String)>, |
|
| 233 | + | ) -> Result<Page<KeysPage>, Error> { |
|
| 234 | + | let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 235 | + | Ok(Page(KeysPage { shell, me, keys, notice, error, pending })) |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> { |
|
| 239 | + | let shell = Shell::new(&app, "/settings/keys", &viewer); |
|
| 240 | + | let me = require(&viewer, &shell)?; |
|
| 241 | + | keys_page(&app, shell, me.account, None, None, None).await |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | /// Upload an identity object (`oxy identity export`): the hub issues a |
|
| 245 | + | /// challenge to sign with that key. |
|
| 246 | + | pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> { |
|
| 247 | + | let shell = Shell::new(&app, "/settings/keys", &viewer); |
|
| 248 | + | let me = require(&viewer, &shell)?; |
|
| 249 | + | let mut token = String::new(); |
|
| 250 | + | let mut object: Vec<u8> = Vec::new(); |
|
| 251 | + | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 252 | + | match field.name() { |
|
| 253 | + | Some("_csrf") => token = field.text().await.unwrap_or_default(), |
|
| 254 | + | Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(), |
|
| 255 | + | _ => {} |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | csrf(&viewer, &token, &shell)?; |
|
| 259 | + | let identity = match Identity::decode(&object) { |
|
| 260 | + | Ok(i) => i, |
|
| 261 | + | Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await, |
|
| 262 | + | }; |
|
| 263 | + | let key = identity.hash().to_hex(); |
|
| 264 | + | if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 265 | + | let msg = if existing.account_id == me.account.id { |
|
| 266 | + | "That key is already linked to you." |
|
| 267 | + | } else { |
|
| 268 | + | "That key is linked to another account." |
|
| 269 | + | }; |
|
| 270 | + | return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await; |
|
| 271 | + | } |
|
| 272 | + | let bytes = auth::random_hex(32); |
|
| 273 | + | let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 274 | + | sqlx::query("UPDATE challenges SET object = ? WHERE id = ?") |
|
| 275 | + | .bind(&object) |
|
| 276 | + | .bind(id) |
|
| 277 | + | .execute(&app.db) |
|
| 278 | + | .await |
|
| 279 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 280 | + | keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | #[derive(Deserialize)] |
|
| 284 | + | pub struct KeyVerifyForm { |
|
| 285 | + | #[serde(rename = "_csrf")] |
|
| 286 | + | csrf_token: String, |
|
| 287 | + | challenge: i64, |
|
| 288 | + | signature: String, |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> { |
|
| 292 | + | let shell = Shell::new(&app, "/settings/keys", &viewer); |
|
| 293 | + | let me = require(&viewer, &shell)?; |
|
| 294 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 295 | + | let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = ? AND account_id = ?") |
|
| 296 | + | .bind(f.challenge) |
|
| 297 | + | .bind(me.account.id) |
|
| 298 | + | .fetch_optional(&app.db) |
|
| 299 | + | .await |
|
| 300 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 301 | + | let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 302 | + | let (Some(object), Some(bytes)) = (object, bytes) else { |
|
| 303 | + | return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await; |
|
| 304 | + | }; |
|
| 305 | + | let Ok(identity) = Identity::decode(&object) else { |
|
| 306 | + | return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await; |
|
| 307 | + | }; |
|
| 308 | + | let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default(); |
|
| 309 | + | let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default(); |
|
| 310 | + | let challenge = hex::decode(&bytes).unwrap_or_default(); |
|
| 311 | + | let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok()); |
|
| 312 | + | if !ok { |
|
| 313 | + | return keys_page( |
|
| 314 | + | &app, |
|
| 315 | + | shell, |
|
| 316 | + | me.account, |
|
| 317 | + | None, |
|
| 318 | + | Some("The signature does not verify against that identity and challenge.".into()), |
|
| 319 | + | None, |
|
| 320 | + | ) |
|
| 321 | + | .await; |
|
| 322 | + | } |
|
| 323 | + | let key = identity.hash().to_hex(); |
|
| 324 | + | accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object) |
|
| 325 | + | .await |
|
| 326 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 327 | + | accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok(); |
|
| 328 | + | keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await |
|
| 329 | + | } |
|
| 330 | + | ||
| 331 | + | #[derive(Deserialize)] |
|
| 332 | + | pub struct KeyForm { |
|
| 333 | + | #[serde(rename = "_csrf")] |
|
| 334 | + | csrf_token: String, |
|
| 335 | + | key: String, |
|
| 336 | + | } |
|
| 337 | + | ||
| 338 | + | pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> { |
|
| 339 | + | let shell = Shell::new(&app, "/settings/keys", &viewer); |
|
| 340 | + | let me = require(&viewer, &shell)?; |
|
| 341 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 342 | + | accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 343 | + | accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok(); |
|
| 344 | + | keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | // ----------------------------------------------------------------- tokens |
|
| 348 | + | ||
| 349 | + | #[derive(Template)] |
|
| 350 | + | #[template(path = "settings_tokens.html")] |
|
| 351 | + | pub struct TokensPage { |
|
| 352 | + | shell: Shell, |
|
| 353 | + | me: accounts::Account, |
|
| 354 | + | tokens: Vec<accounts::Token>, |
|
| 355 | + | /// A freshly created token, shown once. |
|
| 356 | + | fresh: Option<String>, |
|
| 357 | + | notice: Option<String>, |
|
| 358 | + | error: Option<String>, |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | async fn tokens_page( |
|
| 362 | + | app: &Shared, |
|
| 363 | + | shell: Shell, |
|
| 364 | + | me: accounts::Account, |
|
| 365 | + | fresh: Option<String>, |
|
| 366 | + | notice: Option<String>, |
|
| 367 | + | error: Option<String>, |
|
| 368 | + | ) -> Result<Page<TokensPage>, Error> { |
|
| 369 | + | let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 370 | + | Ok(Page(TokensPage { shell, me, tokens, fresh, notice, error })) |
|
| 371 | + | } |
|
| 372 | + | ||
| 373 | + | pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> { |
|
| 374 | + | let shell = Shell::new(&app, "/settings/tokens", &viewer); |
|
| 375 | + | let me = require(&viewer, &shell)?; |
|
| 376 | + | tokens_page(&app, shell, me.account, None, None, None).await |
|
| 377 | + | } |
|
| 378 | + | ||
| 379 | + | #[derive(Deserialize)] |
|
| 380 | + | pub struct TokenForm { |
|
| 381 | + | #[serde(rename = "_csrf")] |
|
| 382 | + | csrf_token: String, |
|
| 383 | + | name: String, |
|
| 384 | + | #[serde(default)] |
|
| 385 | + | scopes: Vec<String>, |
|
| 386 | + | #[serde(default)] |
|
| 387 | + | repos: String, |
|
| 388 | + | #[serde(default)] |
|
| 389 | + | expires_days: String, |
|
| 390 | + | } |
|
| 391 | + | ||
| 392 | + | pub const SCOPES: &[(&str, &str)] = &[ |
|
| 393 | + | ("repo:read", "read repositories you can see (clone, fetch, pull)"), |
|
| 394 | + | ("repo:write", "push to repositories you can write to"), |
|
| 395 | + | ("repo:admin", "change repository settings through the API"), |
|
| 396 | + | ("user:read", "read your profile through the API"), |
|
| 397 | + | ]; |
|
| 398 | + | ||
| 399 | + | pub async fn tokens_create( |
|
| 400 | + | State(app): State<Shared>, |
|
| 401 | + | viewer: Viewer, |
|
| 402 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>, |
|
| 403 | + | ) -> Result<Page<TokensPage>, Error> { |
|
| 404 | + | let shell = Shell::new(&app, "/settings/tokens", &viewer); |
|
| 405 | + | let me = require(&viewer, &shell)?; |
|
| 406 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 407 | + | let name = f.name.trim(); |
|
| 408 | + | if name.is_empty() { |
|
| 409 | + | return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await; |
|
| 410 | + | } |
|
| 411 | + | let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect(); |
|
| 412 | + | if scopes.is_empty() { |
|
| 413 | + | return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await; |
|
| 414 | + | } |
|
| 415 | + | let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000); |
|
| 416 | + | let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(","); |
|
| 417 | + | let (secret, hash) = auth::new_token(); |
|
| 418 | + | accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms) |
|
| 419 | + | .await |
|
| 420 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 421 | + | accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok(); |
|
| 422 | + | tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await |
|
| 423 | + | } |
|
| 424 | + | ||
| 425 | + | pub async fn tokens_delete( |
|
| 426 | + | State(app): State<Shared>, |
|
| 427 | + | viewer: Viewer, |
|
| 428 | + | Path(id): Path<i64>, |
|
| 429 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 430 | + | ) -> Result<Page<TokensPage>, Error> { |
|
| 431 | + | let shell = Shell::new(&app, "/settings/tokens", &viewer); |
|
| 432 | + | let me = require(&viewer, &shell)?; |
|
| 433 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 434 | + | accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 435 | + | accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok(); |
|
| 436 | + | tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await |
|
| 437 | + | } |
added crates/hub/tests/accounts.rs +284 −0
| 1 | + | //! Milestone 0.2 end to end: registration, sessions, tokens, roles and |
|
| 2 | + | //! visibility, linked identities with attribution, organisations. |
|
| 3 | + | ||
| 4 | + | use axum::body::Body; |
|
| 5 | + | use axum::http::{header, Request, StatusCode}; |
|
| 6 | + | use http_body_util::BodyExt; |
|
| 7 | + | use oxy_core::repo::Repo; |
|
| 8 | + | use oxygenhub::config::Config; |
|
| 9 | + | use oxygenhub::db; |
|
| 10 | + | use oxygenhub::Shared; |
|
| 11 | + | use std::path::Path; |
|
| 12 | + | use tower::ServiceExt; |
|
| 13 | + | ||
| 14 | + | fn hermetic() { |
|
| 15 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 16 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 17 | + | ONCE.call_once(|| { |
|
| 18 | + | let d = tempfile::tempdir().unwrap(); |
|
| 19 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 20 | + | DIR.set(d).ok(); |
|
| 21 | + | }); |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 25 | + | let p = root.join(rel); |
|
| 26 | + | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 27 | + | std::fs::write(p, content).unwrap(); |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | struct Hub { |
|
| 31 | + | app: Shared, |
|
| 32 | + | base: String, |
|
| 33 | + | _dir: tempfile::TempDir, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | async fn hub() -> Hub { |
|
| 37 | + | hermetic(); |
|
| 38 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 39 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 40 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 41 | + | let port = listener.local_addr().unwrap().port(); |
|
| 42 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 43 | + | config.registration.mode = "invite".into(); |
|
| 44 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 45 | + | let router = oxygenhub::router(app.clone()); |
|
| 46 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 47 | + | Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | struct Res { |
|
| 51 | + | status: StatusCode, |
|
| 52 | + | body: String, |
|
| 53 | + | cookie: Option<String>, |
|
| 54 | + | location: Option<String>, |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
|
| 58 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 59 | + | if let Some(c) = cookie { |
|
| 60 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 61 | + | } |
|
| 62 | + | let body = match form { |
|
| 63 | + | Some(f) => { |
|
| 64 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 65 | + | Body::from(f.to_string()) |
|
| 66 | + | } |
|
| 67 | + | None => Body::empty(), |
|
| 68 | + | }; |
|
| 69 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 70 | + | let status = res.status(); |
|
| 71 | + | let cookie = res |
|
| 72 | + | .headers() |
|
| 73 | + | .get(header::SET_COOKIE) |
|
| 74 | + | .and_then(|v| v.to_str().ok()) |
|
| 75 | + | .and_then(|v| v.split(';').next()) |
|
| 76 | + | .and_then(|kv| kv.split_once('=')) |
|
| 77 | + | .map(|(_, v)| v.to_string()); |
|
| 78 | + | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 79 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 80 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| 84 | + | send(app, "GET", path, cookie, None).await |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
|
| 88 | + | send(app, "POST", path, cookie, Some(form)).await |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | /// Register (or sign in) and return the session cookie value. |
|
| 92 | + | async fn register(app: &Shared, user: &str, invite: &str) -> String { |
|
| 93 | + | let r = |
|
| 94 | + | post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&invite={invite}")) |
|
| 95 | + | .await; |
|
| 96 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 97 | + | r.cookie.expect("session cookie") |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | fn csrf_of(page: &str) -> String { |
|
| 101 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 102 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 106 | + | async fn accounts_roles_tokens_and_attribution() { |
|
| 107 | + | let h = hub().await; |
|
| 108 | + | let app = &h.app; |
|
| 109 | + | ||
| 110 | + | // The first account registers without an invitation and is the administrator. |
|
| 111 | + | let andy = register(app, "andy", "").await; |
|
| 112 | + | let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 113 | + | assert!(me.is_admin); |
|
| 114 | + | // A second registration needs an invitation. |
|
| 115 | + | let r = post(app, "/register", None, "username=mallory&email=m%40example.com&password=correct-horse-battery&invite=").await; |
|
| 116 | + | assert_eq!(r.status, StatusCode::OK); |
|
| 117 | + | assert!(r.body.contains("invitation"), "{}", r.body); |
|
| 118 | + | let admin_page = get(app, "/admin", Some(&andy)).await; |
|
| 119 | + | assert_eq!(admin_page.status, StatusCode::OK); |
|
| 120 | + | let csrf = csrf_of(&admin_page.body); |
|
| 121 | + | let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 122 | + | let code = r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
|
| 123 | + | let bob = register(app, "bob", &code).await; |
|
| 124 | + | assert!(!db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().is_admin); |
|
| 125 | + | // Wrong password, banned users and CSRF-less posts are refused. |
|
| 126 | + | assert_eq!(post(app, "/login", None, "username=andy&password=nope").await.status, StatusCode::OK); |
|
| 127 | + | assert_eq!(post(app, "/settings", Some(&andy), "_csrf=bogus&display_name=x&email=y").await.status, StatusCode::BAD_REQUEST); |
|
| 128 | + | assert_eq!(get(app, "/settings", None).await.status, StatusCode::SEE_OTHER, "anonymous settings redirects to sign in"); |
|
| 129 | + | ||
| 130 | + | // Andy creates a private repository through the form; Bob cannot see it. |
|
| 131 | + | let new_page = get(app, "/new", Some(&andy)).await; |
|
| 132 | + | let csrf = csrf_of(&new_page.body); |
|
| 133 | + | let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=andy&name=secret&description=hush&visibility=private")).await; |
|
| 134 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 135 | + | assert_eq!(get(app, "/andy/secret", Some(&andy)).await.status, StatusCode::OK); |
|
| 136 | + | assert_eq!(get(app, "/andy/secret", Some(&bob)).await.status, StatusCode::NOT_FOUND); |
|
| 137 | + | assert_eq!(get(app, "/andy/secret", None).await.status, StatusCode::NOT_FOUND); |
|
| 138 | + | assert!(!get(app, "/", Some(&bob)).await.body.contains("secret")); |
|
| 139 | + | assert!(get(app, "/", Some(&andy)).await.body.contains("secret")); |
|
| 140 | + | ||
| 141 | + | // Tokens: a read-only token cannot push; a write token can. Anonymous cannot read a private repository. |
|
| 142 | + | let tokens_page = get(app, "/settings/tokens", Some(&andy)).await; |
|
| 143 | + | let csrf = csrf_of(&tokens_page.body); |
|
| 144 | + | let r = post(app, "/settings/tokens", Some(&andy), &format!("_csrf={csrf}&name=ro&scopes=repo%3Aread")).await; |
|
| 145 | + | let ro = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap(); |
|
| 146 | + | let r = post(app, "/settings/tokens", Some(&andy), &format!("_csrf={csrf}&name=rw&scopes=repo%3Aread&scopes=repo%3Awrite")).await; |
|
| 147 | + | let rw = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap(); |
|
| 148 | + | let work = tempfile::tempdir().unwrap(); |
|
| 149 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 150 | + | client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
|
| 151 | + | client.config.set("remote.origin.url", &format!("{}/andy/secret", h.base)).unwrap(); |
|
| 152 | + | client.config.set("core.sign", "change").unwrap(); |
|
| 153 | + | client.save_config().unwrap(); |
|
| 154 | + | write(work.path(), "a.txt", b"a\n"); |
|
| 155 | + | client.snapshot("t").unwrap(); |
|
| 156 | + | let c1 = client.commit("first", &[]).unwrap(); |
|
| 157 | + | let err = client.push("origin", false).err().unwrap().to_string(); |
|
| 158 | + | assert!(err.contains("404"), "anonymous cannot even see it: {err}"); |
|
| 159 | + | client.config.set("remote.origin.token", &ro).unwrap(); |
|
| 160 | + | let err = client.push("origin", false).err().unwrap().to_string(); |
|
| 161 | + | assert!(err.contains("403"), "read-only token cannot push: {err}"); |
|
| 162 | + | client.config.set("remote.origin.token", &rw).unwrap(); |
|
| 163 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 164 | + | // Bob's token cannot read it either. |
|
| 165 | + | let bob_tokens = get(app, "/settings/tokens", Some(&bob)).await; |
|
| 166 | + | let csrf_b = csrf_of(&bob_tokens.body); |
|
| 167 | + | let r = post(app, "/settings/tokens", Some(&bob), &format!("_csrf={csrf_b}&name=b&scopes=repo%3Aread&scopes=repo%3Awrite")).await; |
|
| 168 | + | let bob_tok = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap(); |
|
| 169 | + | std::env::set_var("OXY_TOKEN", &bob_tok); |
|
| 170 | + | let err = Repo::clone(&format!("{}/andy/secret", h.base), &work.path().join("bobclone")).err().unwrap().to_string(); |
|
| 171 | + | std::env::remove_var("OXY_TOKEN"); |
|
| 172 | + | assert!(err.contains("404"), "{err}"); |
|
| 173 | + | // The push log names the pusher. |
|
| 174 | + | tokio::time::sleep(std::time::Duration::from_millis(150)).await; |
|
| 175 | + | let pushes = get(app, "/andy/secret/pushes", Some(&andy)).await.body; |
|
| 176 | + | assert!(pushes.contains("andy"), "{pushes}"); |
|
| 177 | + | ||
| 178 | + | // Bob becomes a collaborator with write, and can then see and push. |
|
| 179 | + | let settings = get(app, "/andy/secret/settings", Some(&andy)).await; |
|
| 180 | + | assert_eq!(settings.status, StatusCode::OK); |
|
| 181 | + | let csrf = csrf_of(&settings.body); |
|
| 182 | + | let r = post(app, "/andy/secret/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; |
|
| 183 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 184 | + | assert_eq!(get(app, "/andy/secret", Some(&bob)).await.status, StatusCode::OK); |
|
| 185 | + | assert_eq!(get(app, "/andy/secret/settings", Some(&bob)).await.status, StatusCode::NOT_FOUND, "write is not maintain"); |
|
| 186 | + | std::env::set_var("OXY_TOKEN", &bob_tok); |
|
| 187 | + | let ok = Repo::clone(&format!("{}/andy/secret", h.base), &work.path().join("bobclone2")).is_ok(); |
|
| 188 | + | std::env::remove_var("OXY_TOKEN"); |
|
| 189 | + | assert!(ok); |
|
| 190 | + | ||
| 191 | + | // Linking a key: upload the identity, sign the challenge, see attribution. |
|
| 192 | + | let (key, identity) = { |
|
| 193 | + | let k = client.config.author_key.unwrap(); |
|
| 194 | + | (k, client.identity(&k).unwrap()) |
|
| 195 | + | }; |
|
| 196 | + | let keys_page = get(app, "/settings/keys", Some(&andy)).await; |
|
| 197 | + | let csrf = csrf_of(&keys_page.body); |
|
| 198 | + | let boundary = "XYZ"; |
|
| 199 | + | let mut multipart = Vec::new(); |
|
| 200 | + | multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\n{csrf}\r\n").as_bytes()); |
|
| 201 | + | multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"identity\"; filename=\"me.identity\"\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes()); |
|
| 202 | + | multipart.extend_from_slice(&identity.encode()); |
|
| 203 | + | multipart.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); |
|
| 204 | + | let req = Request::builder() |
|
| 205 | + | .method("POST") |
|
| 206 | + | .uri("/settings/keys/upload") |
|
| 207 | + | .header(header::COOKIE, format!("oxyhub={andy}")) |
|
| 208 | + | .header(header::CONTENT_TYPE, format!("multipart/form-data; boundary={boundary}")) |
|
| 209 | + | .body(Body::from(multipart)) |
|
| 210 | + | .unwrap(); |
|
| 211 | + | let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 212 | + | assert_eq!(res.status(), StatusCode::OK); |
|
| 213 | + | let body = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 214 | + | let challenge_hex = body.split("sign-challenge ").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
|
| 215 | + | let challenge_id = body.split("name=\"challenge\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
|
| 216 | + | let (_, sk) = client.secret_key().unwrap().unwrap(); |
|
| 217 | + | let sig = sk.sign_challenge(&key, &hex::decode(&challenge_hex).unwrap()).unwrap(); |
|
| 218 | + | let sig_hex = hex::encode(oxy_core::identity::Signature::encode_list(&[sig])); |
|
| 219 | + | let r = post(app, "/settings/keys/verify", Some(&andy), &format!("_csrf={csrf}&challenge={challenge_id}&signature={sig_hex}")).await; |
|
| 220 | + | assert!(r.body.contains("Key linked"), "{}", r.body); |
|
| 221 | + | let hist = get(app, "/andy/secret/changes/main", Some(&andy)).await.body; |
|
| 222 | + | assert!(hist.contains("href=\"/andy\">Andy McCall</a>"), "author linked to the account: {hist}"); |
|
| 223 | + | let change = get(app, &format!("/andy/secret/change/{}", c1.to_hex()), Some(&andy)).await.body; |
|
| 224 | + | assert!(change.contains("@andy"), "{change}"); |
|
| 225 | + | // A forged signature is refused. |
|
| 226 | + | let r = post(app, "/settings/keys/verify", Some(&andy), &format!("_csrf={csrf}&challenge=999&signature={sig_hex}")).await; |
|
| 227 | + | assert!(r.body.contains("expired"), "{}", r.body); |
|
| 228 | + | ||
| 229 | + | // Organisations: a private org repository is visible to members through the default role. |
|
| 230 | + | let org_page = get(app, "/orgs/new", Some(&andy)).await; |
|
| 231 | + | let csrf = csrf_of(&org_page.body); |
|
| 232 | + | let r = post(app, "/orgs/new", Some(&andy), &format!("_csrf={csrf}&name=acme&display_name=Acme")).await; |
|
| 233 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 234 | + | let new_page = get(app, "/new", Some(&andy)).await; |
|
| 235 | + | let csrf = csrf_of(&new_page.body); |
|
| 236 | + | assert_eq!( |
|
| 237 | + | post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=acme&name=plans&visibility=private")).await.status, |
|
| 238 | + | StatusCode::SEE_OTHER |
|
| 239 | + | ); |
|
| 240 | + | assert_eq!(get(app, "/acme/plans", Some(&bob)).await.status, StatusCode::NOT_FOUND); |
|
| 241 | + | let org_settings = get(app, "/orgs/acme/settings", Some(&andy)).await; |
|
| 242 | + | let csrf = csrf_of(&org_settings.body); |
|
| 243 | + | assert_eq!( |
|
| 244 | + | post(app, "/orgs/acme/settings/members", Some(&andy), &format!("_csrf={csrf}&username=bob&role=member")).await.status, |
|
| 245 | + | StatusCode::OK |
|
| 246 | + | ); |
|
| 247 | + | assert_eq!(get(app, "/acme/plans", Some(&bob)).await.status, StatusCode::OK, "members read by default"); |
|
| 248 | + | assert_eq!(get(app, "/orgs/acme/settings", Some(&bob)).await.status, StatusCode::NOT_FOUND, "members do not administer"); |
|
| 249 | + | // Bob cannot create under acme's... he can, as a member; a stranger cannot. |
|
| 250 | + | let r = post( |
|
| 251 | + | app, |
|
| 252 | + | "/new", |
|
| 253 | + | Some(&bob), |
|
| 254 | + | &format!("_csrf={}&owner=acme&name=bobs&visibility=public", csrf_of(&get(app, "/new", Some(&bob)).await.body)), |
|
| 255 | + | ) |
|
| 256 | + | .await; |
|
| 257 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 258 | + | ||
| 259 | + | // Sign out everywhere invalidates the old cookie. |
|
| 260 | + | let sec = get(app, "/settings/security", Some(&andy)).await; |
|
| 261 | + | let csrf = csrf_of(&sec.body); |
|
| 262 | + | let r = post(app, "/settings/security/sessions", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 263 | + | assert_eq!(r.status, StatusCode::SEE_OTHER); |
|
| 264 | + | assert_eq!(get(app, "/settings", Some(&andy)).await.status, StatusCode::SEE_OTHER, "old session is gone"); |
|
| 265 | + | let fresh = r.cookie.unwrap(); |
|
| 266 | + | assert_eq!(get(app, "/settings", Some(&fresh)).await.status, StatusCode::OK); |
|
| 267 | + | ||
| 268 | + | // TOTP: enable with the current code, then sign in needs it. |
|
| 269 | + | let sec = get(app, "/settings/security", Some(&fresh)).await; |
|
| 270 | + | let csrf = csrf_of(&sec.body); |
|
| 271 | + | let r = post(app, "/settings/security/totp/begin", Some(&fresh), &format!("_csrf={csrf}")).await; |
|
| 272 | + | let secret = r.body.split("name=\"secret\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
|
| 273 | + | let now = (db::now_ms() / 1000) as u64; |
|
| 274 | + | let code = oxygenhub::auth::totp::current(&secret, now).unwrap(); |
|
| 275 | + | let r = post(app, "/settings/security/totp/enable", Some(&fresh), &format!("_csrf={csrf}&secret={secret}&code={code}")).await; |
|
| 276 | + | assert!(r.body.contains("Recovery codes"), "{}", r.body); |
|
| 277 | + | let r = post(app, "/login", None, "username=andy&password=correct-horse-battery").await; |
|
| 278 | + | assert!(r.body.contains("authenticator"), "second step asked: {}", r.body); |
|
| 279 | + | let code = oxygenhub::auth::totp::current(&secret, (db::now_ms() / 1000) as u64).unwrap(); |
|
| 280 | + | let r = post(app, "/login", None, &format!("username=andy&password=correct-horse-battery&code={code}")).await; |
|
| 281 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 282 | + | assert!(r.cookie.is_some()); |
|
| 283 | + | assert!(r.location.as_deref() == Some("/")); |
|
| 284 | + | } |
added templates/admin.html +41 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Administration{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div> |
|
| 5 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 6 | + | <div class="side-layout"> |
|
| 7 | + | <div> |
|
| 8 | + | <section class="panel"> |
|
| 9 | + | <h2>Accounts</h2> |
|
| 10 | + | <table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr> |
|
| 11 | + | {% for a in accounts %} |
|
| 12 | + | <tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ a.email }}</td><td>{% if a.is_admin %}<span class="tag">admin</span>{% endif %}{% if a.banned %} <span class="tag bad">banned</span>{% endif %}{% if a.totp_enabled() %} <span class="tag">2fa</span>{% endif %}</td> |
|
| 13 | + | <td>{% if a.last_seen_ms > 0 %}<time title="{{ a.last_seen_ms|when_i }}">{{ a.last_seen_ms|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 14 | + | <td><form class="tiny" method="post" action="/admin/account"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}"> |
|
| 15 | + | {% if a.banned %}<button name="action" value="unban">unban</button>{% else %}<button name="action" value="ban">ban</button>{% endif %} |
|
| 16 | + | {% if a.is_admin %}<button name="action" value="demote">demote</button>{% else %}<button name="action" value="promote">make admin</button>{% endif %}</form></td></tr> |
|
| 17 | + | {% endfor %} |
|
| 18 | + | </table> |
|
| 19 | + | </section> |
|
| 20 | + | <section class="panel"> |
|
| 21 | + | <h2>Audit log</h2> |
|
| 22 | + | <table class="small"><tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr> |
|
| 23 | + | {% for r in audit %}<tr><td><time title="{{ r.at_ms|when_i }}">{{ r.at_ms|ago_i }}</time></td><td>{{ r.actor }}</td><td>{{ r.action }}</td><td>{{ r.target }}</td><td class="mute">{{ r.detail }}</td></tr>{% endfor %} |
|
| 24 | + | </table> |
|
| 25 | + | </section> |
|
| 26 | + | </div> |
|
| 27 | + | <aside class="side"> |
|
| 28 | + | <h3>Registration</h3> |
|
| 29 | + | <form class="card" method="post" action="/admin/registration"> |
|
| 30 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 31 | + | <select name="mode">{% for m in ["open", "invite", "closed"] %}<option value="{{ m }}"{% if mode.as_str() == *m %} selected{% endif %}>{{ m }}</option>{% endfor %}</select> |
|
| 32 | + | <button type="submit">SET</button> |
|
| 33 | + | </form> |
|
| 34 | + | <h3>Invitations</h3> |
|
| 35 | + | <form class="card" method="post" action="/admin/invite"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">NEW INVITATION</button></form> |
|
| 36 | + | <ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul> |
|
| 37 | + | <h3>Organisations</h3> |
|
| 38 | + | <ul class="plain">{% for o in orgs %}<li><a href="/orgs/{{ o.name }}/settings">{{ o.name }}</a></li>{% endfor %}</ul> |
|
| 39 | + | </aside> |
|
| 40 | + | </div> |
|
| 41 | + | {% endblock %} |
added templates/login.html +21 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Sign in{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Sign in <small>{{ shell.site }}</small></h1></div> |
|
| 5 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | + | <form class="card" method="post" action="/login"> |
|
| 7 | + | <input type="hidden" name="next" value="{{ next }}"> |
|
| 8 | + | {% if let Some(u) = totp_for %} |
|
| 9 | + | <input type="hidden" name="username" value="{{ u }}"> |
|
| 10 | + | <input type="hidden" name="password" value="" id="pw"> |
|
| 11 | + | <p>Signed in as <strong>{{ u }}</strong>. Enter the six-digit code from your authenticator app, or a recovery code.</p> |
|
| 12 | + | <label for="code">Code</label><input type="text" id="code" name="code" autocomplete="one-time-code" inputmode="numeric" autofocus> |
|
| 13 | + | <label for="password2">Password (again, to keep this step honest)</label><input type="password" id="password2" name="password" autocomplete="current-password"> |
|
| 14 | + | {% else %} |
|
| 15 | + | <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" autofocus required> |
|
| 16 | + | <label for="password">Password</label><input type="password" id="password" name="password" autocomplete="current-password" required> |
|
| 17 | + | {% endif %} |
|
| 18 | + | <button type="submit">SIGN IN</button> |
|
| 19 | + | </form> |
|
| 20 | + | <p class="small mute">No account? <a href="/register">Register</a>.</p> |
|
| 21 | + | {% endblock %} |
added templates/org_new.html +12 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}New organisation{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>New organisation <small>an owner for shared repositories, with members and teams</small></h1></div> |
|
| 5 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | + | <form class="card" method="post" action="/orgs/new"> |
|
| 7 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 8 | + | <label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}"> |
|
| 9 | + | <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name"> |
|
| 10 | + | <button type="submit">CREATE</button> |
|
| 11 | + | </form> |
|
| 12 | + | {% endblock %} |
added templates/org_settings.html +39 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}{{ org.name }} settings{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div> |
|
| 5 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 6 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 7 | + | <form class="card" method="post" action="/orgs/{{ org.name }}/settings"> |
|
| 8 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 9 | + | <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name" value="{{ org.display_name }}"> |
|
| 10 | + | <label for="description">Description</label><input type="text" id="description" name="description" value="{{ org.description }}"> |
|
| 11 | + | <label for="default_role">Members' default role on the organisation's repositories</label> |
|
| 12 | + | <select id="default_role" name="default_role">{% for r in ["none", "read", "triage", "write", "maintain", "admin"] %}<option value="{{ r }}"{% if org.default_role.as_str() == *r %} selected{% endif %}>{{ r }}</option>{% endfor %}</select> |
|
| 13 | + | <button type="submit">SAVE</button> |
|
| 14 | + | </form> |
|
| 15 | + | <section class="panel"> |
|
| 16 | + | <h2>Members</h2> |
|
| 17 | + | <table class="small">{% for (a, r) in members %}<tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ r }}</td> |
|
| 18 | + | <td><form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/members"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}"><button name="remove" value="1">remove</button></form></td></tr>{% endfor %}</table> |
|
| 19 | + | <form class="card" method="post" action="/orgs/{{ org.name }}/settings/members"> |
|
| 20 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 21 | + | <div class="row"><div><label for="username">Account</label><input type="text" id="username" name="username" required></div> |
|
| 22 | + | <div><label for="role">Role</label><select id="role" name="role"><option value="member">member</option><option value="owner">owner</option></select></div></div> |
|
| 23 | + | <button type="submit">ADD OR CHANGE</button> |
|
| 24 | + | </form> |
|
| 25 | + | </section> |
|
| 26 | + | <section class="panel"> |
|
| 27 | + | <h2>Teams</h2> |
|
| 28 | + | {% for (t, ms) in teams %} |
|
| 29 | + | <p><strong>{{ t.name }}</strong>: {% for a in ms %}{{ a.username }}{% if !loop.last %}, {% endif %}{% endfor %}{% if ms.is_empty() %}<span class="mute">nobody yet</span>{% endif %} |
|
| 30 | + | <form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/teams"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="team" value="{{ t.name }}"><button name="action" value="delete">delete team</button></form></p> |
|
| 31 | + | {% endfor %} |
|
| 32 | + | <form class="card" method="post" action="/orgs/{{ org.name }}/settings/teams"> |
|
| 33 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 34 | + | <div class="row"><div><label for="team">Team</label><input type="text" id="team" name="team" required></div> |
|
| 35 | + | <div><label for="tuser">Account (for add or remove)</label><input type="text" id="tuser" name="username"></div></div> |
|
| 36 | + | <div class="choice"><button type="submit" name="action" value="create">CREATE TEAM</button> <button type="submit" name="action" value="add">ADD MEMBER</button> <button type="submit" name="action" value="remove">REMOVE MEMBER</button></div> |
|
| 37 | + | </form> |
|
| 38 | + | </section> |
|
| 39 | + | {% endblock %} |
added templates/register.html +17 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Register{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Register <small>{% if first %}the first account becomes the administrator{% else %}registration is {{ mode }}{% endif %}</small></h1></div> |
|
| 5 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | + | {% if !first && mode == "closed" %} |
|
| 7 | + | <p class="empty">Registration is closed. Ask an administrator for an account.</p> |
|
| 8 | + | {% else %} |
|
| 9 | + | <form class="card" method="post" action="/register"> |
|
| 10 | + | <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}"> |
|
| 11 | + | <label for="email">Email</label><input type="email" id="email" name="email" autocomplete="email"> |
|
| 12 | + | <label for="password">Password (at least 10 characters)</label><input type="password" id="password" name="password" autocomplete="new-password" required minlength="10"> |
|
| 13 | + | {% if !first && mode == "invite" %}<label for="invite">Invitation code</label><input type="text" id="invite" name="invite" value="{{ invite }}" required>{% endif %} |
|
| 14 | + | <button type="submit">CREATE ACCOUNT</button> |
|
| 15 | + | </form> |
|
| 16 | + | {% endif %} |
|
| 17 | + | {% endblock %} |
added templates/repo_new.html +21 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}New repository{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>New repository <small>empty until the first push, which it takes its identity from</small></h1></div> |
|
| 5 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | + | <form class="card" method="post" action="/new"> |
|
| 7 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 8 | + | <div class="row"> |
|
| 9 | + | <div><label for="owner">Owner</label><select id="owner" name="owner">{% for o in owners %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select></div> |
|
| 10 | + | <div><label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}"></div> |
|
| 11 | + | </div> |
|
| 12 | + | <label for="description">Description</label><input type="text" id="description" name="description"> |
|
| 13 | + | <label>Visibility</label> |
|
| 14 | + | <div class="choice"> |
|
| 15 | + | <label><input type="radio" name="visibility" value="public" checked> public</label> |
|
| 16 | + | <label><input type="radio" name="visibility" value="internal"> internal (anyone signed in)</label> |
|
| 17 | + | <label><input type="radio" name="visibility" value="private"> private</label> |
|
| 18 | + | </div> |
|
| 19 | + | <button type="submit">CREATE</button> |
|
| 20 | + | </form> |
|
| 21 | + | {% endblock %} |
added templates/repo_settings.html +53 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "settings") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>Settings <small>{{ repo.owner }}/{{ repo.name }}</small></h1></div> |
|
| 8 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 9 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 10 | + | <form class="card wide" method="post" action="{{ repo.url }}/settings"> |
|
| 11 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 12 | + | <h2>General</h2> |
|
| 13 | + | <label for="description">Description</label><input type="text" id="description" name="description" value="{{ row.description }}"> |
|
| 14 | + | <label for="default_bookmark">Default bookmark</label><input type="text" id="default_bookmark" name="default_bookmark" value="{{ row.default_bookmark }}"> |
|
| 15 | + | <label>Visibility{% if role != crate::auth::Role::Admin %} <span class="mute">(admins only)</span>{% endif %}</label> |
|
| 16 | + | <div class="choice"> |
|
| 17 | + | <label><input type="radio" name="visibility" value="public"{% if row.visibility == "public" %} checked{% endif %}> public</label> |
|
| 18 | + | <label><input type="radio" name="visibility" value="internal"{% if row.visibility == "internal" %} checked{% endif %}> internal</label> |
|
| 19 | + | <label><input type="radio" name="visibility" value="private"{% if row.visibility == "private" %} checked{% endif %}> private</label> |
|
| 20 | + | </div> |
|
| 21 | + | <button type="submit">SAVE</button> |
|
| 22 | + | </form> |
|
| 23 | + | {% if role == crate::auth::Role::Admin %} |
|
| 24 | + | <section class="panel"> |
|
| 25 | + | <h2>Collaborators</h2> |
|
| 26 | + | {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %} |
|
| 27 | + | <table class="small"> |
|
| 28 | + | {% for c in collaborators %} |
|
| 29 | + | <tr><td>{% if let Some(a) = c.account %}<a href="/{{ a.username }}">{{ a.username }}</a>{% else if let Some(t) = c.team %}team {{ t.name }}{% endif %}</td><td>{{ c.role }}</td> |
|
| 30 | + | <td><form class="tiny" method="post" action="{{ repo.url }}/settings/collaborators"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% if let Some(a) = c.account %}<input type="hidden" name="username" value="{{ a.username }}">{% else if let Some(t) = c.team %}<input type="hidden" name="team" value="{{ t.name }}">{% endif %}<button name="remove" value="1">remove</button></form></td></tr> |
|
| 31 | + | {% endfor %} |
|
| 32 | + | </table>{% endif %} |
|
| 33 | + | <form class="card" method="post" action="{{ repo.url }}/settings/collaborators"> |
|
| 34 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 35 | + | <div class="row"> |
|
| 36 | + | <div><label for="username">Account</label><input type="text" id="username" name="username"></div> |
|
| 37 | + | {% if !teams.is_empty() %}<div><label for="team">or team</label><select id="team" name="team"><option value="">—</option>{% for t in teams %}<option value="{{ t.name }}">{{ t.name }}</option>{% endfor %}</select></div>{% endif %} |
|
| 38 | + | <div><label for="role">Role</label><select id="role" name="role"><option value="read">read</option><option value="triage">triage</option><option value="write" selected>write</option><option value="maintain">maintain</option><option value="admin">admin</option></select></div> |
|
| 39 | + | </div> |
|
| 40 | + | <button type="submit">ADD</button> |
|
| 41 | + | </form> |
|
| 42 | + | </section> |
|
| 43 | + | <details class="danger"> |
|
| 44 | + | <summary>Delete this repository</summary> |
|
| 45 | + | <form class="card" method="post" action="{{ repo.url }}/settings/delete"> |
|
| 46 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 47 | + | <p class="small">The repository moves to the trash and is removed after the retention period. Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm.</p> |
|
| 48 | + | <input type="text" name="confirm" required> |
|
| 49 | + | <button type="submit">DELETE</button> |
|
| 50 | + | </form> |
|
| 51 | + | </details> |
|
| 52 | + | {% endif %} |
|
| 53 | + | {% endblock %} |
added templates/settings.html +23 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "settings_tabs.html" as t %} |
|
| 3 | + | {% block title %}Profile{% endblock %} |
|
| 4 | + | {% block tabs %}{% call t::tabs("profile") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Profile <small>{{ me.username }}</small></h1></div> |
|
| 7 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | + | <form class="card" method="post" action="/settings"> |
|
| 10 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 11 | + | <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name" value="{{ me.display_name }}"> |
|
| 12 | + | <label for="email">Email</label><input type="email" id="email" name="email" value="{{ me.email }}"> |
|
| 13 | + | <button type="submit">SAVE</button> |
|
| 14 | + | </form> |
|
| 15 | + | <form class="card" method="post" action="/settings/password"> |
|
| 16 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 17 | + | <h2>Change password</h2> |
|
| 18 | + | <label for="current">Current password</label><input type="password" id="current" name="current" autocomplete="current-password" required> |
|
| 19 | + | <label for="password">New password</label><input type="password" id="password" name="password" autocomplete="new-password" required minlength="10"> |
|
| 20 | + | <p class="small mute">Changing it signs out every other session.</p> |
|
| 21 | + | <button type="submit">CHANGE PASSWORD</button> |
|
| 22 | + | </form> |
|
| 23 | + | {% endblock %} |
added templates/settings_keys.html +36 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "settings_tabs.html" as t %} |
|
| 3 | + | {% block title %}Keys{% endblock %} |
|
| 4 | + | {% block tabs %}{% call t::tabs("keys") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Keys <small>{{ me.username }} · Oxygen identities linked to your account</small></h1></div> |
|
| 7 | + | <p class="lead">A change is yours on every page when it is signed by a key you have proven you hold. Export your identity with <code>oxy identity export</code>, upload the file, then sign the challenge the hub gives you.</p> |
|
| 8 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 9 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 10 | + | {% if let Some((id, bytes, name)) = pending %} |
|
| 11 | + | <form class="card wide" method="post" action="/settings/keys/verify"> |
|
| 12 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 13 | + | <input type="hidden" name="challenge" value="{{ id }}"> |
|
| 14 | + | <h2>Prove you hold the key of {{ name }}</h2> |
|
| 15 | + | <p>Run this in any checkout that uses that identity and paste the output:</p> |
|
| 16 | + | <p class="secret">oxy identity sign-challenge {{ bytes }}</p> |
|
| 17 | + | <label for="signature">Signature</label><textarea id="signature" name="signature" rows="4" required></textarea> |
|
| 18 | + | <button type="submit">LINK KEY</button> |
|
| 19 | + | </form> |
|
| 20 | + | {% endif %} |
|
| 21 | + | {% if keys.is_empty() %}<p class="empty">No keys linked yet.</p>{% else %} |
|
| 22 | + | <div class="table"><table> |
|
| 23 | + | <tr><th>Key</th><th>Identity</th><th>Linked</th><th></th></tr> |
|
| 24 | + | {% for k in keys %} |
|
| 25 | + | <tr><td><code>{{ k.key|short }}</code></td><td>{{ k.name }} <span class="mute">{{ k.email }}</span></td><td><time title="{{ k.linked_ms|when_i }}">{{ k.linked_ms|ago_i }}</time></td> |
|
| 26 | + | <td><form class="tiny" method="post" action="/settings/keys/unlink"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="key" value="{{ k.key }}"><button>unlink</button></form></td></tr> |
|
| 27 | + | {% endfor %} |
|
| 28 | + | </table></div> |
|
| 29 | + | {% endif %} |
|
| 30 | + | <form class="card" method="post" action="/settings/keys/upload" enctype="multipart/form-data"> |
|
| 31 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 32 | + | <h2>Link a key</h2> |
|
| 33 | + | <label for="identity">Identity file (from <code>oxy identity export</code>)</label><input type="file" id="identity" name="identity" required> |
|
| 34 | + | <button type="submit">UPLOAD</button> |
|
| 35 | + | </form> |
|
| 36 | + | {% endblock %} |
added templates/settings_security.html +43 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "settings_tabs.html" as t %} |
|
| 3 | + | {% block title %}Security{% endblock %} |
|
| 4 | + | {% block tabs %}{% call t::tabs("security") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Security <small>two-factor authentication and sessions</small></h1></div> |
|
| 7 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | + | {% if !recovery.is_empty() %} |
|
| 10 | + | <div class="panel"><h2>Recovery codes</h2><ul class="codes">{% for c in recovery %}<li>{{ c }}</li>{% endfor %}</ul></div> |
|
| 11 | + | {% endif %} |
|
| 12 | + | {% if let Some((secret, uri, qr)) = enrol %} |
|
| 13 | + | <form class="card" method="post" action="/settings/security/totp/enable"> |
|
| 14 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 15 | + | <input type="hidden" name="secret" value="{{ secret }}"> |
|
| 16 | + | <h2>Scan, then enter the code</h2> |
|
| 17 | + | <div class="qr">{{ qr|safe }}</div> |
|
| 18 | + | <p class="small">Or enter the secret by hand: <code>{{ secret }}</code></p> |
|
| 19 | + | <label for="code">Code from the app</label><input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus required> |
|
| 20 | + | <button type="submit">TURN ON</button> |
|
| 21 | + | </form> |
|
| 22 | + | {% else if me.totp_enabled() %} |
|
| 23 | + | <form class="card" method="post" action="/settings/security/totp/disable"> |
|
| 24 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 25 | + | <h2><span class="sig sig-valid">✔</span> Two-factor authentication is on</h2> |
|
| 26 | + | <label for="password">Your password, to turn it off</label><input type="password" id="password" name="password" autocomplete="current-password" required> |
|
| 27 | + | <button type="submit">TURN OFF</button> |
|
| 28 | + | </form> |
|
| 29 | + | {% else %} |
|
| 30 | + | <form class="card" method="post" action="/settings/security/totp/begin"> |
|
| 31 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 32 | + | <h2><span class="sig sig-unsigned">○</span> Two-factor authentication is off</h2> |
|
| 33 | + | <p class="small">A time-based code from an authenticator app is asked for at every sign in.</p> |
|
| 34 | + | <button type="submit">SET UP</button> |
|
| 35 | + | </form> |
|
| 36 | + | {% endif %} |
|
| 37 | + | <form class="card" method="post" action="/settings/security/sessions"> |
|
| 38 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 39 | + | <h2>Sessions</h2> |
|
| 40 | + | <p class="small">Sign out every browser except this one.</p> |
|
| 41 | + | <button type="submit">SIGN OUT EVERYWHERE</button> |
|
| 42 | + | </form> |
|
| 43 | + | {% endblock %} |
added templates/settings_tabs.html +10 −0
| 1 | + | {% macro tabs(on) %} |
|
| 2 | + | <div class="tabs"><div class="tabs-in"> |
|
| 3 | + | <span class="crumb"><strong>Settings</strong></span> |
|
| 4 | + | <a href="/settings"{% if on == "profile" %} class="on"{% endif %}>Profile</a> |
|
| 5 | + | <a href="/settings/security"{% if on == "security" %} class="on"{% endif %}>Security</a> |
|
| 6 | + | <a href="/settings/keys"{% if on == "keys" %} class="on"{% endif %}>Keys</a> |
|
| 7 | + | <a href="/settings/tokens"{% if on == "tokens" %} class="on"{% endif %}>Tokens</a> |
|
| 8 | + | <a href="/orgs/new">New organisation</a> |
|
| 9 | + | </div></div> |
|
| 10 | + | {% endmacro %} |
added templates/settings_tokens.html +40 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "settings_tabs.html" as t %} |
|
| 3 | + | {% block title %}Tokens{% endblock %} |
|
| 4 | + | {% block tabs %}{% call t::tabs("tokens") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Personal access tokens <small>{{ me.username }} · for oxy and the API</small></h1></div> |
|
| 7 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | + | {% if let Some(f) = fresh %} |
|
| 10 | + | <div class="panel"><h2>Your new token</h2><p class="secret">{{ f }}</p> |
|
| 11 | + | <p class="small">In a checkout: <code>oxy config remote.<name>.token {{ f }}</code>, or set <code>OXY_TOKEN</code>.</p></div> |
|
| 12 | + | {% endif %} |
|
| 13 | + | {% if tokens.is_empty() %}<p class="empty">No tokens yet.</p>{% else %} |
|
| 14 | + | <div class="table"><table> |
|
| 15 | + | <tr><th>Name</th><th>Scopes</th><th>Repositories</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr> |
|
| 16 | + | {% for t in tokens %} |
|
| 17 | + | <tr><td>{{ t.name }}</td><td>{{ t.scopes }}</td><td>{% if t.repos.is_empty() %}<span class="mute">all</span>{% else %}{{ t.repos }}{% endif %}</td> |
|
| 18 | + | <td><time title="{{ t.created_ms|when_i }}">{{ t.created_ms|ago_i }}</time></td> |
|
| 19 | + | <td>{% if let Some(u) = t.last_used_ms %}<time title="{{ u|when_i }}">{{ u|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 20 | + | <td>{% if let Some(x) = t.expires_ms %}{{ x|when_i }}{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 21 | + | <td><form class="tiny" method="post" action="/settings/tokens/{{ t.id }}/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>delete</button></form></td></tr> |
|
| 22 | + | {% endfor %} |
|
| 23 | + | </table></div> |
|
| 24 | + | {% endif %} |
|
| 25 | + | <form class="card wide" method="post" action="/settings/tokens"> |
|
| 26 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 27 | + | <h2>New token</h2> |
|
| 28 | + | <label for="name">Name</label><input type="text" id="name" name="name" required placeholder="laptop, CI, …"> |
|
| 29 | + | <label>Scopes</label> |
|
| 30 | + | <div class="choice"> |
|
| 31 | + | <label><input type="checkbox" name="scopes" value="repo:read" checked> repo:read</label> |
|
| 32 | + | <label><input type="checkbox" name="scopes" value="repo:write" checked> repo:write</label> |
|
| 33 | + | <label><input type="checkbox" name="scopes" value="repo:admin"> repo:admin</label> |
|
| 34 | + | <label><input type="checkbox" name="scopes" value="user:read"> user:read</label> |
|
| 35 | + | </div> |
|
| 36 | + | <label for="repos">Only these repositories (comma-separated owner/name; empty for all)</label><input type="text" id="repos" name="repos"> |
|
| 37 | + | <label for="expires_days">Expires after (days; empty for never)</label><input type="number" id="expires_days" name="expires_days" min="1"> |
|
| 38 | + | <button type="submit">CREATE TOKEN</button> |
|
| 39 | + | </form> |
|
| 40 | + | {% endblock %} |
modified Cargo.lock +174 −0
| … | 100 unchanged lines |
||
| 101 | 101 | ] |
|
| 102 | 102 | ||
| 103 | 103 | [[package]] |
|
| 104 | + | name = "argon2" |
|
| 105 | + | version = "0.5.3" |
|
| 106 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 107 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" |
|
| 108 | + | dependencies = [ |
|
| 109 | + | "base64ct", |
|
| 110 | + | "blake2", |
|
| 111 | + | "cpufeatures 0.2.17", |
|
| 112 | + | "password-hash", |
|
| 113 | + | ] |
|
| 114 | + | ||
| 115 | + | [[package]] |
|
| 104 | 116 | name = "arrayvec" |
|
| 105 | 117 | version = "0.7.8" |
|
| 106 | 118 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 133 unchanged lines |
||
| 240 | 252 | "matchit", |
|
| 241 | 253 | "memchr", |
|
| 242 | 254 | "mime", |
|
| 255 | + | "multer", |
|
| 243 | 256 | "percent-encoding", |
|
| 244 | 257 | "pin-project-lite", |
|
| 245 | 258 | "serde_core", |
|
| … | 28 unchanged lines |
||
| 274 | 287 | ] |
|
| 275 | 288 | ||
| 276 | 289 | [[package]] |
|
| 290 | + | name = "axum-extra" |
|
| 291 | + | version = "0.10.3" |
|
| 292 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 293 | + | checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" |
|
| 294 | + | dependencies = [ |
|
| 295 | + | "axum", |
|
| 296 | + | "axum-core", |
|
| 297 | + | "bytes", |
|
| 298 | + | "cookie", |
|
| 299 | + | "form_urlencoded", |
|
| 300 | + | "futures-util", |
|
| 301 | + | "http", |
|
| 302 | + | "http-body", |
|
| 303 | + | "http-body-util", |
|
| 304 | + | "mime", |
|
| 305 | + | "pin-project-lite", |
|
| 306 | + | "rustversion", |
|
| 307 | + | "serde_core", |
|
| 308 | + | "serde_html_form", |
|
| 309 | + | "serde_path_to_error", |
|
| 310 | + | "tower-layer", |
|
| 311 | + | "tower-service", |
|
| 312 | + | "tracing", |
|
| 313 | + | ] |
|
| 314 | + | ||
| 315 | + | [[package]] |
|
| 277 | 316 | name = "axum-macros" |
|
| 278 | 317 | version = "0.5.1" |
|
| 279 | 318 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 27 unchanged lines |
||
| 307 | 346 | ] |
|
| 308 | 347 | ||
| 309 | 348 | [[package]] |
|
| 349 | + | name = "base32" |
|
| 350 | + | version = "0.5.1" |
|
| 351 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 352 | + | checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" |
|
| 353 | + | ||
| 354 | + | [[package]] |
|
| 310 | 355 | name = "base64" |
|
| 311 | 356 | version = "0.22.1" |
|
| 312 | 357 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 63 unchanged lines |
||
| 376 | 421 | ] |
|
| 377 | 422 | ||
| 378 | 423 | [[package]] |
|
| 424 | + | name = "blake2" |
|
| 425 | + | version = "0.10.6" |
|
| 426 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 427 | + | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" |
|
| 428 | + | dependencies = [ |
|
| 429 | + | "digest 0.10.7", |
|
| 430 | + | ] |
|
| 431 | + | ||
| 432 | + | [[package]] |
|
| 379 | 433 | name = "blake3" |
|
| 380 | 434 | version = "1.8.7" |
|
| 381 | 435 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 175 unchanged lines |
||
| 557 | 611 | checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" |
|
| 558 | 612 | ||
| 559 | 613 | [[package]] |
|
| 614 | + | name = "cookie" |
|
| 615 | + | version = "0.18.2" |
|
| 616 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 617 | + | checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" |
|
| 618 | + | dependencies = [ |
|
| 619 | + | "percent-encoding", |
|
| 620 | + | "time", |
|
| 621 | + | "version_check", |
|
| 622 | + | ] |
|
| 623 | + | ||
| 624 | + | [[package]] |
|
| 625 | + | name = "core_detect" |
|
| 626 | + | version = "1.0.0" |
|
| 627 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 628 | + | checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" |
|
| 629 | + | ||
| 630 | + | [[package]] |
|
| 560 | 631 | name = "cpufeatures" |
|
| 561 | 632 | version = "0.2.17" |
|
| 562 | 633 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 270 unchanged lines |
||
| 833 | 904 | ] |
|
| 834 | 905 | ||
| 835 | 906 | [[package]] |
|
| 907 | + | name = "encoding_rs" |
|
| 908 | + | version = "0.8.41" |
|
| 909 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 910 | + | checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" |
|
| 911 | + | dependencies = [ |
|
| 912 | + | "cfg-if", |
|
| 913 | + | "core_detect", |
|
| 914 | + | "multiversion", |
|
| 915 | + | "multiversion_no_op", |
|
| 916 | + | "rustversion", |
|
| 917 | + | "scopeguard", |
|
| 918 | + | "simdutf8", |
|
| 919 | + | ] |
|
| 920 | + | ||
| 921 | + | [[package]] |
|
| 836 | 922 | name = "equivalent" |
|
| 837 | 923 | version = "1.0.2" |
|
| 838 | 924 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 823 unchanged lines |
||
| 1662 | 1748 | ] |
|
| 1663 | 1749 | ||
| 1664 | 1750 | [[package]] |
|
| 1751 | + | name = "multer" |
|
| 1752 | + | version = "3.1.0" |
|
| 1753 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1754 | + | checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" |
|
| 1755 | + | dependencies = [ |
|
| 1756 | + | "bytes", |
|
| 1757 | + | "encoding_rs", |
|
| 1758 | + | "futures-util", |
|
| 1759 | + | "http", |
|
| 1760 | + | "httparse", |
|
| 1761 | + | "memchr", |
|
| 1762 | + | "mime", |
|
| 1763 | + | "spin", |
|
| 1764 | + | "version_check", |
|
| 1765 | + | ] |
|
| 1766 | + | ||
| 1767 | + | [[package]] |
|
| 1768 | + | name = "multiversion" |
|
| 1769 | + | version = "0.9.0" |
|
| 1770 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1771 | + | checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" |
|
| 1772 | + | dependencies = [ |
|
| 1773 | + | "multiversion-macros", |
|
| 1774 | + | ] |
|
| 1775 | + | ||
| 1776 | + | [[package]] |
|
| 1777 | + | name = "multiversion-macros" |
|
| 1778 | + | version = "0.9.0" |
|
| 1779 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1780 | + | checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" |
|
| 1781 | + | dependencies = [ |
|
| 1782 | + | "proc-macro2", |
|
| 1783 | + | "quote", |
|
| 1784 | + | "rustversion", |
|
| 1785 | + | "syn 3.0.6", |
|
| 1786 | + | ] |
|
| 1787 | + | ||
| 1788 | + | [[package]] |
|
| 1789 | + | name = "multiversion_no_op" |
|
| 1790 | + | version = "1.0.0" |
|
| 1791 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1792 | + | checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" |
|
| 1793 | + | ||
| 1794 | + | [[package]] |
|
| 1665 | 1795 | name = "new_debug_unreachable" |
|
| 1666 | 1796 | version = "1.0.6" |
|
| 1667 | 1797 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 137 unchanged lines |
||
| 1805 | 1935 | dependencies = [ |
|
| 1806 | 1936 | "ammonia", |
|
| 1807 | 1937 | "anyhow", |
|
| 1938 | + | "argon2", |
|
| 1808 | 1939 | "askama", |
|
| 1809 | 1940 | "axum", |
|
| 1941 | + | "axum-extra", |
|
| 1810 | 1942 | "axum-server", |
|
| 1943 | + | "base32", |
|
| 1944 | + | "blake3", |
|
| 1811 | 1945 | "clap", |
|
| 1812 | 1946 | "hex", |
|
| 1947 | + | "hmac", |
|
| 1813 | 1948 | "http-body-util", |
|
| 1814 | 1949 | "hub-api", |
|
| 1815 | 1950 | "oxy-core", |
|
| 1816 | 1951 | "oxy-server", |
|
| 1817 | 1952 | "pulldown-cmark", |
|
| 1953 | + | "qrcode", |
|
| 1818 | 1954 | "rand 0.8.8", |
|
| 1819 | 1955 | "rcgen", |
|
| 1820 | 1956 | "reqwest", |
|
| 1821 | 1957 | "rustls", |
|
| 1822 | 1958 | "serde", |
|
| 1823 | 1959 | "serde_json", |
|
| 1960 | + | "sha1", |
|
| 1824 | 1961 | "similar", |
|
| 1825 | 1962 | "sqlx", |
|
| 1963 | + | "subtle", |
|
| 1826 | 1964 | "syntect", |
|
| 1827 | 1965 | "tempfile", |
|
| 1828 | 1966 | "thiserror 2.0.21", |
|
| … | 36 unchanged lines |
||
| 1865 | 2003 | ] |
|
| 1866 | 2004 | ||
| 1867 | 2005 | [[package]] |
|
| 2006 | + | name = "password-hash" |
|
| 2007 | + | version = "0.5.0" |
|
| 2008 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2009 | + | checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" |
|
| 2010 | + | dependencies = [ |
|
| 2011 | + | "base64ct", |
|
| 2012 | + | "rand_core 0.6.4", |
|
| 2013 | + | "subtle", |
|
| 2014 | + | ] |
|
| 2015 | + | ||
| 2016 | + | [[package]] |
|
| 1868 | 2017 | name = "pem" |
|
| 1869 | 2018 | version = "4.0.0" |
|
| 1870 | 2019 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 177 unchanged lines |
||
| 2048 | 2197 | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" |
|
| 2049 | 2198 | ||
| 2050 | 2199 | [[package]] |
|
| 2200 | + | name = "qrcode" |
|
| 2201 | + | version = "0.14.1" |
|
| 2202 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2203 | + | checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" |
|
| 2204 | + | ||
| 2205 | + | [[package]] |
|
| 2051 | 2206 | name = "quick-xml" |
|
| 2052 | 2207 | version = "0.42.0" |
|
| 2053 | 2208 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 396 unchanged lines |
||
| 2450 | 2605 | ] |
|
| 2451 | 2606 | ||
| 2452 | 2607 | [[package]] |
|
| 2608 | + | name = "serde_html_form" |
|
| 2609 | + | version = "0.2.8" |
|
| 2610 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2611 | + | checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" |
|
| 2612 | + | dependencies = [ |
|
| 2613 | + | "form_urlencoded", |
|
| 2614 | + | "indexmap", |
|
| 2615 | + | "itoa", |
|
| 2616 | + | "ryu", |
|
| 2617 | + | "serde_core", |
|
| 2618 | + | ] |
|
| 2619 | + | ||
| 2620 | + | [[package]] |
|
| 2453 | 2621 | name = "serde_json" |
|
| 2454 | 2622 | version = "1.0.151" |
|
| 2455 | 2623 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 134 unchanged lines |
||
| 2590 | 2758 | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" |
|
| 2591 | 2759 | ||
| 2592 | 2760 | [[package]] |
|
| 2761 | + | name = "simdutf8" |
|
| 2762 | + | version = "0.1.5" |
|
| 2763 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2764 | + | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" |
|
| 2765 | + | ||
| 2766 | + | [[package]] |
|
| 2593 | 2767 | name = "similar" |
|
| 2594 | 2768 | version = "2.7.0" |
|
| 2595 | 2769 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 1327 unchanged lines |
modified Cargo.toml +9 −1
| … | 18 unchanged lines |
||
| 19 | 19 | serde = { version = "1", features = ["derive"] } |
|
| 20 | 20 | serde_json = "1" |
|
| 21 | 21 | tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal", "fs"] } |
|
| 22 | - | axum = { version = "0.8", features = ["macros", "form", "query"] } |
|
| 22 | + | axum = { version = "0.8", features = ["macros", "form", "query", "multipart"] } |
|
| 23 | 23 | axum-server = { version = "0.8", default-features = false, features = ["tls-rustls-no-provider"] } |
|
| 24 | 24 | rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } |
|
| 25 | 25 | tower-http = { version = "0.6", features = ["trace", "compression-gzip", "set-header", "limit"] } |
|
| … | 11 unchanged lines |
||
| 37 | 37 | hex = "0.4" |
|
| 38 | 38 | rand = "0.8" |
|
| 39 | 39 | similar = "2" |
|
| 40 | + | argon2 = { version = "0.5", features = ["std"] } |
|
| 41 | + | axum-extra = { version = "0.10", features = ["cookie", "form"] } |
|
| 42 | + | hmac = "0.12" |
|
| 43 | + | sha1 = "0.10" |
|
| 44 | + | base32 = "0.5" |
|
| 45 | + | qrcode = { version = "0.14", default-features = false, features = ["svg"] } |
|
| 46 | + | subtle = "2" |
|
| 47 | + | blake3 = "1" |
|
| 40 | 48 | tempfile = "3" |
|
| 41 | 49 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } |
|
| 42 | 50 | ||
| … | 2 unchanged lines |
modified README.md +11 −8
| … | 8 unchanged lines |
||
| 9 | 9 | ||
| 10 | 10 | ## Status |
|
| 11 | 11 | ||
| 12 | - | Milestone 0.1 (browse): repositories are created by an administrator and |
|
| 13 | - | filled by `oxy push`; the pages show trees, files, blame, history with the |
|
| 14 | - | dependency graph, changes with diffs, comparisons, bookmarks, tags, |
|
| 15 | - | conflicts, the trust policy and the push log. Accounts, proposals, issues |
|
| 16 | - | and the rest follow the milestones in the specification. |
|
| 12 | + | Milestones 0.1 (browse) and 0.2 (accounts and access) are done: pages show |
|
| 13 | + | trees, files, blame, history with the dependency graph, changes with diffs, |
|
| 14 | + | comparisons, bookmarks, tags, conflicts, the trust policy and the push log; |
|
| 15 | + | accounts sign in with a password and optional TOTP, create repositories and |
|
| 16 | + | organisations, hold roles, mint scoped tokens for `oxy`, and link their |
|
| 17 | + | Oxygen keys so signed changes are attributed to them. Proposals, issues and |
|
| 18 | + | the rest follow the milestones in the specification. |
|
| 17 | 19 | ||
| 18 | 20 | ## Running |
|
| 19 | 21 | ||
| 20 | 22 | ``` |
|
| 21 | 23 | cargo build --release |
|
| 22 | 24 | target/release/oxygenhub --data /srv/oxygenhub init --base-url https://hub.example:7343 --self-signed |
|
| 23 | - | target/release/oxygenhub --data /srv/oxygenhub admin repo create andy/oxygen --description "The VCS" |
|
| 25 | + | target/release/oxygenhub --data /srv/oxygenhub admin user create andy --admin |
|
| 24 | 26 | target/release/oxygenhub --data /srv/oxygenhub serve |
|
| 25 | 27 | ``` |
|
| 26 | 28 | ||
| 27 | - | Then from any checkout: `oxy remote add hub https://hub.example:7343/andy/oxygen` |
|
| 28 | - | and `oxy push hub`. With a self-signed certificate, hand clients the CA with |
|
| 29 | + | Sign in, create a repository under `+ NEW` and a token under Settings › |
|
| 30 | + | Tokens, then from any checkout: `oxy remote add hub https://hub.example:7343/andy/oxygen`, |
|
| 31 | + | `oxy config remote.hub.token <token>` and `oxy push hub`. With a self-signed certificate, hand clients the CA with |
|
| 29 | 32 | `OXY_CA=<path>` or `oxy config remote.hub.ca <path>`. |
|
| 30 | 33 | ||
| 31 | 34 | Configuration lives in `<data>/hub.toml` (spec §17.1) and every key can be |
|
| … | 19 unchanged lines |
modified crates/hub/Cargo.toml +8 −0
| … | 37 unchanged lines |
||
| 38 | 38 | hex.workspace = true |
|
| 39 | 39 | rand.workspace = true |
|
| 40 | 40 | similar.workspace = true |
|
| 41 | + | argon2.workspace = true |
|
| 42 | + | axum-extra.workspace = true |
|
| 43 | + | hmac.workspace = true |
|
| 44 | + | sha1.workspace = true |
|
| 45 | + | base32.workspace = true |
|
| 46 | + | qrcode.workspace = true |
|
| 47 | + | subtle.workspace = true |
|
| 48 | + | blake3.workspace = true |
|
| 41 | 49 | ||
| 42 | 50 | [dev-dependencies] |
|
| 43 | 51 | tempfile.workspace = true |
|
| … | 3 unchanged lines |
modified crates/hub/src/config.rs +18 −0
| … | 14 unchanged lines |
||
| 15 | 15 | pub server: Server, |
|
| 16 | 16 | pub tls: Tls, |
|
| 17 | 17 | pub auth: Auth, |
|
| 18 | + | pub registration: Registration, |
|
| 18 | 19 | pub limits: Limits, |
|
| 19 | 20 | pub retention: Retention, |
|
| 20 | 21 | } |
|
| 21 | 22 | ||
| 22 | 23 | #[derive(Clone, Debug, Deserialize)] |
|
| 23 | 24 | #[serde(default, deny_unknown_fields)] |
|
| 25 | + | pub struct Registration { |
|
| 26 | + | /// `open`, `invite` or `closed`; the admin page can override it. |
|
| 27 | + | pub mode: String, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | impl Default for Registration { |
|
| 31 | + | fn default() -> Self { |
|
| 32 | + | Registration { mode: "invite".into() } |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 37 | + | #[serde(default, deny_unknown_fields)] |
|
| 24 | 38 | pub struct Site { |
|
| 25 | 39 | pub name: String, |
|
| 26 | 40 | pub description: String, |
|
| … | 123 unchanged lines |
||
| 150 | 164 | server: Server::default(), |
|
| 151 | 165 | tls: Tls::default(), |
|
| 152 | 166 | auth: Auth::default(), |
|
| 167 | + | registration: Registration::default(), |
|
| 153 | 168 | limits: Limits::default(), |
|
| 154 | 169 | retention: Retention::default(), |
|
| 155 | 170 | } |
|
| … | 41 unchanged lines |
||
| 197 | 212 | if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") { |
|
| 198 | 213 | self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes"); |
|
| 199 | 214 | } |
|
| 215 | + | if let Some(v) = get("OXYHUB_REGISTRATION_MODE") { |
|
| 216 | + | self.registration.mode = v; |
|
| 217 | + | } |
|
| 200 | 218 | if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") { |
|
| 201 | 219 | self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); |
|
| 202 | 220 | } |
|
| … | 75 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| 1 | 1 | //! SQLite through `sqlx` (hub spec §3.1, §17.2): WAL, foreign keys on, |
|
| 2 | 2 | //! migrations embedded and applied at start. One module per table group. |
|
| 3 | 3 | ||
| 4 | + | pub mod accounts; |
|
| 4 | 5 | pub mod repos; |
|
| 5 | 6 | ||
| 6 | 7 | use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; |
|
| … | 23 unchanged lines |
modified crates/hub/src/db/repos.rs +10 −3
| … | 36 unchanged lines |
||
| 37 | 37 | pub new_state: String, |
|
| 38 | 38 | pub heads: i64, |
|
| 39 | 39 | pub changes: i64, |
|
| 40 | + | pub pusher: String, |
|
| 40 | 41 | } |
|
| 41 | 42 | ||
| 42 | 43 | /// One reference's outcome in a push, as the protocol backend reports it. |
|
| … | 12 unchanged lines |
||
| 55 | 56 | sqlx::query_as("SELECT * FROM repositories ORDER BY updated_ms DESC, owner, name").fetch_all(pool).await |
|
| 56 | 57 | } |
|
| 57 | 58 | ||
| 59 | + | pub async fn get_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<RepoRow>> { |
|
| 60 | + | sqlx::query_as("SELECT * FROM repositories WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 61 | + | } |
|
| 62 | + | ||
| 58 | 63 | pub async fn list_by_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<RepoRow>> { |
|
| 59 | 64 | sqlx::query_as("SELECT * FROM repositories WHERE owner = ? ORDER BY updated_ms DESC, name").bind(owner).fetch_all(pool).await |
|
| 60 | 65 | } |
|
| … | 38 unchanged lines |
||
| 99 | 104 | } |
|
| 100 | 105 | ||
| 101 | 106 | /// Record a push's per-reference results and bump the repository's activity. |
|
| 102 | - | pub async fn record_push(pool: &Pool, id: i64, results: &[PushOutcome]) -> sqlx::Result<()> { |
|
| 107 | + | pub async fn record_push(pool: &Pool, id: i64, pusher: Option<(i64, &str)>, results: &[PushOutcome]) -> sqlx::Result<()> { |
|
| 103 | 108 | let now = now_ms(); |
|
| 104 | 109 | let mut tx = pool.begin().await?; |
|
| 105 | 110 | for r in results { |
|
| 106 | - | sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, old_state, new_state, heads, changes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") |
|
| 111 | + | sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, account_id, pusher) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") |
|
| 107 | 112 | .bind(id) |
|
| 108 | 113 | .bind(now) |
|
| 109 | 114 | .bind(&r.reference) |
|
| … | 3 unchanged lines |
||
| 113 | 118 | .bind(&r.new_state) |
|
| 114 | 119 | .bind(r.heads) |
|
| 115 | 120 | .bind(r.changes) |
|
| 121 | + | .bind(pusher.map(|p| p.0).filter(|id| *id > 0)) |
|
| 122 | + | .bind(pusher.map(|p| p.1).unwrap_or("")) |
|
| 116 | 123 | .execute(&mut *tx) |
|
| 117 | 124 | .await?; |
|
| 118 | 125 | } |
|
| … | 4 unchanged lines |
||
| 123 | 130 | } |
|
| 124 | 131 | ||
| 125 | 132 | pub async fn pushes(pool: &Pool, id: i64, limit: i64) -> sqlx::Result<Vec<PushRow>> { |
|
| 126 | - | sqlx::query_as("SELECT id, at_ms, reference, ok, reason, old_state, new_state, heads, changes FROM pushes WHERE repo_id = ? ORDER BY at_ms DESC, id DESC LIMIT ?") |
|
| 133 | + | sqlx::query_as("SELECT id, at_ms, reference, ok, reason, old_state, new_state, heads, changes, pusher FROM pushes WHERE repo_id = ? ORDER BY at_ms DESC, id DESC LIMIT ?") |
|
| 127 | 134 | .bind(id) |
|
| 128 | 135 | .bind(limit) |
|
| 129 | 136 | .fetch_all(pool) |
|
| … | 2 unchanged lines |
modified crates/hub/src/lib.rs +4 −1
| … | 3 unchanged lines |
||
| 4 | 4 | //! protocol; SQLite for hub state; repositories as bare Oxygen stores. |
|
| 5 | 5 | ||
| 6 | 6 | pub mod api; |
|
| 7 | + | pub mod auth; |
|
| 7 | 8 | pub mod config; |
|
| 8 | 9 | pub mod db; |
|
| 9 | 10 | pub mod keys; |
|
| … | 13 unchanged lines |
||
| 23 | 24 | pub repos: Arc<Repos>, |
|
| 24 | 25 | pub server: Arc<oxy_server::Server>, |
|
| 25 | 26 | pub hub_key: keys::HubKey, |
|
| 27 | + | pub cookie_key: auth::CookieKey, |
|
| 26 | 28 | pub started: std::time::Instant, |
|
| 27 | 29 | } |
|
| 28 | 30 | ||
| … | 6 unchanged lines |
||
| 35 | 37 | let db = db::open(&config.db_path()).await?; |
|
| 36 | 38 | let repos = Arc::new(Repos::new(config.repos_dir())); |
|
| 37 | 39 | let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?; |
|
| 40 | + | let cookie_key = auth::CookieKey::load_or_create(&config.keys_dir())?; |
|
| 38 | 41 | let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current()); |
|
| 39 | 42 | let server = oxy_server::Server::new(backend); |
|
| 40 | - | Ok(Arc::new(App { config, db, repos, server, hub_key, started: std::time::Instant::now() })) |
|
| 43 | + | Ok(Arc::new(App { config, db, repos, server, hub_key, cookie_key, started: std::time::Instant::now() })) |
|
| 41 | 44 | } |
|
| 42 | 45 | ||
| 43 | 46 | /// The full router: pages, API, protocol and static assets. |
|
| … | 33 unchanged lines |
modified crates/hub/src/main.rs +69 −0
| … | 45 unchanged lines |
||
| 46 | 46 | #[command(subcommand)] |
|
| 47 | 47 | cmd: RepoCmd, |
|
| 48 | 48 | }, |
|
| 49 | + | /// Accounts |
|
| 50 | + | User { |
|
| 51 | + | #[command(subcommand)] |
|
| 52 | + | cmd: UserCmd, |
|
| 53 | + | }, |
|
| 49 | 54 | } |
|
| 50 | 55 | ||
| 51 | 56 | #[derive(Subcommand)] |
|
| 57 | + | enum UserCmd { |
|
| 58 | + | /// Create an account with a generated password, printed once |
|
| 59 | + | Create { |
|
| 60 | + | username: String, |
|
| 61 | + | #[arg(long, default_value = "")] |
|
| 62 | + | email: String, |
|
| 63 | + | /// Make it a site administrator |
|
| 64 | + | #[arg(long)] |
|
| 65 | + | admin: bool, |
|
| 66 | + | }, |
|
| 67 | + | /// Reset an account's password to a new generated one, printed once |
|
| 68 | + | ResetPassword { username: String }, |
|
| 69 | + | /// List accounts |
|
| 70 | + | List, |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | #[derive(Subcommand)] |
|
| 52 | 74 | enum RepoCmd { |
|
| 53 | 75 | /// Create an empty repository OWNER/NAME |
|
| 54 | 76 | Create { |
|
| … | 41 unchanged lines |
||
| 96 | 118 | let app = oxygenhub::build(config).await?; |
|
| 97 | 119 | repo_admin(&app, cmd).await |
|
| 98 | 120 | } |
|
| 121 | + | Cmd::Admin { cmd: AdminCmd::User { cmd } } => { |
|
| 122 | + | let config = Config::load(&cli.data)?; |
|
| 123 | + | let app = oxygenhub::build(config).await?; |
|
| 124 | + | user_admin(&app, cmd).await |
|
| 125 | + | } |
|
| 99 | 126 | Cmd::Check => { |
|
| 100 | 127 | let config = Config::load(&cli.data)?; |
|
| 101 | 128 | let app = oxygenhub::build(config).await?; |
|
| … | 80 unchanged lines |
||
| 182 | 209 | Ok(()) |
|
| 183 | 210 | } |
|
| 184 | 211 | ||
| 212 | + | async fn user_admin(app: &oxygenhub::Shared, cmd: UserCmd) -> anyhow::Result<()> { |
|
| 213 | + | use oxygenhub::auth; |
|
| 214 | + | use oxygenhub::db::accounts; |
|
| 215 | + | match cmd { |
|
| 216 | + | UserCmd::Create { username, email, admin } => { |
|
| 217 | + | if !oxygenhub::repos::names::valid_owner(&username) { |
|
| 218 | + | anyhow::bail!("{username:?} is not a valid username"); |
|
| 219 | + | } |
|
| 220 | + | if accounts::account_by_name(&app.db, &username).await?.is_some() || accounts::org_by_name(&app.db, &username).await?.is_some() |
|
| 221 | + | { |
|
| 222 | + | anyhow::bail!("{username} is taken"); |
|
| 223 | + | } |
|
| 224 | + | let password = auth::random_hex(12); |
|
| 225 | + | let hash = auth::hash_password(&password)?; |
|
| 226 | + | accounts::create_account(&app.db, &username, &email, &hash, &auth::random_hex(16), admin).await?; |
|
| 227 | + | accounts::audit(&app.db, None, "admin.user.create", &username, if admin { "administrator" } else { "" }).await?; |
|
| 228 | + | println!("created {username}{}; one-time password: {password}", if admin { " (administrator)" } else { "" }); |
|
| 229 | + | println!("sign in at {}/login and change it under Settings", app.config.base_url()); |
|
| 230 | + | } |
|
| 231 | + | UserCmd::ResetPassword { username } => { |
|
| 232 | + | let account = accounts::account_by_name(&app.db, &username).await?.ok_or_else(|| anyhow::anyhow!("no such account"))?; |
|
| 233 | + | let password = auth::random_hex(12); |
|
| 234 | + | let hash = auth::hash_password(&password)?; |
|
| 235 | + | accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await?; |
|
| 236 | + | accounts::audit(&app.db, None, "admin.user.reset_password", &username, "").await?; |
|
| 237 | + | println!("new one-time password for {username}: {password}"); |
|
| 238 | + | } |
|
| 239 | + | UserCmd::List => { |
|
| 240 | + | for a in accounts::accounts(&app.db).await? { |
|
| 241 | + | println!( |
|
| 242 | + | "{:<24} {:<32} {}{}", |
|
| 243 | + | a.username, |
|
| 244 | + | a.email, |
|
| 245 | + | if a.is_admin { "admin " } else { "" }, |
|
| 246 | + | if a.banned { "banned" } else { "" } |
|
| 247 | + | ); |
|
| 248 | + | } |
|
| 249 | + | } |
|
| 250 | + | } |
|
| 251 | + | Ok(()) |
|
| 252 | + | } |
|
| 253 | + | ||
| 185 | 254 | async fn check(app: &oxygenhub::Shared) -> anyhow::Result<()> { |
|
| 186 | 255 | let mut problems = 0; |
|
| 187 | 256 | for r in db::repos::list(&app.db).await? { |
|
| … | 26 unchanged lines |
modified crates/hub/src/protocol.rs +89 −26
| 1 | 1 | //! The wire protocol, embedded (hub spec §3.3, §5.4): `oxy-server`'s |
|
| 2 | - | //! `Server` over a backend that resolves `owner/name`, applies visibility |
|
| 3 | - | //! and push tokens, and records pushes. |
|
| 2 | + | //! `Server` over a backend that resolves `owner/name`, authorises reads |
|
| 3 | + | //! and pushes by account role and token scope, and records who pushed. |
|
| 4 | 4 | ||
| 5 | + | use crate::auth::{self, Access, Role}; |
|
| 6 | + | use crate::db::{self, accounts::Account}; |
|
| 5 | 7 | use crate::repos::{RepoName, Repos}; |
|
| 6 | - | use crate::{db, Shared}; |
|
| 8 | + | use crate::Shared; |
|
| 7 | 9 | use axum::body::Bytes; |
|
| 8 | 10 | use axum::extract::{Path, State}; |
|
| 9 | 11 | use axum::http::{HeaderMap, Method, StatusCode}; |
|
| 10 | 12 | use axum::response::{IntoResponse, Response}; |
|
| 11 | - | use hub_api::Visibility; |
|
| 12 | 13 | use oxy_core::repo::{PushReport, Repo}; |
|
| 13 | - | use oxy_server::{Access, Backend, Endpoint, Failure}; |
|
| 14 | + | use oxy_server::{Backend, Endpoint, Failure}; |
|
| 14 | 15 | use std::sync::Arc; |
|
| 15 | 16 | ||
| 16 | 17 | pub struct HubBackend { |
|
| 17 | 18 | repos: Arc<Repos>, |
|
| 18 | 19 | pool: db::Pool, |
|
| 19 | - | push_tokens: Vec<String>, |
|
| 20 | + | /// Site tokens from `hub.toml`: full access, for bootstrapping. |
|
| 21 | + | site_tokens: Vec<String>, |
|
| 20 | 22 | runtime: tokio::runtime::Handle, |
|
| 21 | 23 | } |
|
| 22 | 24 | ||
| 25 | + | /// Who a request's credential resolved to. |
|
| 26 | + | enum Caller { |
|
| 27 | + | Anonymous, |
|
| 28 | + | /// A site token from the configuration. |
|
| 29 | + | Site, |
|
| 30 | + | Token(Box<Account>, Box<db::accounts::Token>), |
|
| 31 | + | } |
|
| 32 | + | ||
| 23 | 33 | impl HubBackend { |
|
| 24 | - | pub fn new(repos: Arc<Repos>, pool: db::Pool, push_tokens: Vec<String>, runtime: tokio::runtime::Handle) -> HubBackend { |
|
| 25 | - | HubBackend { repos, pool, push_tokens, runtime } |
|
| 34 | + | pub fn new(repos: Arc<Repos>, pool: db::Pool, site_tokens: Vec<String>, runtime: tokio::runtime::Handle) -> HubBackend { |
|
| 35 | + | HubBackend { repos, pool, site_tokens, runtime } |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | fn block<T>(&self, f: impl std::future::Future<Output = T>) -> T { |
|
| 39 | + | tokio::task::block_in_place(|| self.runtime.block_on(f)) |
|
| 26 | 40 | } |
|
| 27 | 41 | ||
| 28 | 42 | fn row(&self, name: &RepoName) -> Result<db::repos::RepoRow, Failure> { |
|
| 29 | 43 | let pool = self.pool.clone(); |
|
| 30 | - | let (owner, repo) = (name.owner.clone(), name.name.clone()); |
|
| 31 | - | let row = tokio::task::block_in_place(|| self.runtime.block_on(db::repos::get(&pool, &owner, &repo))); |
|
| 32 | - | match row { |
|
| 44 | + | match self.block(db::repos::get(&pool, &name.owner, &name.name)) { |
|
| 33 | 45 | Ok(Some(r)) => Ok(r), |
|
| 34 | 46 | Ok(None) => Err(not_found()), |
|
| 35 | 47 | Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), |
|
| 36 | 48 | } |
|
| 37 | 49 | } |
|
| 38 | 50 | ||
| 39 | - | fn has_push_token(&self, headers: &HeaderMap) -> bool { |
|
| 40 | - | match oxy_server::bearer_token(headers) { |
|
| 41 | - | Some(t) => self.push_tokens.iter().any(|x| x == t), |
|
| 42 | - | None => false, |
|
| 51 | + | fn caller(&self, headers: &HeaderMap) -> Result<Caller, Failure> { |
|
| 52 | + | let Some(presented) = oxy_server::bearer_token(headers) else { return Ok(Caller::Anonymous) }; |
|
| 53 | + | if self.site_tokens.iter().any(|t| auth::mac_equal(t, presented)) { |
|
| 54 | + | return Ok(Caller::Site); |
|
| 43 | 55 | } |
|
| 56 | + | let hash = auth::token_hash(presented); |
|
| 57 | + | let pool = self.pool.clone(); |
|
| 58 | + | let token = self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?; |
|
| 59 | + | let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) }; |
|
| 60 | + | if token.expired(db::now_ms()) { |
|
| 61 | + | return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into())); |
|
| 62 | + | } |
|
| 63 | + | let account = self.block(db::accounts::account_by_id(&pool, token.account_id)).map_err(internal)?; |
|
| 64 | + | let Some(account) = account else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) }; |
|
| 65 | + | if account.banned { |
|
| 66 | + | return Err(Failure(StatusCode::UNAUTHORIZED, "this account is banned".into())); |
|
| 67 | + | } |
|
| 68 | + | let _ = self.block(db::accounts::touch_token(&pool, token.id)); |
|
| 69 | + | Ok(Caller::Token(Box::new(account), Box::new(token))) |
|
| 44 | 70 | } |
|
| 45 | 71 | } |
|
| 46 | 72 | ||
| 73 | + | fn internal(e: sqlx::Error) -> Failure { |
|
| 74 | + | Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) |
|
| 75 | + | } |
|
| 76 | + | ||
| 47 | 77 | fn not_found() -> Failure { |
|
| 48 | 78 | Failure(StatusCode::NOT_FOUND, "no such repository".into()) |
|
| 49 | 79 | } |
|
| … | 7 unchanged lines |
||
| 57 | 87 | }) |
|
| 58 | 88 | } |
|
| 59 | 89 | ||
| 60 | - | fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> { |
|
| 90 | + | fn authorize(&self, name: &str, access: oxy_server::Access, headers: &HeaderMap) -> Result<(), Failure> { |
|
| 61 | 91 | let parsed = RepoName::parse(name).ok_or_else(not_found)?; |
|
| 62 | 92 | let row = self.row(&parsed)?; |
|
| 63 | - | let token = self.has_push_token(headers); |
|
| 64 | - | // Until accounts exist (hub 0.2) a push token is the only credential: |
|
| 65 | - | // it reads everything and may push everywhere. |
|
| 66 | - | if row.visibility() != Visibility::Public && !token { |
|
| 93 | + | let caller = self.caller(headers)?; |
|
| 94 | + | let (role, scope_ok) = match &caller { |
|
| 95 | + | Caller::Site => (Some(Role::Admin), true), |
|
| 96 | + | Caller::Anonymous => (self.block(auth::roles::role_for(&self.pool, None, &row)).map_err(internal)?, true), |
|
| 97 | + | Caller::Token(account, token) => { |
|
| 98 | + | let role = self.block(auth::roles::role_for(&self.pool, Some(account), &row)).map_err(internal)?; |
|
| 99 | + | let needed = match access { |
|
| 100 | + | oxy_server::Access::Read => { |
|
| 101 | + | token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin") |
|
| 102 | + | } |
|
| 103 | + | oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"), |
|
| 104 | + | }; |
|
| 105 | + | (role, needed && token.covers(&parsed.to_string())) |
|
| 106 | + | } |
|
| 107 | + | }; |
|
| 108 | + | // An unreadable repository does not exist for the caller (hub spec §4.4). |
|
| 109 | + | if !auth::roles::allows(role, Access::Read) { |
|
| 67 | 110 | return Err(not_found()); |
|
| 68 | 111 | } |
|
| 69 | 112 | match access { |
|
| 70 | - | Access::Read => Ok(()), |
|
| 71 | - | Access::Push if self.push_tokens.is_empty() || token => Ok(()), |
|
| 72 | - | Access::Push => Err(Failure(StatusCode::UNAUTHORIZED, "a valid bearer token is required to push".into())), |
|
| 113 | + | oxy_server::Access::Read if scope_ok => Ok(()), |
|
| 114 | + | oxy_server::Access::Read => Err(Failure(StatusCode::FORBIDDEN, "this token cannot read repositories".into())), |
|
| 115 | + | oxy_server::Access::Push => { |
|
| 116 | + | if matches!(caller, Caller::Anonymous) { |
|
| 117 | + | return Err(Failure(StatusCode::UNAUTHORIZED, "a personal access token is required to push".into())); |
|
| 118 | + | } |
|
| 119 | + | if !auth::roles::allows(role, Access::Write) { |
|
| 120 | + | return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into())); |
|
| 121 | + | } |
|
| 122 | + | if !scope_ok { |
|
| 123 | + | return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into())); |
|
| 124 | + | } |
|
| 125 | + | Ok(()) |
|
| 126 | + | } |
|
| 73 | 127 | } |
|
| 74 | 128 | } |
|
| 75 | 129 | ||
| 76 | - | fn after_push(&self, name: &str, report: &PushReport) { |
|
| 130 | + | fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) { |
|
| 77 | 131 | let Some(parsed) = RepoName::parse(name) else { return }; |
|
| 78 | 132 | let Ok(row) = self.row(&parsed) else { return }; |
|
| 133 | + | let pusher = match self.caller(headers) { |
|
| 134 | + | Ok(Caller::Token(account, _)) => Some((account.id, account.username)), |
|
| 135 | + | Ok(Caller::Site) => Some((0, "site token".to_string())), |
|
| 136 | + | _ => None, |
|
| 137 | + | }; |
|
| 79 | 138 | let pool = self.pool.clone(); |
|
| 80 | 139 | let results: Vec<db::repos::PushOutcome> = report |
|
| 81 | 140 | .results |
|
| … | 12 unchanged lines |
||
| 94 | 153 | }) |
|
| 95 | 154 | .collect(); |
|
| 96 | 155 | self.runtime.spawn(async move { |
|
| 97 | - | if let Err(e) = db::repos::record_push(&pool, row.id, &results).await { |
|
| 156 | + | let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str())).filter(|(id, _)| *id > 0); |
|
| 157 | + | let label = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); |
|
| 158 | + | if let Err(e) = |
|
| 159 | + | db::repos::record_push(&pool, row.id, who.or(if label.is_empty() { None } else { Some((0, label.as_str())) }), &results) |
|
| 160 | + | .await |
|
| 161 | + | { |
|
| 98 | 162 | tracing::warn!("recording push to {}: {e}", parsed); |
|
| 99 | 163 | } |
|
| 100 | 164 | }); |
|
| … | 14 unchanged lines |
||
| 115 | 179 | let Some(repo) = RepoName::new(&owner, &name) else { |
|
| 116 | 180 | return not_found().into_response(); |
|
| 117 | 181 | }; |
|
| 118 | - | // Pushes and checkpoints write; everything else reads. |
|
| 119 | 182 | let _guard = match endpoint { |
|
| 120 | 183 | Endpoint::Push | Endpoint::Checkpoint => Guard::Write(app.repos.write_guard(&repo).await), |
|
| 121 | 184 | _ => Guard::Read(app.repos.read_guard(&repo).await), |
|
| … | 8 unchanged lines |
modified crates/hub/src/repos/view.rs +5 −3
| … | 86 unchanged lines |
||
| 87 | 87 | pub key: String, |
|
| 88 | 88 | /// True when the key is not an identity the store knows. |
|
| 89 | 89 | pub unknown: bool, |
|
| 90 | + | /// The hub account the key is linked to, filled by the web layer. |
|
| 91 | + | pub account: Option<String>, |
|
| 90 | 92 | } |
|
| 91 | 93 | ||
| 92 | 94 | pub fn author(repo: &Repo, key: &KeyId, message: &str) -> AuthorView { |
|
| … | 4 unchanged lines |
||
| 97 | 99 | Some((n, e)) => (n.trim().to_string(), e.trim_end_matches('>').trim().to_string()), |
|
| 98 | 100 | None => (line.trim().to_string(), String::new()), |
|
| 99 | 101 | }; |
|
| 100 | - | return AuthorView { name, email, key: String::new(), unknown: true }; |
|
| 102 | + | return AuthorView { name, email, key: String::new(), unknown: true, account: None }; |
|
| 101 | 103 | } |
|
| 102 | 104 | match repo.store().get_identity(key) { |
|
| 103 | - | Ok(id) => AuthorView { name: id.name, email: id.email, key: key.to_hex(), unknown: false }, |
|
| 104 | - | Err(_) => AuthorView { name: key.short(12), email: String::new(), key: key.to_hex(), unknown: true }, |
|
| 105 | + | Ok(id) => AuthorView { name: id.name, email: id.email, key: key.to_hex(), unknown: false, account: None }, |
|
| 106 | + | Err(_) => AuthorView { name: key.short(12), email: String::new(), key: key.to_hex(), unknown: true, account: None }, |
|
| 105 | 107 | } |
|
| 106 | 108 | } |
|
| 107 | 109 | ||
| … | 792 unchanged lines |
modified crates/hub/src/web/filters.rs +6 −3
| … | 13 unchanged lines |
||
| 14 | 14 | impl Num for $t { |
|
| 15 | 15 | fn as_u64(self) -> u64 { if (self as i128) < 0 { 0 } else { self as u64 } } |
|
| 16 | 16 | } |
|
| 17 | - | impl Num for &$t { |
|
| 18 | - | fn as_u64(self) -> u64 { (*self).as_u64() } |
|
| 19 | - | } |
|
| 20 | 17 | )*}; |
|
| 21 | 18 | } |
|
| 22 | 19 | num!(u64, i64, u32, i32, usize); |
|
| 23 | 20 | ||
| 21 | + | impl<T: Num> Num for &T { |
|
| 22 | + | fn as_u64(self) -> u64 { |
|
| 23 | + | (*self).as_u64() |
|
| 24 | + | } |
|
| 25 | + | } |
|
| 26 | + | ||
| 24 | 27 | pub fn ago(ms: impl Num, _: &dyn askama::Values) -> askama::Result<String> { |
|
| 25 | 28 | Ok(render::time::ago(ms.as_u64(), crate::db::now_ms() as u64)) |
|
| 26 | 29 | } |
|
| … | 30 unchanged lines |
modified crates/hub/src/web/history.rs +24 −9
| 1 | 1 | //! History, change and compare pages. |
|
| 2 | 2 | ||
| 3 | 3 | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
|
| 4 | + | use crate::auth::Viewer; |
|
| 4 | 5 | use crate::render::{self, diff, graph}; |
|
| 5 | 6 | use crate::repos::view; |
|
| 6 | 7 | use crate::Shared; |
|
| … | 23 unchanged lines |
||
| 30 | 31 | ||
| 31 | 32 | pub async fn history( |
|
| 32 | 33 | State(app): State<Shared>, |
|
| 34 | + | viewer: Viewer, |
|
| 33 | 35 | Path((owner, name, r)): Path<(String, String, String)>, |
|
| 34 | 36 | Query(q): Query<HistoryQuery>, |
|
| 35 | 37 | ) -> Result<Page<HistoryPage>, Error> { |
|
| 36 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/changes/{r}")); |
|
| 37 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 38 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/changes/{r}"), &viewer); |
|
| 39 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 38 | 40 | let spec = r.clone(); |
|
| 39 | 41 | let path = q.path.clone().filter(|p| !p.is_empty()); |
|
| 40 | 42 | let offset = q.offset.unwrap_or(0); |
|
| … | 5 unchanged lines |
||
| 46 | 48 | }) |
|
| 47 | 49 | .await |
|
| 48 | 50 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 51 | + | let mut h = h; |
|
| 52 | + | super::attribute(&app, &mut h.entries.iter_mut().collect::<Vec<_>>()).await; |
|
| 49 | 53 | let graph_input: Vec<(String, Vec<String>)> = h.entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); |
|
| 50 | 54 | let rows = graph::layout(&graph_input); |
|
| 51 | 55 | let lanes = graph::width(&rows); |
|
| 52 | - | Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes"), h, rows, lanes })) |
|
| 56 | + | Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), h, rows, lanes })) |
|
| 53 | 57 | } |
|
| 54 | 58 | ||
| 55 | 59 | /// A file's diff, rendered. |
|
| … | 39 unchanged lines |
||
| 95 | 99 | ||
| 96 | 100 | pub async fn change( |
|
| 97 | 101 | State(app): State<Shared>, |
|
| 102 | + | viewer: Viewer, |
|
| 98 | 103 | Path((owner, name, hash)): Path<(String, String, String)>, |
|
| 99 | 104 | ) -> Result<Page<ChangePage>, Error> { |
|
| 100 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}")); |
|
| 101 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 105 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer); |
|
| 106 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 102 | 107 | let spec = hash.clone(); |
|
| 103 | 108 | let c = app |
|
| 104 | 109 | .repos |
|
| … | 3 unchanged lines |
||
| 108 | 113 | }) |
|
| 109 | 114 | .await |
|
| 110 | 115 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 116 | + | let mut c = c; |
|
| 117 | + | { |
|
| 118 | + | let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view]; |
|
| 119 | + | all.extend(c.deps.iter_mut()); |
|
| 120 | + | all.extend(c.dependents.iter_mut()); |
|
| 121 | + | super::attribute(&app, &mut all).await; |
|
| 122 | + | } |
|
| 111 | 123 | let files = render_files(&c.files, &c.bases); |
|
| 112 | 124 | let body_html = |
|
| 113 | 125 | if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) }; |
|
| 114 | - | Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes"), c, files, body_html })) |
|
| 126 | + | Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html })) |
|
| 115 | 127 | } |
|
| 116 | 128 | ||
| 117 | 129 | #[derive(Template)] |
|
| … | 8 unchanged lines |
||
| 126 | 138 | /// `/compare/{base}...{head}`. |
|
| 127 | 139 | pub async fn compare( |
|
| 128 | 140 | State(app): State<Shared>, |
|
| 141 | + | viewer: Viewer, |
|
| 129 | 142 | Path((owner, name, spec)): Path<(String, String, String)>, |
|
| 130 | 143 | ) -> Result<Page<ComparePage>, Error> { |
|
| 131 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}")); |
|
| 132 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 144 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer); |
|
| 145 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 133 | 146 | let Some((a, b)) = spec.split_once("...") else { |
|
| 134 | 147 | return Err(Error::bad(shell, "compare takes base...head")); |
|
| 135 | 148 | }; |
|
| … | 7 unchanged lines |
||
| 143 | 156 | }) |
|
| 144 | 157 | .await |
|
| 145 | 158 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 159 | + | let mut cmp = cmp; |
|
| 160 | + | super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await; |
|
| 146 | 161 | let files = render_files(&cmp.files, &cmp.bases); |
|
| 147 | - | Ok(Page(ComparePage { shell, repo: RepoShell::new(&app, &row, "changes"), cmp, files })) |
|
| 162 | + | Ok(Page(ComparePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), cmp, files })) |
|
| 148 | 163 | } |
modified crates/hub/src/web/home.rs +34 −12
| 1 | 1 | //! The front page, explore, owner pages and small endpoints. |
|
| 2 | 2 | ||
| 3 | 3 | use super::{filters, Error, Page, Shell}; |
|
| 4 | + | use crate::auth::Viewer; |
|
| 4 | 5 | use crate::{db, Shared}; |
|
| 5 | 6 | use askama::Template; |
|
| 6 | 7 | use axum::extract::{Path, State}; |
|
| … | 10 unchanged lines |
||
| 17 | 18 | owner: Option<String>, |
|
| 18 | 19 | } |
|
| 19 | 20 | ||
| 20 | - | pub async fn index(State(app): State<Shared>) -> Result<Page<HomePage>, Error> { |
|
| 21 | - | let shell = Shell::new(&app, "/"); |
|
| 22 | - | let repos = db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 23 | - | let repos = repos.into_iter().filter(|r| r.visibility() == Visibility::Public).collect(); |
|
| 21 | + | pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Page<HomePage>, Error> { |
|
| 22 | + | let shell = Shell::new(&app, "/", &viewer); |
|
| 23 | + | let repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?).await?; |
|
| 24 | 24 | Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None })) |
|
| 25 | 25 | } |
|
| 26 | 26 | ||
| 27 | - | pub async fn explore(State(app): State<Shared>) -> Result<Page<HomePage>, Error> { |
|
| 28 | - | let mut page = index(State(app.clone())).await?; |
|
| 29 | - | page.0.shell = Shell::new(&app, "/explore"); |
|
| 27 | + | pub async fn explore(State(app): State<Shared>, viewer: Viewer) -> Result<Page<HomePage>, Error> { |
|
| 28 | + | let mut page = index(State(app.clone()), viewer.clone()).await?; |
|
| 29 | + | page.0.shell = Shell::new(&app, "/explore", &viewer); |
|
| 30 | 30 | Ok(page) |
|
| 31 | 31 | } |
|
| 32 | 32 | ||
| 33 | - | pub async fn owner(State(app): State<Shared>, Path(owner): Path<String>) -> Result<Page<HomePage>, Error> { |
|
| 34 | - | let shell = Shell::new(&app, &format!("/{owner}")); |
|
| 33 | + | pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<HomePage>, Error> { |
|
| 34 | + | let shell = Shell::new(&app, &format!("/{owner}"), &viewer); |
|
| 35 | 35 | if !crate::repos::names::valid_owner(&owner) { |
|
| 36 | 36 | return Err(Error::not_found(shell)); |
|
| 37 | 37 | } |
|
| 38 | - | let repos = db::repos::list_by_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 39 | - | let repos: Vec<_> = repos.into_iter().filter(|r| r.visibility() == Visibility::Public).collect(); |
|
| 40 | - | if repos.is_empty() { |
|
| 38 | + | let repos = |
|
| 39 | + | visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?) |
|
| 40 | + | .await?; |
|
| 41 | + | let known = db::accounts::account_by_name(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 42 | + | || db::accounts::org_by_name(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some(); |
|
| 43 | + | if repos.is_empty() && !known { |
|
| 41 | 44 | return Err(Error::not_found(shell)); |
|
| 42 | 45 | } |
|
| 43 | 46 | Ok(Page(HomePage { description: String::new(), shell, repos, owner: Some(owner) })) |
|
| 44 | 47 | } |
|
| 45 | 48 | ||
| 49 | + | /// The repositories among `rows` the viewer may see: public ones, internal |
|
| 50 | + | /// ones when signed in, and those the viewer owns or collaborates on. |
|
| 51 | + | async fn visible(app: &Shared, shell: &Shell, viewer: &Viewer, rows: Vec<db::repos::RepoRow>) -> Result<Vec<db::repos::RepoRow>, Error> { |
|
| 52 | + | let mine: Vec<i64> = match viewer.account() { |
|
| 53 | + | Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(), |
|
| 54 | + | Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?, |
|
| 55 | + | None => vec![], |
|
| 56 | + | }; |
|
| 57 | + | let signed_in = viewer.signed_in(); |
|
| 58 | + | Ok(rows |
|
| 59 | + | .into_iter() |
|
| 60 | + | .filter(|r| match r.visibility() { |
|
| 61 | + | Visibility::Public => true, |
|
| 62 | + | Visibility::Internal => signed_in, |
|
| 63 | + | Visibility::Private => mine.contains(&r.id), |
|
| 64 | + | }) |
|
| 65 | + | .collect()) |
|
| 66 | + | } |
|
| 67 | + | ||
| 46 | 68 | pub async fn health(State(app): State<Shared>) -> Response { |
|
| 47 | 69 | let body = serde_json::json!({ |
|
| 48 | 70 | "ok": true, |
|
| … | 15 unchanged lines |
modified crates/hub/src/web/mod.rs +172 −22
| 1 | 1 | //! Pages (hub spec §6): the router, the shell every template extends, the |
|
| 2 | - | //! error page, and askama filters. Handlers are thin: they resolve names, |
|
| 3 | - | //! call the view layer under the repository lock, and render. |
|
| 2 | + | //! error page, viewer-aware lookups and askama filters. Handlers are thin: |
|
| 3 | + | //! they resolve names, call the view layer under the repository lock, and |
|
| 4 | + | //! render. |
|
| 4 | 5 | ||
| 6 | + | mod admin; |
|
| 5 | 7 | pub mod filters; |
|
| 6 | 8 | mod history; |
|
| 7 | 9 | mod home; |
|
| 10 | + | mod orgs; |
|
| 8 | 11 | mod refs; |
|
| 9 | 12 | mod repo; |
|
| 13 | + | mod repo_settings; |
|
| 14 | + | mod session; |
|
| 15 | + | mod settings; |
|
| 10 | 16 | mod statics; |
|
| 11 | 17 | ||
| 18 | + | use crate::auth::{self, Access, Role, Viewer}; |
|
| 12 | 19 | use crate::repos::RepoError; |
|
| 13 | 20 | use crate::{db, Shared}; |
|
| 14 | 21 | use askama::Template; |
|
| 15 | 22 | use axum::extract::DefaultBodyLimit; |
|
| 16 | 23 | use axum::http::{header, StatusCode}; |
|
| 17 | - | use axum::response::{Html, IntoResponse, Response}; |
|
| 18 | - | use axum::routing::{any, get}; |
|
| 24 | + | use axum::response::{Html, IntoResponse, Redirect, Response}; |
|
| 25 | + | use axum::routing::{any, get, post}; |
|
| 19 | 26 | use axum::Router; |
|
| 20 | 27 | use tower_http::compression::CompressionLayer; |
|
| 21 | 28 | use tower_http::trace::TraceLayer; |
|
| … | 9 unchanged lines |
||
| 31 | 38 | .route("/favicon.svg", get(statics::favicon)) |
|
| 32 | 39 | .route("/favicon.ico", get(statics::favicon)) |
|
| 33 | 40 | .route("/static/{*path}", get(statics::asset)) |
|
| 41 | + | .route("/login", get(session::login_form).post(session::login)) |
|
| 42 | + | .route("/logout", post(session::logout)) |
|
| 43 | + | .route("/register", get(session::register_form).post(session::register)) |
|
| 44 | + | .route("/settings", get(settings::profile).post(settings::update_profile)) |
|
| 45 | + | .route("/settings/password", post(settings::change_password)) |
|
| 46 | + | .route("/settings/security", get(settings::security)) |
|
| 47 | + | .route("/settings/security/totp/begin", post(settings::totp_begin)) |
|
| 48 | + | .route("/settings/security/totp/enable", post(settings::totp_enable)) |
|
| 49 | + | .route("/settings/security/totp/disable", post(settings::totp_disable)) |
|
| 50 | + | .route("/settings/security/sessions", post(settings::sessions_reset)) |
|
| 51 | + | .route("/settings/keys", get(settings::keys)) |
|
| 52 | + | .route("/settings/keys/upload", post(settings::keys_upload)) |
|
| 53 | + | .route("/settings/keys/verify", post(settings::keys_verify)) |
|
| 54 | + | .route("/settings/keys/unlink", post(settings::keys_unlink)) |
|
| 55 | + | .route("/settings/tokens", get(settings::tokens).post(settings::tokens_create)) |
|
| 56 | + | .route("/settings/tokens/{id}/delete", post(settings::tokens_delete)) |
|
| 57 | + | .route("/new", get(repo_settings::new_form).post(repo_settings::create)) |
|
| 58 | + | .route("/orgs/new", get(orgs::new_form).post(orgs::create)) |
|
| 59 | + | .route("/orgs/{name}/settings", get(orgs::settings).post(orgs::update)) |
|
| 60 | + | .route("/orgs/{name}/settings/members", post(orgs::member)) |
|
| 61 | + | .route("/orgs/{name}/settings/teams", post(orgs::team)) |
|
| 62 | + | .route("/admin", get(admin::index)) |
|
| 63 | + | .route("/admin/registration", post(admin::set_mode)) |
|
| 64 | + | .route("/admin/invite", post(admin::invite)) |
|
| 65 | + | .route("/admin/account", post(admin::account)) |
|
| 34 | 66 | .nest("/api/v1", crate::api::router()) |
|
| 35 | 67 | .route("/{owner}", get(home::owner)) |
|
| 36 | 68 | .route("/{owner}/{name}", get(repo::overview)) |
|
| … | 9 unchanged lines |
||
| 46 | 78 | .route("/{owner}/{name}/conflicts", get(refs::conflicts)) |
|
| 47 | 79 | .route("/{owner}/{name}/trust", get(refs::trust)) |
|
| 48 | 80 | .route("/{owner}/{name}/pushes", get(refs::pushes)) |
|
| 81 | + | .route("/{owner}/{name}/settings", get(repo_settings::settings).post(repo_settings::update)) |
|
| 82 | + | .route("/{owner}/{name}/settings/collaborators", post(repo_settings::collaborator)) |
|
| 83 | + | .route("/{owner}/{name}/settings/delete", post(repo_settings::delete)) |
|
| 49 | 84 | .route("/{owner}/{name}/oxy/v1/{endpoint}", any(crate::protocol::dispatch).layer(DefaultBodyLimit::max(push_limit))) |
|
| 50 | 85 | .fallback(not_found) |
|
| 86 | + | .layer(axum::middleware::from_fn_with_state(app.clone(), same_origin)) |
|
| 51 | 87 | .layer(CompressionLayer::new()) |
|
| 52 | 88 | .layer(TraceLayer::new_for_http()) |
|
| 53 | 89 | .with_state(app) |
|
| 54 | 90 | } |
|
| 55 | 91 | ||
| 92 | + | /// Cross-site form posts are refused (hub spec §15.2): a browser that |
|
| 93 | + | /// sends `Origin` or `Sec-Fetch-Site` must name this site. Protocol and |
|
| 94 | + | /// API clients send neither. |
|
| 95 | + | async fn same_origin( |
|
| 96 | + | axum::extract::State(app): axum::extract::State<Shared>, |
|
| 97 | + | req: axum::extract::Request, |
|
| 98 | + | next: axum::middleware::Next, |
|
| 99 | + | ) -> Response { |
|
| 100 | + | if req.method() != axum::http::Method::GET && req.method() != axum::http::Method::HEAD { |
|
| 101 | + | let headers = req.headers(); |
|
| 102 | + | if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") { |
|
| 103 | + | return (StatusCode::FORBIDDEN, "cross-site request refused").into_response(); |
|
| 104 | + | } |
|
| 105 | + | if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { |
|
| 106 | + | let host_of = |u: &str| u.split("://").nth(1).unwrap_or(u).split('/').next().unwrap_or("").to_ascii_lowercase(); |
|
| 107 | + | let request_host = headers.get(header::HOST).and_then(|v| v.to_str().ok()).map(|h| h.to_ascii_lowercase()); |
|
| 108 | + | let ok = host_of(origin) == host_of(app.config.base_url()) || request_host.as_deref() == Some(host_of(origin).as_str()); |
|
| 109 | + | if !ok && origin != "null" { |
|
| 110 | + | return (StatusCode::FORBIDDEN, "cross-site request refused").into_response(); |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | next.run(req).await |
|
| 115 | + | } |
|
| 116 | + | ||
| 56 | 117 | /// What every page's chrome needs. |
|
| 57 | 118 | #[derive(Clone, Debug)] |
|
| 58 | 119 | pub struct Shell { |
|
| … | 1 unchanged lines |
||
| 60 | 121 | pub base_url: String, |
|
| 61 | 122 | pub path: String, |
|
| 62 | 123 | pub version: &'static str, |
|
| 124 | + | /// The signed-in username, if any. |
|
| 125 | + | pub user: Option<String>, |
|
| 126 | + | pub is_admin: bool, |
|
| 127 | + | pub csrf: String, |
|
| 63 | 128 | } |
|
| 64 | 129 | ||
| 65 | 130 | impl Shell { |
|
| 66 | - | pub fn new(app: &Shared, path: &str) -> Shell { |
|
| 131 | + | pub fn new(app: &Shared, path: &str, viewer: &Viewer) -> Shell { |
|
| 67 | 132 | Shell { |
|
| 68 | 133 | site: app.config.site.name.clone(), |
|
| 69 | 134 | base_url: app.config.base_url().to_string(), |
|
| 70 | 135 | path: path.to_string(), |
|
| 71 | 136 | version: env!("CARGO_PKG_VERSION"), |
|
| 137 | + | user: viewer.account().map(|a| a.username.clone()), |
|
| 138 | + | is_admin: viewer.is_admin(), |
|
| 139 | + | csrf: viewer.csrf().to_string(), |
|
| 72 | 140 | } |
|
| 73 | 141 | } |
|
| 142 | + | ||
| 143 | + | pub fn anonymous(app: &Shared, path: &str) -> Shell { |
|
| 144 | + | Shell::new(app, path, &Viewer(None)) |
|
| 145 | + | } |
|
| 74 | 146 | } |
|
| 75 | 147 | ||
| 76 | 148 | /// The repository chrome: tabs and counts. |
|
| … | 7 unchanged lines |
||
| 84 | 156 | pub visibility: String, |
|
| 85 | 157 | pub tab: &'static str, |
|
| 86 | 158 | pub clone_url: String, |
|
| 159 | + | /// The viewer's role, for showing settings and write actions. |
|
| 160 | + | pub role: Option<Role>, |
|
| 87 | 161 | } |
|
| 88 | 162 | ||
| 89 | 163 | impl RepoShell { |
|
| … | 7 unchanged lines |
||
| 97 | 171 | visibility: row.visibility.clone(), |
|
| 98 | 172 | tab, |
|
| 99 | 173 | clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name), |
|
| 174 | + | role: None, |
|
| 100 | 175 | } |
|
| 101 | 176 | } |
|
| 177 | + | ||
| 178 | + | pub fn with_role(mut self, role: Role) -> RepoShell { |
|
| 179 | + | self.role = Some(role); |
|
| 180 | + | self |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | pub fn can(&self, access: Access) -> bool { |
|
| 184 | + | auth::roles::allows(self.role, access) |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | pub fn can_maintain(&self) -> bool { |
|
| 188 | + | self.can(Access::Maintain) |
|
| 189 | + | } |
|
| 102 | 190 | } |
|
| 103 | 191 | ||
| 104 | 192 | /// Render a template as an HTML response. |
|
| … | 20 unchanged lines |
||
| 125 | 213 | detail: String, |
|
| 126 | 214 | } |
|
| 127 | 215 | ||
| 128 | - | /// A page-level failure, rendered inside the shell. |
|
| 216 | + | /// A page-level failure, rendered inside the shell, or a redirect. |
|
| 129 | 217 | #[derive(Debug)] |
|
| 130 | 218 | pub struct Error { |
|
| 131 | 219 | pub status: StatusCode, |
|
| 132 | 220 | pub detail: String, |
|
| 133 | - | shell: Option<Shell>, |
|
| 221 | + | shell: Option<Box<Shell>>, |
|
| 222 | + | redirect: Option<String>, |
|
| 134 | 223 | } |
|
| 135 | 224 | ||
| 136 | 225 | impl Error { |
|
| 226 | + | pub fn redirect(to: String) -> Error { |
|
| 227 | + | Error { status: StatusCode::SEE_OTHER, detail: String::new(), shell: None, redirect: Some(to) } |
|
| 228 | + | } |
|
| 229 | + | ||
| 137 | 230 | pub fn not_found(shell: Shell) -> Error { |
|
| 138 | - | Error { status: StatusCode::NOT_FOUND, detail: "There is nothing at this address.".into(), shell: Some(shell) } |
|
| 231 | + | Error { |
|
| 232 | + | status: StatusCode::NOT_FOUND, |
|
| 233 | + | detail: "There is nothing at this address.".into(), |
|
| 234 | + | shell: Some(Box::new(shell)), |
|
| 235 | + | redirect: None, |
|
| 236 | + | } |
|
| 139 | 237 | } |
|
| 140 | 238 | ||
| 141 | 239 | pub fn bad(shell: Shell, detail: impl Into<String>) -> Error { |
|
| 142 | - | Error { status: StatusCode::BAD_REQUEST, detail: detail.into(), shell: Some(shell) } |
|
| 240 | + | Error { status: StatusCode::BAD_REQUEST, detail: detail.into(), shell: Some(Box::new(shell)), redirect: None } |
|
| 143 | 241 | } |
|
| 144 | 242 | ||
| 145 | 243 | pub fn from_repo(shell: Shell, e: RepoError) -> Error { |
|
| 146 | 244 | match e { |
|
| 147 | 245 | RepoError::NotFound => Error::not_found(shell), |
|
| 148 | - | RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => Error { status: StatusCode::NOT_FOUND, detail: m, shell: Some(shell) }, |
|
| 149 | - | other => Error { status: StatusCode::INTERNAL_SERVER_ERROR, detail: other.to_string(), shell: Some(shell) }, |
|
| 246 | + | RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => { |
|
| 247 | + | Error { status: StatusCode::NOT_FOUND, detail: m, shell: Some(Box::new(shell)), redirect: None } |
|
| 248 | + | } |
|
| 249 | + | other => { |
|
| 250 | + | Error { status: StatusCode::INTERNAL_SERVER_ERROR, detail: other.to_string(), shell: Some(Box::new(shell)), redirect: None } |
|
| 251 | + | } |
|
| 150 | 252 | } |
|
| 151 | 253 | } |
|
| 152 | 254 | ||
| 153 | 255 | pub fn from_db(shell: Shell, e: sqlx::Error) -> Error { |
|
| 154 | - | Error { status: StatusCode::INTERNAL_SERVER_ERROR, detail: e.to_string(), shell: Some(shell) } |
|
| 256 | + | Error { status: StatusCode::INTERNAL_SERVER_ERROR, detail: e.to_string(), shell: Some(Box::new(shell)), redirect: None } |
|
| 155 | 257 | } |
|
| 156 | 258 | } |
|
| 157 | 259 | ||
| 158 | 260 | impl IntoResponse for Error { |
|
| 159 | 261 | fn into_response(self) -> Response { |
|
| 262 | + | if let Some(to) = self.redirect { |
|
| 263 | + | return Redirect::to(&to).into_response(); |
|
| 264 | + | } |
|
| 160 | 265 | let title = match self.status { |
|
| 161 | 266 | StatusCode::NOT_FOUND => "Not found", |
|
| 162 | 267 | StatusCode::BAD_REQUEST => "Bad request", |
|
| … | 2 unchanged lines |
||
| 165 | 270 | if self.status.is_server_error() { |
|
| 166 | 271 | tracing::error!("{}", self.detail); |
|
| 167 | 272 | } |
|
| 168 | - | let shell = self.shell.unwrap_or(Shell { site: "Oxygenhub".into(), base_url: String::new(), path: String::new(), version: "" }); |
|
| 273 | + | let shell = self.shell.map(|b| *b).unwrap_or(Shell { |
|
| 274 | + | site: "Oxygenhub".into(), |
|
| 275 | + | base_url: String::new(), |
|
| 276 | + | path: String::new(), |
|
| 277 | + | version: "", |
|
| 278 | + | user: None, |
|
| 279 | + | is_admin: false, |
|
| 280 | + | csrf: String::new(), |
|
| 281 | + | }); |
|
| 169 | 282 | let page = ErrorPage { shell, status: self.status.as_u16(), title: title.into(), detail: self.detail }; |
|
| 170 | 283 | match page.render() { |
|
| 171 | 284 | Ok(s) => (self.status, Html(s)).into_response(), |
|
| … | 2 unchanged lines |
||
| 174 | 287 | } |
|
| 175 | 288 | } |
|
| 176 | 289 | ||
| 177 | - | async fn not_found(axum::extract::State(app): axum::extract::State<Shared>, uri: axum::http::Uri) -> Response { |
|
| 178 | - | Error::not_found(Shell::new(&app, uri.path())).into_response() |
|
| 290 | + | async fn not_found(axum::extract::State(app): axum::extract::State<Shared>, viewer: Viewer, uri: axum::http::Uri) -> Response { |
|
| 291 | + | Error::not_found(Shell::new(&app, uri.path(), &viewer)).into_response() |
|
| 179 | 292 | } |
|
| 180 | 293 | ||
| 181 | 294 | /// A raw file response with a safe content type (hub spec §15.2). |
|
| … | 26 unchanged lines |
||
| 208 | 321 | } |
|
| 209 | 322 | } |
|
| 210 | 323 | ||
| 211 | - | /// Resolve `{owner}/{name}` to its row, or 404. Private repositories are |
|
| 212 | - | /// invisible until accounts exist (hub 0.2). |
|
| 213 | - | pub async fn lookup(app: &Shared, shell: &Shell, owner: &str, name: &str) -> Result<(crate::repos::RepoName, db::repos::RepoRow), Error> { |
|
| 324 | + | /// Resolve `{owner}/{name}` for the viewer with at least `access`, or 404: |
|
| 325 | + | /// a repository the viewer may not see does not exist for them (hub spec |
|
| 326 | + | /// §4.4). Returns the viewer's role too. |
|
| 327 | + | pub async fn lookup_role( |
|
| 328 | + | app: &Shared, |
|
| 329 | + | shell: &Shell, |
|
| 330 | + | viewer: &Viewer, |
|
| 331 | + | owner: &str, |
|
| 332 | + | name: &str, |
|
| 333 | + | access: Access, |
|
| 334 | + | ) -> Result<(crate::repos::RepoName, db::repos::RepoRow, Role), Error> { |
|
| 214 | 335 | let repo = crate::repos::RepoName::new(owner, name).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 215 | - | let row = db::repos::get(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 216 | - | match row { |
|
| 217 | - | Some(r) if r.visibility() == hub_api::Visibility::Public => Ok((repo, r)), |
|
| 218 | - | _ => Err(Error::not_found(shell.clone())), |
|
| 336 | + | let row = db::repos::get(&app.db, owner, name) |
|
| 337 | + | .await |
|
| 338 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 339 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 340 | + | let role = auth::roles::role_for(&app.db, viewer.account(), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 341 | + | if !auth::roles::allows(role, access) { |
|
| 342 | + | return Err(Error::not_found(shell.clone())); |
|
| 343 | + | } |
|
| 344 | + | Ok((repo, row, role.unwrap_or(Role::Read))) |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | /// `lookup_role` for reading. |
|
| 348 | + | pub async fn lookup( |
|
| 349 | + | app: &Shared, |
|
| 350 | + | shell: &Shell, |
|
| 351 | + | viewer: &Viewer, |
|
| 352 | + | owner: &str, |
|
| 353 | + | name: &str, |
|
| 354 | + | ) -> Result<(crate::repos::RepoName, db::repos::RepoRow, Role), Error> { |
|
| 355 | + | lookup_role(app, shell, viewer, owner, name, Access::Read).await |
|
| 356 | + | } |
|
| 357 | + | ||
| 358 | + | /// Fill `account` on change authors whose key is linked to an account. |
|
| 359 | + | pub async fn attribute(app: &Shared, changes: &mut [&mut crate::repos::view::ChangeView]) { |
|
| 360 | + | if changes.is_empty() { |
|
| 361 | + | return; |
|
| 362 | + | } |
|
| 363 | + | let Ok(links) = db::accounts::identity_accounts(&app.db).await else { return }; |
|
| 364 | + | let map: std::collections::HashMap<String, String> = links.into_iter().collect(); |
|
| 365 | + | for c in changes.iter_mut() { |
|
| 366 | + | if let Some(u) = map.get(&c.author.key) { |
|
| 367 | + | c.author.account = Some(u.clone()); |
|
| 368 | + | } |
|
| 219 | 369 | } |
|
| 220 | 370 | } |
modified crates/hub/src/web/refs.rs +37 −20
| 1 | 1 | //! Bookmarks, tags, conflicts, trust and pushes pages. |
|
| 2 | 2 | ||
| 3 | 3 | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
|
| 4 | + | use crate::auth::Viewer; |
|
| 4 | 5 | use crate::repos::view; |
|
| 5 | 6 | use crate::{db, Shared}; |
|
| 6 | 7 | use askama::Template; |
|
| … | 7 unchanged lines |
||
| 14 | 15 | bookmarks: Vec<view::BookmarkView>, |
|
| 15 | 16 | } |
|
| 16 | 17 | ||
| 17 | - | pub async fn bookmarks(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<BookmarksPage>, Error> { |
|
| 18 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks")); |
|
| 19 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 18 | + | pub async fn bookmarks( |
|
| 19 | + | State(app): State<Shared>, |
|
| 20 | + | viewer: Viewer, |
|
| 21 | + | Path((owner, name)): Path<(String, String)>, |
|
| 22 | + | ) -> Result<Page<BookmarksPage>, Error> { |
|
| 23 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer); |
|
| 24 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 20 | 25 | let default = row.default_bookmark.clone(); |
|
| 21 | 26 | let bookmarks = |
|
| 22 | 27 | app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 23 | - | Ok(Page(BookmarksPage { shell, repo: RepoShell::new(&app, &row, "bookmarks"), bookmarks })) |
|
| 28 | + | Ok(Page(BookmarksPage { shell, repo: RepoShell::new(&app, &row, "bookmarks").with_role(role), bookmarks })) |
|
| 24 | 29 | } |
|
| 25 | 30 | ||
| 26 | 31 | #[derive(Template)] |
|
| … | 4 unchanged lines |
||
| 31 | 36 | tags: Vec<view::TagView>, |
|
| 32 | 37 | } |
|
| 33 | 38 | ||
| 34 | - | pub async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<TagsPage>, Error> { |
|
| 35 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/tags")); |
|
| 36 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 39 | + | pub async fn tags(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Page<TagsPage>, Error> { |
|
| 40 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer); |
|
| 41 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 37 | 42 | let tags = app.repos.read(&repo, |r, _| view::tags(r)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 38 | - | Ok(Page(TagsPage { shell, repo: RepoShell::new(&app, &row, "tags"), tags })) |
|
| 43 | + | Ok(Page(TagsPage { shell, repo: RepoShell::new(&app, &row, "tags").with_role(role), tags })) |
|
| 39 | 44 | } |
|
| 40 | 45 | ||
| 41 | 46 | #[derive(Template)] |
|
| … | 4 unchanged lines |
||
| 46 | 51 | conflicts: Vec<view::ConflictView>, |
|
| 47 | 52 | } |
|
| 48 | 53 | ||
| 49 | - | pub async fn conflicts(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<ConflictsPage>, Error> { |
|
| 50 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/conflicts")); |
|
| 51 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 54 | + | pub async fn conflicts( |
|
| 55 | + | State(app): State<Shared>, |
|
| 56 | + | viewer: Viewer, |
|
| 57 | + | Path((owner, name)): Path<(String, String)>, |
|
| 58 | + | ) -> Result<Page<ConflictsPage>, Error> { |
|
| 59 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/conflicts"), &viewer); |
|
| 60 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 52 | 61 | let conflicts = app.repos.read(&repo, view::conflicts).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 53 | - | Ok(Page(ConflictsPage { shell, repo: RepoShell::new(&app, &row, "conflicts"), conflicts })) |
|
| 62 | + | Ok(Page(ConflictsPage { shell, repo: RepoShell::new(&app, &row, "conflicts").with_role(role), conflicts })) |
|
| 54 | 63 | } |
|
| 55 | 64 | ||
| 56 | 65 | #[derive(Template)] |
|
| … | 4 unchanged lines |
||
| 61 | 70 | t: view::Trust, |
|
| 62 | 71 | } |
|
| 63 | 72 | ||
| 64 | - | pub async fn trust(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<TrustPage>, Error> { |
|
| 65 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/trust")); |
|
| 66 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 73 | + | pub async fn trust( |
|
| 74 | + | State(app): State<Shared>, |
|
| 75 | + | viewer: Viewer, |
|
| 76 | + | Path((owner, name)): Path<(String, String)>, |
|
| 77 | + | ) -> Result<Page<TrustPage>, Error> { |
|
| 78 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/trust"), &viewer); |
|
| 79 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 67 | 80 | let key = app.hub_key.key; |
|
| 68 | 81 | let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 69 | - | Ok(Page(TrustPage { shell, repo: RepoShell::new(&app, &row, "trust"), t })) |
|
| 82 | + | Ok(Page(TrustPage { shell, repo: RepoShell::new(&app, &row, "trust").with_role(role), t })) |
|
| 70 | 83 | } |
|
| 71 | 84 | ||
| 72 | 85 | #[derive(Template)] |
|
| … | 4 unchanged lines |
||
| 77 | 90 | pushes: Vec<db::repos::PushRow>, |
|
| 78 | 91 | } |
|
| 79 | 92 | ||
| 80 | - | pub async fn pushes(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<PushesPage>, Error> { |
|
| 81 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/pushes")); |
|
| 82 | - | let (_, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 93 | + | pub async fn pushes( |
|
| 94 | + | State(app): State<Shared>, |
|
| 95 | + | viewer: Viewer, |
|
| 96 | + | Path((owner, name)): Path<(String, String)>, |
|
| 97 | + | ) -> Result<Page<PushesPage>, Error> { |
|
| 98 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/pushes"), &viewer); |
|
| 99 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 83 | 100 | let pushes = db::repos::pushes(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 84 | - | Ok(Page(PushesPage { shell, repo: RepoShell::new(&app, &row, "pushes"), pushes })) |
|
| 101 | + | Ok(Page(PushesPage { shell, repo: RepoShell::new(&app, &row, "pushes").with_role(role), pushes })) |
|
| 85 | 102 | } |
modified crates/hub/src/web/repo.rs +26 −14
| 1 | 1 | //! Repository overview, tree, file, raw and blame pages. |
|
| 2 | 2 | ||
| 3 | 3 | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
|
| 4 | + | use crate::auth::Viewer; |
|
| 4 | 5 | use crate::render::{self, highlight}; |
|
| 5 | 6 | use crate::repos::view::{self, EntryKind, FileBody}; |
|
| 6 | 7 | use crate::Shared; |
|
| … | 11 unchanged lines |
||
| 18 | 19 | readme_html: Option<String>, |
|
| 19 | 20 | } |
|
| 20 | 21 | ||
| 21 | - | pub async fn overview(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<OverviewPage>, Error> { |
|
| 22 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}")); |
|
| 23 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 22 | + | pub async fn overview( |
|
| 23 | + | State(app): State<Shared>, |
|
| 24 | + | viewer: Viewer, |
|
| 25 | + | Path((owner, name)): Path<(String, String)>, |
|
| 26 | + | ) -> Result<Page<OverviewPage>, Error> { |
|
| 27 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer); |
|
| 28 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 24 | 29 | let default = row.default_bookmark.clone(); |
|
| 25 | 30 | let o = |
|
| 26 | 31 | app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 27 | - | let rs = RepoShell::new(&app, &row, "code"); |
|
| 32 | + | let rs = RepoShell::new(&app, &row, "code").with_role(role); |
|
| 28 | 33 | let readme_html = o.readme_html(&rs, &o.tree.target.label); |
|
| 29 | 34 | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html })) |
|
| 30 | 35 | } |
|
| … | 16 unchanged lines |
||
| 47 | 52 | readme_html: Option<String>, |
|
| 48 | 53 | } |
|
| 49 | 54 | ||
| 50 | - | pub async fn tree_root(State(app): State<Shared>, Path((owner, name, r)): Path<(String, String, String)>) -> Result<Page<TreePage>, Error> { |
|
| 51 | - | tree(State(app), Path((owner, name, r, String::new()))).await |
|
| 55 | + | pub async fn tree_root( |
|
| 56 | + | State(app): State<Shared>, |
|
| 57 | + | viewer: Viewer, |
|
| 58 | + | Path((owner, name, r)): Path<(String, String, String)>, |
|
| 59 | + | ) -> Result<Page<TreePage>, Error> { |
|
| 60 | + | tree(State(app), viewer, Path((owner, name, r, String::new()))).await |
|
| 52 | 61 | } |
|
| 53 | 62 | ||
| 54 | 63 | pub async fn tree( |
|
| 55 | 64 | State(app): State<Shared>, |
|
| 65 | + | viewer: Viewer, |
|
| 56 | 66 | Path((owner, name, r, path)): Path<(String, String, String, String)>, |
|
| 57 | 67 | ) -> Result<Page<TreePage>, Error> { |
|
| 58 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/tree/{r}/{path}")); |
|
| 59 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 68 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/tree/{r}/{path}"), &viewer); |
|
| 69 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 60 | 70 | let (spec, dir) = (r.clone(), path.clone()); |
|
| 61 | 71 | let t = app |
|
| 62 | 72 | .repos |
|
| … | 3 unchanged lines |
||
| 66 | 76 | }) |
|
| 67 | 77 | .await |
|
| 68 | 78 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 69 | - | let rs = RepoShell::new(&app, &row, "code"); |
|
| 79 | + | let rs = RepoShell::new(&app, &row, "code").with_role(role); |
|
| 70 | 80 | let readme_html = t.readme.as_ref().map(|md| { |
|
| 71 | 81 | let dir = md.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default(); |
|
| 72 | 82 | render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/file/{r}/{dir}", rs.url)) |
|
| … | 36 unchanged lines |
||
| 109 | 119 | ||
| 110 | 120 | pub async fn file( |
|
| 111 | 121 | State(app): State<Shared>, |
|
| 122 | + | viewer: Viewer, |
|
| 112 | 123 | Path((owner, name, r, path)): Path<(String, String, String, String)>, |
|
| 113 | 124 | Query(q): Query<FileQuery>, |
|
| 114 | 125 | ) -> Result<Response, Error> { |
|
| 115 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/file/{r}/{path}")); |
|
| 116 | - | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 126 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/file/{r}/{path}"), &viewer); |
|
| 127 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 117 | 128 | let limit = app.config.limits.render_limit.0; |
|
| 118 | 129 | let want_blame = q.blame.is_some(); |
|
| 119 | 130 | let (spec, p) = (r.clone(), path.clone()); |
|
| … | 13 unchanged lines |
||
| 133 | 144 | if q.plain.is_some() { |
|
| 134 | 145 | return Ok(super::raw(f.bytes, &f.name, true)); |
|
| 135 | 146 | } |
|
| 136 | - | let rs = RepoShell::new(&app, &row, "code"); |
|
| 147 | + | let rs = RepoShell::new(&app, &row, "code").with_role(role); |
|
| 137 | 148 | let is_image = super::mime_of(&f.name).starts_with("image/"); |
|
| 138 | 149 | let is_markdown = f.name.to_lowercase().ends_with(".md") || f.name.to_lowercase() == "readme"; |
|
| 139 | 150 | let want_source = q.view.as_deref() == Some("source") || blame.is_some(); |
|
| … | 45 unchanged lines |
||
| 185 | 196 | /// path, fetched by the tree page after it paints (hub spec §6.5). |
|
| 186 | 197 | pub async fn last_changes( |
|
| 187 | 198 | State(app): State<Shared>, |
|
| 199 | + | viewer: Viewer, |
|
| 188 | 200 | Path((owner, name, r)): Path<(String, String, String)>, |
|
| 189 | 201 | Query(q): Query<LastQuery>, |
|
| 190 | 202 | ) -> Result<Response, Error> { |
|
| 191 | - | let shell = Shell::new(&app, ""); |
|
| 192 | - | let (repo, _) = lookup(&app, &shell, &owner, &name).await?; |
|
| 203 | + | let shell = Shell::new(&app, "", &viewer); |
|
| 204 | + | let (repo, _, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 193 | 205 | let paths: Vec<String> = q.paths.split(',').filter(|p| !p.is_empty()).take(500).map(String::from).collect(); |
|
| 194 | 206 | let spec = r.clone(); |
|
| 195 | 207 | let found = app |
|
| … | 27 unchanged lines |
modified crates/hub/tests/browse.rs +2 −1
| … | 72 unchanged lines |
||
| 73 | 73 | // A signing identity, so changes and the tag travel signed. |
|
| 74 | 74 | client.create_identity("Test Author", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
|
| 75 | 75 | client.config.set("remote.origin.url", url).unwrap(); |
|
| 76 | + | client.config.set("remote.origin.token", "site-token").unwrap(); |
|
| 76 | 77 | client.config.set("core.sign", "change").unwrap(); |
|
| 77 | 78 | client.save_config().unwrap(); |
|
| 78 | 79 | write(work, "README.md", b"# Demo\n\nSee [docs](docs/guide.md).\n"); |
|
| … | 18 unchanged lines |
||
| 97 | 98 | ||
| 98 | 99 | #[tokio::test(flavor = "multi_thread")] |
|
| 99 | 100 | async fn create_push_and_browse() { |
|
| 100 | - | let h = hub(vec![]).await; |
|
| 101 | + | let h = hub(vec!["site-token".into()]).await; |
|
| 101 | 102 | let name = RepoName::parse("andy/demo").unwrap(); |
|
| 102 | 103 | h.app.repos.create(&name, None).unwrap(); |
|
| 103 | 104 | db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap(); |
|
| … | 119 unchanged lines |
modified docs/plan.md +12 −2
| … | 15 unchanged lines |
||
| 16 | 16 | - [x] Oxygen changes it needed (in the Oxygen plan, phase 4): bare repositories, embeddable server, unbound repositories, adoption of anonymous changes by seals, `oxy seal`. |
|
| 17 | 17 | - [ ] Developer edition running on Earth with `oxygen`, `oxygen-vscode` and `oxygenhub` hosted (docs/deploy.md). |
|
| 18 | 18 | ||
| 19 | - | ## 0.2 Accounts and access |
|
| 19 | + | ## 0.2 Accounts and access (2026-09-27) |
|
| 20 | 20 | ||
| 21 | - | - [ ] Registration, sessions, TOTP, profile, organisations, teams, roles, visibility enforcement by viewer, personal access tokens with scopes, linked identities with challenge signing (`oxy identity sign-challenge`), audit log, settings pages, pushes attributed to keys. |
|
| 21 | + | - [x] Schema: accounts, organisations, members, teams, collaborators, tokens, identities, challenges, invitations, settings, audit; pushes carry the pusher. |
|
| 22 | + | - [x] Passwords (Argon2id), signed session cookie bound to a per-account salt, CSRF tokens on every form, same-origin check on every non-GET request, TOTP with recovery codes, sign out everywhere. |
|
| 23 | + | - [x] Registration modes (open, invite, closed; first account is the administrator), invitations, admin page (accounts, ban, promote, registration mode, audit log). |
|
| 24 | + | - [x] Roles: owner and site admin → admin; organisation owners → admin, members → the organisation's default role; collaborators (accounts and teams) → their role; visibility public/internal/private decides the anonymous and signed-in floor. A repository the viewer may not read is a 404 everywhere, including the protocol. |
|
| 25 | + | - [x] Personal access tokens: hashed, scoped (`repo:read`, `repo:write`, `repo:admin`, `user:read`), optionally limited to repositories, with expiry; the protocol checks role and scope; site tokens from `hub.toml` remain for bootstrapping. |
|
| 26 | + | - [x] Linked identities: upload `oxy identity export`, sign the challenge with `oxy identity sign-challenge`, verified with `verify_challenge`; authors whose key is linked show as accounts on history, change and compare pages. |
|
| 27 | + | - [x] Pages: sign in, register, profile, security, keys, tokens, new repository, repository settings (general, collaborators, delete), organisation settings (members, teams), admin. |
|
| 28 | + | - [x] CLI: `admin user create|reset-password|list`. |
|
| 29 | + | - [x] Tests: `tests/accounts.rs` drives the whole flow through the router and the wire protocol. |
|
| 30 | + | - [ ] Email verification and password reset by email (needs SMTP, hub 0.4 with notifications). |
|
| 31 | + | - [ ] Passkeys (1.0). |
|
| 22 | 32 | ||
| 23 | 33 | ## Later |
|
| 24 | 34 | ||
| … | 12 unchanged lines |
modified templates/base.html +10 −1
| … | 12 unchanged lines |
||
| 13 | 13 | <a class="brand" href="/"><svg xmlns="http://www.w3.org/2000/svg" viewBox="-24 -12 48 24" width="48" height="24" aria-hidden="true"><defs><linearGradient id="oxyGradH" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00F0FF"/><stop offset="100%" stop-color="#0072FF"/></linearGradient></defs><path d="M -11 -6 L 11 -6" stroke="url(#oxyGradH)" stroke-width="3" stroke-linecap="round"/><path d="M -11 6 L 11 6" stroke="url(#oxyGradH)" stroke-width="3" stroke-linecap="round"/><circle cx="-11" cy="0" r="10" fill="none" stroke="url(#oxyGradH)" stroke-width="3"/><circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/><circle cx="11" cy="0" r="10" fill="none" stroke="url(#oxyGradH)" stroke-width="3"/><circle cx="11" cy="0" r="3.5" fill="#FFFFFF"/></svg><span class="px">OXYGEN<em>HUB</em></span></a> |
|
| 14 | 14 | <form class="find" action="/search" method="get" role="search"><input type="search" name="q" placeholder="Search…" aria-label="Search" autocomplete="off"></form> |
|
| 15 | 15 | <nav> |
|
| 16 | - | <a class="go" href="/explore">EXPLORE</a> |
|
| 16 | + | {% if let Some(u) = shell.user %} |
|
| 17 | + | <a class="go" href="/new" title="New repository">+ NEW</a> |
|
| 18 | + | <a class="me" href="/{{ u }}">{{ u }}</a> |
|
| 19 | + | <form class="inline" method="post" action="/logout"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>SIGN OUT</button></form> |
|
| 20 | + | {% else %} |
|
| 21 | + | <a class="go" href="/login">SIGN IN</a> |
|
| 22 | + | <a class="go" href="/register">REGISTER</a> |
|
| 23 | + | {% endif %} |
|
| 17 | 24 | </nav> |
|
| 18 | 25 | </header> |
|
| 19 | 26 | <div class="band"> |
|
| 20 | 27 | <a href="/">Repositories</a> |
|
| 21 | 28 | <a href="/explore">Explore</a> |
|
| 29 | + | {% if shell.user.is_some() %}<a href="/settings">Settings</a>{% endif %} |
|
| 22 | 30 | <span class="grow"></span> |
|
| 31 | + | {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %} |
|
| 23 | 32 | <a class="dim" href="/{{ shell.version }}" title="Oxygenhub {{ shell.version }}" tabindex="-1">v{{ shell.version }}</a> |
|
| 24 | 33 | </div> |
|
| 25 | 34 | {% block tabs %}{% endblock %} |
|
| … | 22 unchanged lines |
modified templates/change.html +1 −1
| … | 8 unchanged lines |
||
| 9 | 9 | <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small></h1></div> |
|
| 10 | 10 | {{ body_html|safe }} |
|
| 11 | 11 | <dl class="facts"> |
|
| 12 | - | <dt>Author</dt><dd>{{ c.view.author.name }}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if c.view.author.unknown && !c.view.author.key.is_empty() %} <span class="tag">unlinked key</span>{% endif %}</dd> |
|
| 12 | + | <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if c.view.author.unknown && !c.view.author.key.is_empty() %} <span class="tag">unlinked key</span>{% endif %}</dd> |
|
| 13 | 13 | <dt>When</dt><dd><time title="{{ c.view.when_ms|when }}">{{ c.view.when_ms|ago }}</time> · {{ c.view.when_ms|when }}</dd> |
|
| 14 | 14 | <dt>Signature</dt><dd>{% call m::sig(c.view.signature) %} {{ c.view.signature.title() }}</dd> |
|
| 15 | 15 | <dt>Hash</dt><dd><code class="full">{{ c.view.hash }}</code> <button class="small" data-copy="{{ c.view.hash }}">copy</button></dd> |
|
| … | 11 unchanged lines |
modified templates/pushes.html +2 −2
| … | 6 unchanged lines |
||
| 7 | 7 | <div class="head"><h1>Pushes <small>every reference update the hub accepted or refused</small></h1></div> |
|
| 8 | 8 | {% if pushes.is_empty() %}<p class="empty">Nothing has been pushed yet.</p>{% else %} |
|
| 9 | 9 | <div class="table"><table> |
|
| 10 | - | <tr><th>When</th><th>Reference</th><th>Result</th><th>Changes</th><th>Heads</th><th>State</th></tr> |
|
| 10 | + | <tr><th>When</th><th>Who</th><th>Reference</th><th>Result</th><th>Changes</th><th>Heads</th><th>State</th></tr> |
|
| 11 | 11 | {% for p in pushes %} |
|
| 12 | - | <tr><td><time title="{{ p.at_ms|when_i }}">{{ p.at_ms|ago_i }}</time></td><td class="px">{{ p.reference }}</td><td>{% if p.ok %}<span class="sig sig-valid">✔</span> accepted{% else %}<span class="sig sig-invalid">✘</span> refused: {{ p.reason }}{% endif %}</td><td>{% if p.ok %}{{ p.changes }}{% endif %}</td><td>{% if p.ok %}{{ p.heads }}{% endif %}</td><td class="small mute">{% if !p.new_state.is_empty() %}<code title="{{ p.old_state }} → {{ p.new_state }}">{{ p.new_state|short }}</code>{% endif %}</td></tr> |
|
| 12 | + | <tr><td><time title="{{ p.at_ms|when_i }}">{{ p.at_ms|ago_i }}</time></td><td>{% if p.pusher.is_empty() %}<span class="mute">anonymous</span>{% else if p.pusher == "site token" %}<span class="mute">site token</span>{% else %}<a href="/{{ p.pusher }}">{{ p.pusher }}</a>{% endif %}</td><td class="px">{{ p.reference }}</td><td>{% if p.ok %}<span class="sig sig-valid">✔</span> accepted{% else %}<span class="sig sig-invalid">✘</span> refused: {{ p.reason }}{% endif %}</td><td>{% if p.ok %}{{ p.changes }}{% endif %}</td><td>{% if p.ok %}{{ p.heads }}{% endif %}</td><td class="small mute">{% if !p.new_state.is_empty() %}<code title="{{ p.old_state }} → {{ p.new_state }}">{{ p.new_state|short }}</code>{% endif %}</td></tr> |
|
| 13 | 13 | {% endfor %} |
|
| 14 | 14 | </table></div> |
|
| 15 | 15 | {% endif %} |
|
| … | 1 unchanged lines |
modified templates/repo_tabs.html +2 −1
| … | 8 unchanged lines |
||
| 9 | 9 | <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on"{% endif %}>Conflicts</a> |
|
| 10 | 10 | <a href="{{ repo.url }}/trust"{% if tab == "trust" %} class="on"{% endif %}>Trust</a> |
|
| 11 | 11 | <a href="{{ repo.url }}/pushes"{% if tab == "pushes" %} class="on"{% endif %}>Pushes</a> |
|
| 12 | + | {% if repo.can_maintain() %}<a href="{{ repo.url }}/settings"{% if tab == "settings" %} class="on"{% endif %}>Settings</a>{% endif %} |
|
| 12 | 13 | </div> |
|
| 13 | 14 | </div> |
|
| 14 | 15 | {% endmacro %} |
|
| … | 9 unchanged lines |
||
| 24 | 25 | {% call labels(c) %} |
|
| 25 | 26 | {% if c.resolution %}<span class="tag">resolution</span>{% endif %} |
|
| 26 | 27 | {% if c.head %}<span class="tag head">head</span>{% endif %} |
|
| 27 | - | <div class="meta"><span class="who">{{ c.author.name }}</span> · <time title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time></div> |
|
| 28 | + | <div class="meta"><span class="who">{% if let Some(a) = c.author.account %}<a href="/{{ a }}">{{ c.author.name }}</a>{% else %}{{ c.author.name }}{% endif %}</span> · <time title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time></div> |
|
| 28 | 29 | </div> |
|
| 29 | 30 | <div class="chg-side"> |
|
| 30 | 31 | {% call sig(c.signature) %} |
|
| … | 28 unchanged lines |
modified templates/style.css +27 −1
| … | 56 unchanged lines |
||
| 57 | 57 | .brand svg { height: 22px; width: auto; } |
|
| 58 | 58 | .brand em { font-style: normal; color: var(--acc); } |
|
| 59 | 59 | .brand:hover { text-decoration: none; } |
|
| 60 | - | header nav { margin-left: auto; display: flex; gap: 8px; } |
|
| 60 | + | header nav { margin-left: auto; display: flex; gap: 8px; align-items: center; } |
|
| 61 | + | header nav .me { color: var(--ink); font-weight: 600; padding: 0 6px; } |
|
| 62 | + | form.inline { display: inline; margin: 0; } |
|
| 63 | + | .band a.admin { color: var(--acc); } |
|
| 61 | 64 | form.find { margin: 0; flex: 0 1 320px; } |
|
| 62 | 65 | form.find input { width: 100%; background: var(--bg); border: 1px solid var(--line); color: var(--ink); padding: 6px 10px; font: inherit; } |
|
| 63 | 66 | form.find input:focus { outline: 2px solid var(--band); outline-offset: 0; } |
|
| … | 162 unchanged lines |
||
| 226 | 229 | .source .markup.italic { font-style: italic; } |
|
| 227 | 230 | .source .markup.raw { color: var(--sy-str); } |
|
| 228 | 231 | .source .markup.underline.link { color: var(--sy-fn); } |
|
| 232 | + | ||
| 233 | + | /* forms */ |
|
| 234 | + | form.card { max-width: 460px; background: var(--panel); border: 1px solid var(--line); padding: 18px 20px; margin: 0 0 16px; } |
|
| 235 | + | form.card.wide { max-width: 720px; } |
|
| 236 | + | form.card label, .field label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); margin: 10px 0 4px; } |
|
| 237 | + | form.card input[type=text], form.card input[type=password], form.card input[type=email], form.card input[type=search], form.card input[type=number], form.card textarea, form.card select, |
|
| 238 | + | .field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--line); color: var(--ink); padding: 7px 10px; font: inherit; } |
|
| 239 | + | form.card input:focus, form.card textarea:focus, form.card select:focus { outline: 2px solid var(--band); outline-offset: 0; } |
|
| 240 | + | form.card button[type=submit], form.card .primary { background: var(--acc); color: var(--now-ink); border: 0; margin-top: 12px; } |
|
| 241 | + | form.card .row { display: flex; gap: 10px; align-items: end; } |
|
| 242 | + | form.card .row > * { flex: 1; } |
|
| 243 | + | form.card .choice { display: flex; gap: 12px; flex-wrap: wrap; margin: 6px 0; } |
|
| 244 | + | form.card .choice label { display: inline-flex; gap: 6px; align-items: center; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--ink); margin: 0; } |
|
| 245 | + | .note { color: var(--ok); } |
|
| 246 | + | .error { color: var(--bad); } |
|
| 247 | + | details.danger { border: 1px solid var(--bad); padding: 10px 14px; margin-top: 18px; } |
|
| 248 | + | details.danger summary { color: var(--bad); cursor: pointer; font-weight: 600; } |
|
| 249 | + | .secret { font-family: ui-monospace, monospace; background: var(--bg); border: 1px solid var(--acc); padding: 8px 10px; word-break: break-all; } |
|
| 250 | + | .qr { background: #fff; display: inline-block; padding: 8px; } |
|
| 251 | + | ul.codes { columns: 2; font-family: ui-monospace, monospace; } |
|
| 252 | + | table.small td, table.small th { padding: 5px 8px; font-size: 13px; } |
|
| 253 | + | form.tiny { display: inline; margin: 0; } |
|
| 254 | + | form.tiny button { padding: 1px 6px; font-size: 10px; } |