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

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