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.

324 linesCodeBlameRaw
1//! Staff-only views of workspaces, for sudo.g1t.sh.
2//!
3//! These methods take no viewer and check no membership. Only sudo calls
4//! them, over its service binding, once it has verified a Cloudflare Access
5//! sign-in against its staff list; nothing a customer can reach forwards to
6//! them. They read, and never change, anything.
7//!
8//! Every workspace is listed: there is one kind, and a person's own space
9//! is simply a workspace with one member. A user with no workspace has
10//! nothing to list, as nothing can exist outside one.
11
12use std::collections::HashMap;
13
14use g1t_contracts::Role;
15use g1t_contracts::identity::*;
16use serde::Deserialize;
17use worker::Result;
18
19use crate::Identity;
20
21#[derive(Deserialize)]
22struct ListRow {
23 id: String,
24 slug: String,
25 name: String,
26 created_at: String,
27 member_count: u32,
28}
29
30#[derive(Deserialize)]
31struct OwnerRow {
32 workspace_id: String,
33 username: String,
34 email: Option<String>,
35}
36
37#[derive(Deserialize)]
38struct DetailRow {
39 id: String,
40 slug: String,
41 name: String,
42 description: Option<String>,
43 created_at: String,
44 #[serde(default)]
45 protected: u8,
46}
47
48#[derive(Deserialize)]
49struct MemberRow {
50 username: String,
51 email: Option<String>,
52 role: Role,
53 joined: String,
54}
55
56/// A `LIKE` pattern matching `query` anywhere, lowercased, with `%`, `_`
57/// and the escape character itself taken literally. None for a blank query.
58pub(crate) fn like_pattern(query: Option<&str>) -> Option<String> {
59 let query = query.map(str::trim).filter(|q| !q.is_empty())?;
60 let mut pattern = String::from("%");
61 for c in query.to_lowercase().chars().take(100) {
62 if matches!(c, '%' | '_' | '\\') {
63 pattern.push('\\');
64 }
65 pattern.push(c);
66 }
67 pattern.push('%');
68 Some(pattern)
69}
70
71/// `admin_growth`'s one statement: for people and for workspaces, the
72/// total, how many are from the last day, week and the week before, and
73/// the newest. Deleted ones are left out.
74fn growth_sql() -> String {
75 const AGO: &str = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ";
76 let part = |prefix: &str, table: &str, name: &str| {
77 let live = format!("FROM {table} WHERE deleted_at IS NULL");
78 format!(
79 "(SELECT count(*) {live}) AS {prefix},
80 (SELECT count(*) {live} AND created_at >= {AGO}'-1 day')) AS {prefix}_day,
81 (SELECT count(*) {live} AND created_at >= {AGO}'-7 days')) AS {prefix}_week,
82 (SELECT count(*) {live} AND created_at >= {AGO}'-14 days') AND created_at < {AGO}'-7 days')) AS {prefix}_prior,
83 (SELECT {name} {live} ORDER BY created_at DESC LIMIT 1) AS {prefix}_newest,
84 (SELECT created_at {live} ORDER BY created_at DESC LIMIT 1) AS {prefix}_newest_at"
85 )
86 };
87 format!(
88 "SELECT {}, {}",
89 part("people", "users", "coalesce(display_username, username)"),
90 part("workspaces", "workspaces", "slug")
91 )
92}
93
94/// The workspaces, in their order, each with its owners.
95fn with_owners(rows: Vec<ListRow>, owners: Vec<OwnerRow>) -> Vec<AdminWorkspace> {
96 let mut by_workspace: HashMap<String, Vec<AdminOwner>> = HashMap::new();
97 for owner in owners {
98 by_workspace.entry(owner.workspace_id).or_default().push(AdminOwner {
99 username: owner.username,
100 email: owner.email,
101 });
102 }
103 rows.into_iter()
104 .map(|row| AdminWorkspace {
105 owners: by_workspace.remove(&row.id).unwrap_or_default(),
106 slug: row.slug,
107 name: row.name,
108 created_at: row.created_at,
109 member_count: row.member_count,
110 })
111 .collect()
112}
113
114impl Identity {
115 /// The workspaces staff can see, newest first. Staff only: see the
116 /// module's note.
117 /// Emails each owner of the workspace with a confirmed address. Only
118 /// other services call this; the link must stay on g1t.sh, so a notice
119 /// can never point owners anywhere else.
120 pub async fn notify_owners(&self, a: NotifyOwnersArgs) -> Result<u32> {
121 if !a.link.starts_with("https://g1t.sh/") {
122 return Ok(0);
123 }
124 #[derive(Deserialize)]
125 struct Owner {
126 email: Option<String>,
127 }
128 let owners = self
129 .db
130 .prepare(
131 "SELECT u.email FROM workspace_members m
132 JOIN users u ON u.id = m.user_id
133 JOIN workspaces w ON w.id = m.workspace_id
134 WHERE w.slug = ? AND w.deleted_at IS NULL AND m.role = 'owner' AND u.email_verified_at IS NOT NULL",
135 )
136 .bind(&[a.workspace.to_lowercase().into()])?
137 .all()
138 .await?
139 .results::<Owner>()?;
140 let mut sent = 0;
141 for email in owners.into_iter().filter_map(|owner| owner.email) {
142 match crate::email::send_link(&self.env, &email, &a.subject, &a.intro, &a.action, &a.link, &a.footer).await {
143 Ok(()) => sent += 1,
144 Err(error) => worker::console_error!("could not email an owner of {}: {error}", a.workspace),
145 }
146 }
147 Ok(sent)
148 }
149
150 /// How many people and workspaces there are and how many are new, in
151 /// one round trip. Staff only: see the module's note.
152 pub async fn admin_growth(&self) -> Result<AdminGrowth> {
153 #[derive(Deserialize)]
154 struct Row {
155 people: u32,
156 people_day: u32,
157 people_week: u32,
158 people_prior: u32,
159 people_newest: Option<String>,
160 people_newest_at: Option<String>,
161 workspaces: u32,
162 workspaces_day: u32,
163 workspaces_week: u32,
164 workspaces_prior: u32,
165 workspaces_newest: Option<String>,
166 workspaces_newest_at: Option<String>,
167 }
168 // created_at is written as strftime('%Y-%m-%dT%H:%M:%fZ'), so the
169 // same format compares as text.
170 let sql = growth_sql();
171 let row = self.db.prepare(sql).first::<Row>(None).await?;
172 Ok(row.map_or_else(AdminGrowth::default, |r| AdminGrowth {
173 people: GrowthCount {
174 total: r.people,
175 day: r.people_day,
176 week: r.people_week,
177 prior_week: r.people_prior,
178 newest: r.people_newest,
179 newest_at: r.people_newest_at,
180 },
181 workspaces: GrowthCount {
182 total: r.workspaces,
183 day: r.workspaces_day,
184 week: r.workspaces_week,
185 prior_week: r.workspaces_prior,
186 newest: r.workspaces_newest,
187 newest_at: r.workspaces_newest_at,
188 },
189 }))
190 }
191
192 pub async fn admin_workspaces(&self, a: AdminWorkspacesArgs) -> Result<Vec<AdminWorkspace>> {
193 let pattern = like_pattern(a.query.as_deref());
194 // The same filter picks the workspaces and, below, their owners.
195 // Deleted ones are listed apart (`admin_deleted_workspaces`).
196 let filter = if pattern.is_some() {
197 "WHERE w.deleted_at IS NULL AND (w.slug LIKE ?1 ESCAPE '\\' OR lower(w.name) LIKE ?1 ESCAPE '\\'
198 OR EXISTS (SELECT 1 FROM workspace_members om JOIN users ou ON ou.id = om.user_id
199 WHERE om.workspace_id = w.id AND om.role = 'owner'
200 AND (lower(ou.username) LIKE ?1 ESCAPE '\\' OR lower(ou.email) LIKE ?1 ESCAPE '\\')))"
201 } else {
202 "WHERE w.deleted_at IS NULL"
203 };
204 let chosen = format!(
205 "SELECT w.id FROM workspaces w {filter}
206 ORDER BY w.created_at DESC, w.slug LIMIT {ADMIN_WORKSPACES_LIMIT}"
207 );
208 let binds: Vec<worker::wasm_bindgen::JsValue> = pattern.into_iter().map(Into::into).collect();
209 let rows = self
210 .db
211 .prepare(format!(
212 "SELECT w.id, w.slug, w.name, w.created_at,
213 (SELECT count(*) FROM workspace_members m WHERE m.workspace_id = w.id) AS member_count
214 FROM workspaces w WHERE w.id IN ({chosen})
215 ORDER BY w.created_at DESC, w.slug"
216 ))
217 .bind(&binds)?
218 .all()
219 .await?
220 .results::<ListRow>()?;
221 let owners = self
222 .db
223 .prepare(format!(
224 "SELECT m.workspace_id, u.username, u.email FROM workspace_members m
225 JOIN users u ON u.id = m.user_id
226 WHERE m.role = 'owner' AND m.workspace_id IN ({chosen})
227 ORDER BY m.created_at, u.username"
228 ))
229 .bind(&binds)?
230 .all()
231 .await?
232 .results::<OwnerRow>()?;
233 Ok(with_owners(rows, owners))
234 }
235
236 /// One workspace with every member, or None. Staff only: see the
237 /// module's note.
238 pub async fn admin_workspace(&self, a: SlugArgs) -> Result<Option<AdminWorkspaceDetail>> {
239 let Some(row) = self
240 .db
241 .prepare("SELECT id, slug, name, description, created_at, protected FROM workspaces WHERE slug = ?")
242 .bind(&[a.slug.trim().to_lowercase().into()])?
243 .first::<DetailRow>(None)
244 .await?
245 else {
246 return Ok(None);
247 };
248 let members = self
249 .db
250 .prepare(
251 "SELECT u.username, u.email, m.role, m.created_at AS joined FROM workspace_members m
252 JOIN users u ON u.id = m.user_id
253 WHERE m.workspace_id = ?
254 ORDER BY m.role DESC, u.username",
255 )
256 .bind(&[row.id.as_str().into()])?
257 .all()
258 .await?
259 .results::<MemberRow>()?
260 .into_iter()
261 .map(|m| AdminMember {
262 username: m.username,
263 email: m.email,
264 role: m.role,
265 joined: m.joined,
266 })
267 .collect();
268 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
269 let suspended = self.workspace_suspension(&row.slug).await?;
270 Ok(Some(AdminWorkspaceDetail {
271 protected,
272 suspended,
273 slug: row.slug,
274 name: row.name,
275 description: row.description,
276 created_at: row.created_at,
277 members,
278 }))
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn a_blank_query_matches_everything() {
288 assert_eq!(like_pattern(None), None);
289 assert_eq!(like_pattern(Some(" ")), None);
290 }
291
292 #[test]
293 fn a_query_is_matched_literally_anywhere() {
294 assert_eq!(like_pattern(Some(" Acme ")).as_deref(), Some("%acme%"));
295 assert_eq!(like_pattern(Some("50%_off\\")).as_deref(), Some("%50\\%\\_off\\\\%"));
296 }
297
298 #[test]
299 fn owners_go_to_their_workspaces_in_order() {
300 let row = |id: &str, slug: &str| ListRow {
301 id: id.into(),
302 slug: slug.into(),
303 name: slug.into(),
304 created_at: "2026-10-04T00:00:00Z".into(),
305 member_count: 2,
306 };
307 let owner = |workspace_id: &str, username: &str| OwnerRow {
308 workspace_id: workspace_id.into(),
309 username: username.into(),
310 email: Some(format!("{username}@example.com")),
311 };
312 let listed = with_owners(
313 vec![row("wsp_2", "newer"), row("wsp_1", "older"), row("wsp_3", "orphan")],
314 vec![owner("wsp_1", "ada"), owner("wsp_2", "bob"), owner("wsp_1", "cy")],
315 );
316 let slugs: Vec<_> = listed.iter().map(|w| w.slug.as_str()).collect();
317 assert_eq!(slugs, ["newer", "older", "orphan"]);
318 let owners = |i: usize| listed[i].owners.iter().map(|o| o.username.as_str()).collect::<Vec<_>>();
319 assert_eq!(owners(0), ["bob"]);
320 assert_eq!(owners(1), ["ada", "cy"]);
321 assert!(owners(2).is_empty());
322 assert_eq!(listed[1].owners[0].email.as_deref(), Some("ada@example.com"));
323 }
324}