Tahinli/forge
public0 watching
★ 0
| 1 | //! Domain types. Stored shapes are described in docs/m1-interfaces.md. |
| 2 | |
| 3 | pub type Id = String; |
| 4 | |
| 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 6 | #[serde(rename_all = "lowercase")] |
| 7 | pub enum Visibility { |
| 8 | Public, |
| 9 | Private, |
| 10 | } |
| 11 | |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 13 | #[serde(rename_all = "lowercase")] |
| 14 | pub enum Role { |
| 15 | Read, |
| 16 | Triage, |
| 17 | Write, |
| 18 | Maintain, |
| 19 | Admin, |
| 20 | } |
| 21 | |
| 22 | /// What a `users` row stands for. An org is a row with `Org`; it can never authenticate. |
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 24 | #[serde(rename_all = "lowercase")] |
| 25 | pub enum AccountKind { |
| 26 | User, |
| 27 | Org, |
| 28 | } |
| 29 | |
| 30 | #[derive(Clone, PartialEq)] |
| 31 | pub struct User { |
| 32 | pub id: Id, |
| 33 | pub username: String, |
| 34 | pub email: Option<String>, |
| 35 | pub password_hash: String, |
| 36 | pub is_admin: bool, |
| 37 | pub created_at: String, |
| 38 | /// Raw markdown, at most 2000 chars. |
| 39 | pub about: String, |
| 40 | /// IANA name, e.g. "Europe/Istanbul"; "UTC" by default. |
| 41 | pub timezone: String, |
| 42 | /// Hash of the current avatar, or `None` when the user has none. Filled by the user queries. |
| 43 | pub avatar_hash: Option<String>, |
| 44 | /// Colour theme: "auto", "light" or "dark". Follows the browser when "auto". |
| 45 | pub theme: String, |
| 46 | /// Language code, e.g. "en"; "auto" uses the instance default. |
| 47 | pub language: String, |
| 48 | /// `User` for a person, `Org` for an organization. |
| 49 | pub kind: AccountKind, |
| 50 | /// Whether notifications are also sent by email (needs a verified email and instance mail). |
| 51 | pub email_notifications: bool, |
| 52 | } |
| 53 | |
| 54 | impl User { |
| 55 | /// True for an organization row. Orgs never authenticate and never act as a person. |
| 56 | pub fn is_org(&self) -> bool { |
| 57 | self.kind == AccountKind::Org |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | #[derive(Debug, Clone, PartialEq)] |
| 62 | pub struct Repo { |
| 63 | pub id: Id, |
| 64 | pub owner_id: Id, |
| 65 | pub owner_name: String, |
| 66 | pub name: String, |
| 67 | pub description: String, |
| 68 | pub visibility: Visibility, |
| 69 | pub default_branch: String, |
| 70 | pub archived: bool, |
| 71 | pub created_at: String, |
| 72 | /// The repo this one was forked from. `None` for originals and for forks whose parent was |
| 73 | /// deleted. |
| 74 | pub fork_of: Option<Id>, |
| 75 | } |
| 76 | |
| 77 | #[derive(Debug, Clone, PartialEq)] |
| 78 | pub struct SshKey { |
| 79 | pub id: Id, |
| 80 | pub user_id: Id, |
| 81 | pub title: String, |
| 82 | pub algorithm: String, |
| 83 | /// Raw SSH wire-format public key blob (the bytes base64-encoded in authorized_keys). |
| 84 | pub key_blob: Vec<u8>, |
| 85 | pub fingerprint: String, |
| 86 | pub created_at: String, |
| 87 | pub last_used_at: Option<String>, |
| 88 | } |
| 89 | |
| 90 | #[derive(Debug, Clone, PartialEq)] |
| 91 | pub struct AccessToken { |
| 92 | pub id: Id, |
| 93 | pub user_id: Id, |
| 94 | pub name: String, |
| 95 | pub scopes: Vec<String>, |
| 96 | pub expires_at: Option<String>, |
| 97 | pub created_at: String, |
| 98 | pub last_used_at: Option<String>, |
| 99 | } |
| 100 | |
| 101 | #[derive(Clone, PartialEq)] |
| 102 | pub struct Session { |
| 103 | pub id: Id, |
| 104 | pub user_id: Id, |
| 105 | pub csrf_token: String, |
| 106 | pub created_at: String, |
| 107 | pub expires_at: String, |
| 108 | pub last_seen: String, |
| 109 | pub user_agent: Option<String>, |
| 110 | } |
| 111 | |
| 112 | const REDACTED: &str = "<redacted>"; |
| 113 | |
| 114 | impl std::fmt::Debug for User { |
| 115 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 116 | f.debug_struct("User") |
| 117 | .field("id", &self.id) |
| 118 | .field("username", &self.username) |
| 119 | .field("email", &self.email) |
| 120 | .field("password_hash", &REDACTED) |
| 121 | .field("is_admin", &self.is_admin) |
| 122 | .field("created_at", &self.created_at) |
| 123 | .field("about", &self.about) |
| 124 | .field("timezone", &self.timezone) |
| 125 | .field("avatar_hash", &self.avatar_hash) |
| 126 | .field("theme", &self.theme) |
| 127 | .field("language", &self.language) |
| 128 | .field("kind", &self.kind) |
| 129 | .finish() |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | impl std::fmt::Debug for Session { |
| 134 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 135 | f.debug_struct("Session") |
| 136 | .field("id", &self.id) |
| 137 | .field("user_id", &self.user_id) |
| 138 | .field("csrf_token", &REDACTED) |
| 139 | .field("created_at", &self.created_at) |
| 140 | .field("expires_at", &self.expires_at) |
| 141 | .field("last_seen", &self.last_seen) |
| 142 | .field("user_agent", &self.user_agent) |
| 143 | .finish() |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | impl Visibility { |
| 148 | pub fn as_str(&self) -> &'static str { |
| 149 | match self { |
| 150 | Visibility::Public => "public", |
| 151 | Visibility::Private => "private", |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | impl std::str::FromStr for Visibility { |
| 157 | type Err = crate::error::Error; |
| 158 | |
| 159 | fn from_str(s: &str) -> Result<Self, Self::Err> { |
| 160 | match s { |
| 161 | "public" => Ok(Visibility::Public), |
| 162 | "private" => Ok(Visibility::Private), |
| 163 | other => Err(crate::error::Error::Invalid(format!( |
| 164 | "visibility {other:?}" |
| 165 | ))), |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | impl Role { |
| 171 | pub fn as_str(&self) -> &'static str { |
| 172 | match self { |
| 173 | Role::Read => "read", |
| 174 | Role::Triage => "triage", |
| 175 | Role::Write => "write", |
| 176 | Role::Maintain => "maintain", |
| 177 | Role::Admin => "admin", |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | impl std::str::FromStr for Role { |
| 183 | type Err = crate::error::Error; |
| 184 | |
| 185 | fn from_str(s: &str) -> Result<Self, Self::Err> { |
| 186 | match s { |
| 187 | "read" => Ok(Role::Read), |
| 188 | "triage" => Ok(Role::Triage), |
| 189 | "write" => Ok(Role::Write), |
| 190 | "maintain" => Ok(Role::Maintain), |
| 191 | "admin" => Ok(Role::Admin), |
| 192 | other => Err(crate::error::Error::Invalid(format!("role {other:?}"))), |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | #[cfg(test)] |
| 198 | mod tests { |
| 199 | use super::*; |
| 200 | |
| 201 | #[test] |
| 202 | fn debug_output_redacts_secrets() { |
| 203 | let user = User { |
| 204 | id: "u1".into(), |
| 205 | username: "alice".into(), |
| 206 | email: None, |
| 207 | password_hash: "$argon2id$SECRETHASH".into(), |
| 208 | is_admin: true, |
| 209 | created_at: "now".into(), |
| 210 | about: String::new(), |
| 211 | timezone: "UTC".into(), |
| 212 | avatar_hash: None, |
| 213 | theme: "auto".into(), |
| 214 | language: "auto".into(), |
| 215 | kind: AccountKind::User, |
| 216 | email_notifications: true, |
| 217 | }; |
| 218 | let session = Session { |
| 219 | id: "s1".into(), |
| 220 | user_id: "u1".into(), |
| 221 | csrf_token: "CSRFSECRET".into(), |
| 222 | created_at: "now".into(), |
| 223 | expires_at: "later".into(), |
| 224 | last_seen: "now".into(), |
| 225 | user_agent: None, |
| 226 | }; |
| 227 | let dbg = format!("{user:?} {session:?}"); |
| 228 | assert!(dbg.contains("\"<redacted>\"")); |
| 229 | assert!(!dbg.contains("SECRETHASH")); |
| 230 | assert!(!dbg.contains("CSRFSECRET")); |
| 231 | assert!(dbg.contains("alice")); |
| 232 | } |
| 233 | |
| 234 | #[test] |
| 235 | fn visibility_round_trips() { |
| 236 | for v in [Visibility::Public, Visibility::Private] { |
| 237 | assert_eq!(v.as_str().parse::<Visibility>().ok(), Some(v)); |
| 238 | } |
| 239 | assert!("Public".parse::<Visibility>().is_err()); |
| 240 | } |
| 241 | |
| 242 | #[test] |
| 243 | fn role_round_trips() { |
| 244 | for r in [ |
| 245 | Role::Read, |
| 246 | Role::Triage, |
| 247 | Role::Write, |
| 248 | Role::Maintain, |
| 249 | Role::Admin, |
| 250 | ] { |
| 251 | assert_eq!(r.as_str().parse::<Role>().ok(), Some(r)); |
| 252 | } |
| 253 | assert!("owner".parse::<Role>().is_err()); |
| 254 | } |
| 255 | } |