Skip to content
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.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1//! 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>,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains21 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>,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains27 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,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains36 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,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains42 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";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains50
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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains139/// 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>,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains147}
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)?,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains157 })
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"))
Agents and memory, checks and conflicts, profiles, slug renames, custom domains170 .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 = ?
Agents and memory, checks and conflicts, profiles, slug renames, custom domains191 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),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains200 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 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains328 }
329}

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