Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
279 linesCodeBlameRaw
1//! A workspace's people: the directory of its members and teams, and each
2//! member's place in it (a title and what they own). See
3//! `g1t_contracts::people`.
4
5use g1t_contracts::audit::Surface;
6use g1t_contracts::people::*;
7use g1t_contracts::teams::{TeamRef, TeamRole};
8use g1t_contracts::{FailureCode, Outcome, Role};
9use serde::Deserialize;
10use worker::Result;
11use worker::wasm_bindgen::JsValue;
12
13use crate::Identity;
14use crate::teams::{TeamRow, channel_of, may_see, role_of};
15
16const MEMBERS_ONLY: &str = "Only members can see who is in a workspace.";
17const NO_SUCH_MEMBER: &str = "There is no member with that username.";
18
19/// What someone owns, from its stored JSON; nothing when it is missing or
20/// not a list of words.
21pub fn owns_from(json: Option<&str>) -> Vec<String> {
22 json.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
23 .map(|owns| clean_owns(&owns))
24 .unwrap_or_default()
25}
26
27/// One member's row, as the directory reads it.
28#[derive(Deserialize)]
29struct PersonRow {
30 user_id: String,
31 username: String,
32 #[serde(default)]
33 display_username: Option<String>,
34 #[serde(default)]
35 name: Option<String>,
36 #[serde(default)]
37 avatar: Option<String>,
38 #[serde(default)]
39 bio: Option<String>,
40 #[serde(default)]
41 location: Option<String>,
42 #[serde(default)]
43 pronouns: Option<String>,
44 #[serde(default)]
45 timezone: Option<String>,
46 role: String,
47 #[serde(default)]
48 title: Option<String>,
49 #[serde(default)]
50 owns: Option<String>,
51 joined_at: String,
52}
53
54impl PersonRow {
55 fn person(self) -> DirectoryPerson {
56 let blank = |value: Option<String>| value.filter(|value| !value.trim().is_empty());
57 DirectoryPerson {
58 user_id: self.user_id,
59 display_username: self.display_username.filter(|shown| *shown != self.username),
60 username: self.username,
61 name: blank(self.name),
62 avatar: self.avatar,
63 bio: blank(self.bio),
64 location: blank(self.location),
65 pronouns: blank(self.pronouns),
66 timezone: blank(self.timezone),
67 role: if self.role == "owner" { Role::Owner } else { Role::Member },
68 title: self.title.as_deref().and_then(clean_title),
69 owns: owns_from(self.owns.as_deref()),
70 joined_at: self.joined_at,
71 }
72 }
73}
74
75/// Every member of a workspace (by slug, `?1`), with `filter` added.
76fn people_sql(filter: &str) -> String {
77 format!(
78 "SELECT u.id AS user_id, u.username, u.display_username, u.display_name AS name, u.avatar, u.bio, u.location,
79 u.pronouns, u.timezone, wm.role, wm.title, wm.owns, wm.created_at AS joined_at
80 FROM workspace_members wm
81 JOIN users u ON u.id = wm.user_id AND u.deleted_at IS NULL
82 JOIN workspaces w ON w.id = wm.workspace_id AND w.deleted_at IS NULL
83 WHERE w.slug = ?1 {filter}
84 ORDER BY lower(coalesce(u.display_name, u.username)) LIMIT 5000"
85 )
86}
87
88#[derive(Deserialize)]
89struct TeamPersonRow {
90 team_id: String,
91 username: String,
92 role: String,
93}
94
95#[derive(Deserialize)]
96struct BaseRow {
97 #[serde(default)]
98 base_permission: Option<String>,
99}
100
101impl Identity {
102 pub async fn people_directory(&self, a: PeopleArgs) -> Result<Outcome<PeopleDirectory>> {
103 let workspace = a.workspace.trim().to_lowercase();
104 let Some(viewer) = a.viewer.as_ref() else {
105 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
106 };
107 let Some(role) = role_of(viewer, &workspace) else {
108 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
109 };
110 let owner = role == Role::Owner;
111 let slug = JsValue::from(workspace.as_str());
112 let mut found = self
113 .db
114 .batch(vec![
115 self.db.prepare(people_sql("")).bind(std::slice::from_ref(&slug))?,
116 self.db
117 .prepare(
118 "SELECT tm.team_id, u.username, tm.role FROM team_members tm
119 JOIN teams t ON t.id = tm.team_id
120 JOIN workspaces w ON w.id = t.workspace_id
121 JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
122 WHERE w.slug = ?1 ORDER BY tm.role DESC, u.username LIMIT 20000",
123 )
124 .bind(std::slice::from_ref(&slug))?,
125 self.db
126 .prepare("SELECT base_permission FROM workspaces WHERE slug = ?1 AND deleted_at IS NULL")
127 .bind(std::slice::from_ref(&slug))?,
128 ])
129 .await?
130 .into_iter();
131 let mut next = || found.next().ok_or_else(|| worker::Error::RustError("a statement of the batch did not answer".into()));
132 let people = next()?.results::<PersonRow>()?;
133 let team_people = next()?.results::<TeamPersonRow>()?;
134 let base = next()?.results::<BaseRow>()?;
135 let rows = self.workspace_team_rows(&workspace, &viewer.id).await?;
136 let teams = rows
137 .iter()
138 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
139 .map(|row| DirectoryTeam {
140 slug: row.slug.clone(),
141 name: row.name.clone(),
142 description: row.description.clone(),
143 visibility: row.visibility(),
144 parent: match (&row.parent_slug, &row.parent_name) {
145 (Some(slug), Some(name)) => Some(TeamRef {
146 slug: slug.clone(),
147 name: name.clone(),
148 }),
149 _ => None,
150 },
151 lead: row.lead(),
152 channel: channel_of(row.channel_id.as_deref(), row.channel_name.as_deref()),
153 people: team_people
154 .iter()
155 .filter(|person| person.team_id == row.id)
156 .map(|person| TeamPersonRef {
157 username: person.username.clone(),
158 role: TeamRole::parse(&person.role).unwrap_or(TeamRole::Member),
159 })
160 .collect(),
161 repos_count: row.repos_count,
162 })
163 .collect();
164 Ok(Outcome::Ok(PeopleDirectory {
165 people: people.into_iter().map(PersonRow::person).collect(),
166 teams,
167 base_permission: base
168 .into_iter()
169 .next()
170 .and_then(|row| row.base_permission)
171 .filter(|base| !base.trim().is_empty())
172 .unwrap_or_else(|| "write".to_owned()),
173 can_manage: owner,
174 }))
175 }
176
177 pub async fn set_member_profile(&self, a: SetMemberProfileArgs) -> Result<Outcome<DirectoryPerson>> {
178 let workspace = a.workspace.trim().to_lowercase();
179 if !crate::security::is_person(&a.actor) {
180 return Ok(Outcome::fail(
181 FailureCode::Forbidden,
182 "Only a person can change a profile, signed in as themselves.",
183 ));
184 }
185 let Some(role) = role_of(&a.actor, &workspace) else {
186 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
187 };
188 if !a.actor.verified {
189 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
190 }
191 let owner = role == Role::Owner;
192 let Some(workspace_id) = self.workspace_id_of(&workspace).await? else {
193 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
194 };
195 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
196 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
197 };
198 if !self.is_member_of(&workspace_id, &user_id).await? {
199 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
200 }
201 let is_self = a.actor.id == user_id;
202 let mut sets: Vec<String> = Vec::new();
203 let mut binds: Vec<JsValue> = Vec::new();
204 let mut changed: Vec<&str> = Vec::new();
205 let refused = |what: &str| {
206 Ok(Outcome::fail(
207 FailureCode::Forbidden,
208 format!("Only owners of {workspace} can change {what} for someone else."),
209 ))
210 };
211 if let Some(title) = &a.title {
212 if !may_edit_profile(ProfileField::Title, is_self, owner) {
213 return refused("a title");
214 }
215 binds.push(clean_title(title).map_or(JsValue::NULL, JsValue::from));
216 sets.push(format!("title = ?{}", binds.len()));
217 changed.push("title");
218 }
219 if let Some(owns) = &a.owns {
220 if !may_edit_profile(ProfileField::Owns, is_self, owner) {
221 return refused("what someone owns");
222 }
223 let owns = clean_owns(owns);
224 binds.push(if owns.is_empty() { JsValue::NULL } else { serde_json::to_string(&owns)?.into() });
225 sets.push(format!("owns = ?{}", binds.len()));
226 changed.push("what they own");
227 }
228 if !sets.is_empty() {
229 binds.push(JsValue::from(workspace_id.clone()));
230 binds.push(JsValue::from(user_id.clone()));
231 self.db
232 .prepare(format!(
233 "UPDATE workspace_members SET {} WHERE workspace_id = ?{} AND user_id = ?{}",
234 sets.join(", "),
235 binds.len() - 1,
236 binds.len()
237 ))
238 .bind(&binds)?
239 .run()
240 .await?;
241 self.audit_workspace(
242 &a.actor,
243 "member.profile_edited",
244 &workspace,
245 a.surface.unwrap_or(Surface::Web),
246 format!("Changed {} for {username}", changed.join(", ")),
247 )
248 .await;
249 }
250 let person = self
251 .db
252 .prepare(people_sql("AND u.id = ?2"))
253 .bind(&[workspace.as_str().into(), user_id.as_str().into()])?
254 .first::<PersonRow>(None)
255 .await?;
256 Ok(match person {
257 Some(person) => Outcome::Ok(person.person()),
258 None => Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER),
259 })
260 }
261
262 /// Every team of a workspace, as `viewer_id` stands in each.
263 async fn workspace_team_rows(&self, workspace: &str, viewer_id: &str) -> Result<Vec<TeamRow>> {
264 self.team_rows("WHERE w.slug = ?2 ORDER BY lower(t.name)", &[viewer_id.into(), workspace.into()])
265 .await
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn what_someone_owns_reads_back_from_storage() {
275 assert_eq!(owns_from(Some(r#"["storefront"," Storefront ","releases"]"#)), vec!["storefront", "releases"]);
276 assert!(owns_from(Some("not json")).is_empty());
277 assert!(owns_from(None).is_empty());
278 }
279}