forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.2 KiB
1//! Domain types. Stored shapes are described in docs/m1-interfaces.md.
2
3pub type Id = String;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6#[serde(rename_all = "lowercase")]
7pub enum Visibility {
8 Public,
9 Private,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub 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")]
25pub enum AccountKind {
26 User,
27 Org,
28}
29
30#[derive(Clone, PartialEq)]
31pub 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
54impl 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)]
62pub 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)]
78pub 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)]
91pub 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)]
102pub 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
112const REDACTED: &str = "<redacted>";
113
114impl 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
133impl 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
147impl Visibility {
148 pub fn as_str(&self) -> &'static str {
149 match self {
150 Visibility::Public => "public",
151 Visibility::Private => "private",
152 }
153 }
154}
155
156impl 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
170impl 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
182impl 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)]
198mod 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}