//! Domain types. Stored shapes are described in docs/m1-interfaces.md. pub type Id = String; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum Visibility { Public, Private, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum Role { Read, Triage, Write, Maintain, Admin, } /// What a `users` row stands for. An org is a row with `Org`; it can never authenticate. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum AccountKind { User, Org, } #[derive(Clone, PartialEq)] pub struct User { pub id: Id, pub username: String, pub email: Option, pub password_hash: String, pub is_admin: bool, pub created_at: String, /// Raw markdown, at most 2000 chars. pub about: String, /// IANA name, e.g. "Europe/Istanbul"; "UTC" by default. pub timezone: String, /// Hash of the current avatar, or `None` when the user has none. Filled by the user queries. pub avatar_hash: Option, /// Colour theme: "auto", "light" or "dark". Follows the browser when "auto". pub theme: String, /// Language code, e.g. "en"; "auto" uses the instance default. pub language: String, /// `User` for a person, `Org` for an organization. pub kind: AccountKind, /// Whether notifications are also sent by email (needs a verified email and instance mail). pub email_notifications: bool, } impl User { /// True for an organization row. Orgs never authenticate and never act as a person. pub fn is_org(&self) -> bool { self.kind == AccountKind::Org } } #[derive(Debug, Clone, PartialEq)] pub struct Repo { pub id: Id, pub owner_id: Id, pub owner_name: String, pub name: String, pub description: String, pub visibility: Visibility, pub default_branch: String, pub archived: bool, pub created_at: String, /// The repo this one was forked from. `None` for originals and for forks whose parent was /// deleted. pub fork_of: Option, } #[derive(Debug, Clone, PartialEq)] pub struct SshKey { pub id: Id, pub user_id: Id, pub title: String, pub algorithm: String, /// Raw SSH wire-format public key blob (the bytes base64-encoded in authorized_keys). pub key_blob: Vec, pub fingerprint: String, pub created_at: String, pub last_used_at: Option, } #[derive(Debug, Clone, PartialEq)] pub struct AccessToken { pub id: Id, pub user_id: Id, pub name: String, pub scopes: Vec, pub expires_at: Option, pub created_at: String, pub last_used_at: Option, } #[derive(Clone, PartialEq)] pub struct Session { pub id: Id, pub user_id: Id, pub csrf_token: String, pub created_at: String, pub expires_at: String, pub last_seen: String, pub user_agent: Option, } const REDACTED: &str = ""; impl std::fmt::Debug for User { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("User") .field("id", &self.id) .field("username", &self.username) .field("email", &self.email) .field("password_hash", &REDACTED) .field("is_admin", &self.is_admin) .field("created_at", &self.created_at) .field("about", &self.about) .field("timezone", &self.timezone) .field("avatar_hash", &self.avatar_hash) .field("theme", &self.theme) .field("language", &self.language) .field("kind", &self.kind) .finish() } } impl std::fmt::Debug for Session { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Session") .field("id", &self.id) .field("user_id", &self.user_id) .field("csrf_token", &REDACTED) .field("created_at", &self.created_at) .field("expires_at", &self.expires_at) .field("last_seen", &self.last_seen) .field("user_agent", &self.user_agent) .finish() } } impl Visibility { pub fn as_str(&self) -> &'static str { match self { Visibility::Public => "public", Visibility::Private => "private", } } } impl std::str::FromStr for Visibility { type Err = crate::error::Error; fn from_str(s: &str) -> Result { match s { "public" => Ok(Visibility::Public), "private" => Ok(Visibility::Private), other => Err(crate::error::Error::Invalid(format!( "visibility {other:?}" ))), } } } impl Role { pub fn as_str(&self) -> &'static str { match self { Role::Read => "read", Role::Triage => "triage", Role::Write => "write", Role::Maintain => "maintain", Role::Admin => "admin", } } } impl std::str::FromStr for Role { type Err = crate::error::Error; fn from_str(s: &str) -> Result { match s { "read" => Ok(Role::Read), "triage" => Ok(Role::Triage), "write" => Ok(Role::Write), "maintain" => Ok(Role::Maintain), "admin" => Ok(Role::Admin), other => Err(crate::error::Error::Invalid(format!("role {other:?}"))), } } } #[cfg(test)] mod tests { use super::*; #[test] fn debug_output_redacts_secrets() { let user = User { id: "u1".into(), username: "alice".into(), email: None, password_hash: "$argon2id$SECRETHASH".into(), is_admin: true, created_at: "now".into(), about: String::new(), timezone: "UTC".into(), avatar_hash: None, theme: "auto".into(), language: "auto".into(), kind: AccountKind::User, email_notifications: true, }; let session = Session { id: "s1".into(), user_id: "u1".into(), csrf_token: "CSRFSECRET".into(), created_at: "now".into(), expires_at: "later".into(), last_seen: "now".into(), user_agent: None, }; let dbg = format!("{user:?} {session:?}"); assert!(dbg.contains("\"\"")); assert!(!dbg.contains("SECRETHASH")); assert!(!dbg.contains("CSRFSECRET")); assert!(dbg.contains("alice")); } #[test] fn visibility_round_trips() { for v in [Visibility::Public, Visibility::Private] { assert_eq!(v.as_str().parse::().ok(), Some(v)); } assert!("Public".parse::().is_err()); } #[test] fn role_round_trips() { for r in [ Role::Read, Role::Triage, Role::Write, Role::Maintain, Role::Admin, ] { assert_eq!(r.as_str().parse::().ok(), Some(r)); } assert!("owner".parse::().is_err()); } }