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
329 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1//! Profiles: what a person says about themselves, shown to anyone at
2//! `g1t.sh/u/<username>`.
3//!
4//! A profile is public by design, so nothing private goes into one: no
5//! email address, and no workspace the viewer has no other way to know the
6//! person belongs to (see `profile_workspaces`).
7
8use g1t_contracts::identity::*;
9use g1t_contracts::{FailureCode, Outcome, PrincipalKind};
10use serde::Deserialize;
11use worker::Result;
12use worker::wasm_bindgen::JsValue;
13
14use crate::Identity;
15
16#[derive(Deserialize)]
17struct ProfileRow {
18 username: String,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers19 #[serde(default)]
20 display_username: Option<String>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas21 display_name: Option<String>,
22 bio: Option<String>,
23 location: Option<String>,
24 website: Option<String>,
25 pronouns: Option<String>,
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)26 timezone: Option<String>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas27 avatar: Option<String>,
28 created_at: String,
29}
30
31impl From<ProfileRow> for Profile {
32 fn from(row: ProfileRow) -> Self {
33 Profile {
34 username: row.username,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers35 display_username: row.display_username,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas36 name: row.display_name,
37 bio: row.bio,
38 location: row.location,
39 website: row.website,
40 pronouns: row.pronouns,
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)41 timezone: row.timezone,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas42 avatar: row.avatar,
43 created_at: row.created_at,
44 }
45 }
46}
47
48const PROFILE_COLUMNS: &str =
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers49 "username, display_username, display_name, bio, location, website, pronouns, timezone, avatar, created_at";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas50
51/// A field as it is kept: whitespace runs made single spaces, control
52/// characters dropped, trimmed. Empty is none. Too long is refused.
53fn tidy(value: &str, max: usize, what: &str) -> std::result::Result<Option<String>, String> {
54 let text = value
55 .chars()
56 .map(|c| if c.is_whitespace() { ' ' } else { c })
57 .filter(|c| !c.is_control())
58 .collect::<String>()
59 .split(' ')
60 .filter(|word| !word.is_empty())
61 .collect::<Vec<_>>()
62 .join(" ");
63 if text.is_empty() {
64 Ok(None)
65 } else if text.chars().count() > max {
66 Err(format!("Keep your {what} to {max} characters."))
67 } else {
68 Ok(Some(text))
69 }
70}
71
72/// A website as it is kept: an `https://` address with a real host name.
73/// A bare `example.com` is taken to mean `https://example.com`. Plain
74/// `http://`, other schemes and anything a browser might read as script are
75/// refused.
76pub fn website(value: &str) -> std::result::Result<Option<String>, &'static str> {
77 const REFUSED: &str = "Use an https:// address for your website, such as https://example.com.";
78 let value = value.trim();
79 if value.is_empty() {
80 return Ok(None);
81 }
82 if value.chars().count() > MAX_PROFILE_WEBSITE {
83 return Err("That website address is too long.");
84 }
85 if value.chars().any(|c| c.is_whitespace() || c.is_control() || "<>\"'`\\".contains(c)) {
86 return Err(REFUSED);
87 }
88 let address = match value.split_once("://") {
89 Some((scheme, rest)) if scheme.eq_ignore_ascii_case("https") => format!("https://{rest}"),
90 Some(_) => return Err(REFUSED),
91 // `javascript:alert(1)` has no `//` but is no host name either; the
92 // host check below refuses it.
93 None => format!("https://{value}"),
94 };
95 let rest = &address["https://".len()..];
96 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
97 // No credentials in an address shown to others.
98 if authority.contains('@') {
99 return Err(REFUSED);
100 }
101 let host = match authority.rsplit_once(':') {
102 Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
103 Some(_) => return Err(REFUSED),
104 None => authority,
105 };
106 let labels: Vec<&str> = host.split('.').collect();
107 let well_formed = labels.len() >= 2
108 && labels.iter().all(|label| {
109 !label.is_empty()
110 && label.len() <= 63
111 && !label.starts_with('-')
112 && !label.ends_with('-')
113 && label.chars().all(|c| c.is_alphanumeric() || c == '-')
114 });
115 if !well_formed {
116 return Err(REFUSED);
117 }
118 Ok(Some(address))
119}
120
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)121/// An IANA time zone name as it is kept, such as `America/Denver` or
122/// `UTC`: empty is none. Only the name's shape is checked here; the web
123/// app offers the zones its runtime knows, and one it does not know is
124/// shown without a local time.
125fn timezone(value: &str) -> std::result::Result<Option<String>, &'static str> {
126 const REFUSED: &str = "That is not a time zone. Pick one from the list, such as America/Denver.";
127 let name = value.trim();
128 if name.is_empty() {
129 return Ok(None);
130 }
131 let well_formed = name.len() <= MAX_PROFILE_TIMEZONE
132 && name.split('/').all(|part| {
133 part.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
134 && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+'))
135 });
136 if well_formed { Ok(Some(name.to_owned())) } else { Err(REFUSED) }
137}
138
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas139/// The fields of an update, checked, or the first thing wrong.
140pub struct Checked {
141 pub name: Option<String>,
142 pub bio: Option<String>,
143 pub location: Option<String>,
144 pub website: Option<String>,
145 pub pronouns: Option<String>,
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)146 pub timezone: Option<String>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas147}
148
149pub fn check(a: &UpdateProfileArgs) -> std::result::Result<Checked, String> {
150 Ok(Checked {
151 name: tidy(&a.name, MAX_PROFILE_NAME, "name")?,
152 bio: tidy(&a.bio, MAX_PROFILE_BIO, "bio")?,
153 location: tidy(&a.location, MAX_PROFILE_LOCATION, "location")?,
154 website: website(&a.website).map_err(str::to_owned)?,
155 pronouns: tidy(&a.pronouns, MAX_PROFILE_PRONOUNS, "pronouns")?,
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)156 timezone: timezone(&a.timezone).map_err(str::to_owned)?,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas157 })
158}
159
160fn optional(value: &Option<String>) -> JsValue {
161 value.as_deref().map_or(JsValue::NULL, JsValue::from)
162}
163
164impl Identity {
165 pub async fn profile(&self, a: UsernameArgs) -> Result<Option<Profile>> {
166 Ok(self
167 .db
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)168 // A deleted account's profile is not found (account_deletion.rs).
169 .prepare(format!("SELECT {PROFILE_COLUMNS} FROM users WHERE username = ? AND deleted_at IS NULL"))
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas170 .bind(&[a.username.trim().to_lowercase().into()])?
171 .first::<ProfileRow>(None)
172 .await?
173 .map(Profile::from))
174 }
175
176 pub async fn update_profile(&self, a: UpdateProfileArgs) -> Result<Outcome<Profile>> {
177 if a.actor.kind != PrincipalKind::User || a.actor.id.is_empty() {
178 return Ok(Outcome::fail(
179 FailureCode::Forbidden,
180 "Only a person can change their own profile.",
181 ));
182 }
183 let fields = match check(&a) {
184 Ok(fields) => fields,
185 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
186 };
187 let row = self
188 .db
189 .prepare(format!(
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)190 "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ?, timezone = ?
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas191 WHERE id = ? RETURNING {PROFILE_COLUMNS}"
192 ))
193 .bind(&[
194 optional(&fields.name),
195 optional(&fields.bio),
196 optional(&fields.location),
197 optional(&fields.website),
198 optional(&fields.pronouns),
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)199 optional(&fields.timezone),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas200 a.actor.id.as_str().into(),
201 ])?
202 .first::<ProfileRow>(None)
203 .await?;
204 Ok(match row {
205 Some(row) => Outcome::Ok(row.into()),
206 None => Outcome::fail(FailureCode::NotFound, "There is no such account."),
207 })
208 }
209
210 /// The workspaces a profile shows to `viewer`. Belonging to a workspace
211 /// is private to its members, so a membership is shown only where the
212 /// viewer could know it anyway:
213 ///
214 /// - a workspace the viewer belongs to too, whose members they can list;
215 /// - a workspace in `public`, where the person made a public project,
216 /// which the project's page shows already.
217 ///
218 /// Anything else, including every workspace of someone viewed signed
219 /// out, is left off. Only real memberships are ever returned: `public`
220 /// can narrow what is shown, never add to it.
221 pub async fn profile_workspaces(&self, a: ProfileWorkspacesArgs) -> Result<Vec<ProfileWorkspace>> {
222 #[derive(Deserialize)]
223 struct Row {
224 id: String,
225 }
226 let Some(person) = self
227 .db
228 .prepare("SELECT id FROM users WHERE username = ?")
229 .bind(&[a.username.trim().to_lowercase().into()])?
230 .first::<Row>(None)
231 .await?
232 else {
233 return Ok(Vec::new());
234 };
235 let memberships = self.memberships(&person.id).await?;
236 let shared = |slug: &str| a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(slug));
237 let public = |slug: &str| a.public.iter().any(|shown| shown.eq_ignore_ascii_case(slug));
238 Ok(memberships
239 .into_iter()
240 .filter(|membership| shared(&membership.slug) || public(&membership.slug))
241 .map(|membership| ProfileWorkspace {
242 name: membership.name.clone().unwrap_or_else(|| membership.slug.clone()),
243 slug: membership.slug,
244 avatar: membership.avatar,
245 })
246 .collect())
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn keeps_https_addresses() {
256 assert_eq!(website("https://example.com").unwrap().as_deref(), Some("https://example.com"));
257 assert_eq!(
258 website("HTTPS://syntaqx.com/about?x=1#me").unwrap().as_deref(),
259 Some("https://syntaqx.com/about?x=1#me")
260 );
261 assert_eq!(website("example.com/me").unwrap().as_deref(), Some("https://example.com/me"));
262 assert_eq!(website("https://a.b.example.dev:8443/").unwrap().as_deref(), Some("https://a.b.example.dev:8443/"));
263 assert_eq!(website(" ").unwrap(), None);
264 }
265
266 #[test]
267 fn refuses_anything_else() {
268 for refused in [
269 "http://example.com",
270 "javascript:alert(1)",
271 "javascript://example.com/%0Aalert(1)",
272 "data:text/html,<script>",
273 "ftp://example.com",
274 "https://localhost",
275 "https://user:pass@example.com",
276 "https://exa mple.com",
277 "https://example.com/\"onmouseover=",
278 "https://-bad.com",
279 "https://example..com",
280 "https://example.com:port",
281 "https://",
282 ] {
283 assert!(website(refused).is_err(), "{refused} was kept");
284 }
285 assert!(website(&format!("https://example.com/{}", "a".repeat(200))).is_err());
286 }
287
288 #[test]
289 fn tidies_text_fields() {
290 assert_eq!(tidy(" Chase \n Pierce ", 80, "name").unwrap().as_deref(), Some("Chase Pierce"));
291 assert_eq!(tidy("\u{0}\u{7}", 80, "name").unwrap(), None);
292 assert_eq!(tidy("", 80, "name").unwrap(), None);
293 assert!(tidy(&"a".repeat(161), MAX_PROFILE_BIO, "bio").is_err());
294 assert!(tidy(&"é".repeat(160), MAX_PROFILE_BIO, "bio").is_ok());
295 }
296
297 #[test]
298 fn checks_every_field() {
299 let args = UpdateProfileArgs {
300 name: "Chase".into(),
301 bio: "Builds g1t.".into(),
302 website: "http://insecure.example".into(),
303 ..UpdateProfileArgs::default()
304 };
305 assert!(check(&args).is_err());
306 let args = UpdateProfileArgs {
307 website: "syntaqx.com".into(),
308 pronouns: "he/him".into(),
309 ..args
310 };
311 let fields = check(&args).ok().unwrap();
312 assert_eq!(fields.website.as_deref(), Some("https://syntaqx.com"));
313 assert_eq!(fields.pronouns.as_deref(), Some("he/him"));
314 assert_eq!(fields.location, None);
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)315 assert_eq!(fields.timezone, None);
316 }
317
318 #[test]
319 fn a_time_zone_is_an_iana_name_or_nothing() {
320 for name in ["America/Denver", "UTC", "America/Argentina/Buenos_Aires", "Etc/GMT+7", "America/Port-au-Prince"] {
321 assert_eq!(timezone(name).unwrap().as_deref(), Some(name));
322 }
323 assert_eq!(timezone(" Europe/Berlin ").unwrap().as_deref(), Some("Europe/Berlin"));
324 assert_eq!(timezone("").unwrap(), None);
325 for bad in ["America/", "/UTC", "Europe/Ber lin", "<script>", "../etc", &"A".repeat(65)] {
326 assert!(timezone(bad).is_err(), "{bad}");
327 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas328 }
329}

This file's history is long; its oldest lines are credited to the oldest commit read.