g1t/services/identity/src/profiles.rs
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 domains | 1 | //! 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 | ||
| 8 | use g1t_contracts::identity::*; | |
| 9 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind}; | |
| 10 | use serde::Deserialize; | |
| 11 | use worker::Result; | |
| 12 | use worker::wasm_bindgen::JsValue; | |
| 13 | ||
| 14 | use crate::Identity; | |
| 15 | ||
| 16 | #[derive(Deserialize)] | |
| 17 | struct 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>, | |
| 24 | avatar: Option<String>, | |
| 25 | created_at: String, | |
| 26 | } | |
| 27 | ||
| 28 | impl From<ProfileRow> for Profile { | |
| 29 | fn from(row: ProfileRow) -> Self { | |
| 30 | Profile { | |
| 31 | username: row.username, | |
| 32 | name: row.display_name, | |
| 33 | bio: row.bio, | |
| 34 | location: row.location, | |
| 35 | website: row.website, | |
| 36 | pronouns: row.pronouns, | |
| 37 | avatar: row.avatar, | |
| 38 | created_at: row.created_at, | |
| 39 | } | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | const PROFILE_COLUMNS: &str = | |
| 44 | "username, display_name, bio, location, website, pronouns, avatar, created_at"; | |
| 45 | ||
| 46 | /// A field as it is kept: whitespace runs made single spaces, control | |
| 47 | /// characters dropped, trimmed. Empty is none. Too long is refused. | |
| 48 | fn tidy(value: &str, max: usize, what: &str) -> std::result::Result<Option<String>, String> { | |
| 49 | let text = value | |
| 50 | .chars() | |
| 51 | .map(|c| if c.is_whitespace() { ' ' } else { c }) | |
| 52 | .filter(|c| !c.is_control()) | |
| 53 | .collect::<String>() | |
| 54 | .split(' ') | |
| 55 | .filter(|word| !word.is_empty()) | |
| 56 | .collect::<Vec<_>>() | |
| 57 | .join(" "); | |
| 58 | if text.is_empty() { | |
| 59 | Ok(None) | |
| 60 | } else if text.chars().count() > max { | |
| 61 | Err(format!("Keep your {what} to {max} characters.")) | |
| 62 | } else { | |
| 63 | Ok(Some(text)) | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /// A website as it is kept: an `https://` address with a real host name. | |
| 68 | /// A bare `example.com` is taken to mean `https://example.com`. Plain | |
| 69 | /// `http://`, other schemes and anything a browser might read as script are | |
| 70 | /// refused. | |
| 71 | pub fn website(value: &str) -> std::result::Result<Option<String>, &'static str> { | |
| 72 | const REFUSED: &str = "Use an https:// address for your website, such as https://example.com."; | |
| 73 | let value = value.trim(); | |
| 74 | if value.is_empty() { | |
| 75 | return Ok(None); | |
| 76 | } | |
| 77 | if value.chars().count() > MAX_PROFILE_WEBSITE { | |
| 78 | return Err("That website address is too long."); | |
| 79 | } | |
| 80 | if value.chars().any(|c| c.is_whitespace() || c.is_control() || "<>\"'`\\".contains(c)) { | |
| 81 | return Err(REFUSED); | |
| 82 | } | |
| 83 | let address = match value.split_once("://") { | |
| 84 | Some((scheme, rest)) if scheme.eq_ignore_ascii_case("https") => format!("https://{rest}"), | |
| 85 | Some(_) => return Err(REFUSED), | |
| 86 | // `javascript:alert(1)` has no `//` but is no host name either; the | |
| 87 | // host check below refuses it. | |
| 88 | None => format!("https://{value}"), | |
| 89 | }; | |
| 90 | let rest = &address["https://".len()..]; | |
| 91 | let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); | |
| 92 | // No credentials in an address shown to others. | |
| 93 | if authority.contains('@') { | |
| 94 | return Err(REFUSED); | |
| 95 | } | |
| 96 | let host = match authority.rsplit_once(':') { | |
| 97 | Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, | |
| 98 | Some(_) => return Err(REFUSED), | |
| 99 | None => authority, | |
| 100 | }; | |
| 101 | let labels: Vec<&str> = host.split('.').collect(); | |
| 102 | let well_formed = labels.len() >= 2 | |
| 103 | && labels.iter().all(|label| { | |
| 104 | !label.is_empty() | |
| 105 | && label.len() <= 63 | |
| 106 | && !label.starts_with('-') | |
| 107 | && !label.ends_with('-') | |
| 108 | && label.chars().all(|c| c.is_alphanumeric() || c == '-') | |
| 109 | }); | |
| 110 | if !well_formed { | |
| 111 | return Err(REFUSED); | |
| 112 | } | |
| 113 | Ok(Some(address)) | |
| 114 | } | |
| 115 | ||
| 116 | /// The fields of an update, checked, or the first thing wrong. | |
| 117 | pub struct Checked { | |
| 118 | pub name: Option<String>, | |
| 119 | pub bio: Option<String>, | |
| 120 | pub location: Option<String>, | |
| 121 | pub website: Option<String>, | |
| 122 | pub pronouns: Option<String>, | |
| 123 | } | |
| 124 | ||
| 125 | pub fn check(a: &UpdateProfileArgs) -> std::result::Result<Checked, String> { | |
| 126 | Ok(Checked { | |
| 127 | name: tidy(&a.name, MAX_PROFILE_NAME, "name")?, | |
| 128 | bio: tidy(&a.bio, MAX_PROFILE_BIO, "bio")?, | |
| 129 | location: tidy(&a.location, MAX_PROFILE_LOCATION, "location")?, | |
| 130 | website: website(&a.website).map_err(str::to_owned)?, | |
| 131 | pronouns: tidy(&a.pronouns, MAX_PROFILE_PRONOUNS, "pronouns")?, | |
| 132 | }) | |
| 133 | } | |
| 134 | ||
| 135 | fn optional(value: &Option<String>) -> JsValue { | |
| 136 | value.as_deref().map_or(JsValue::NULL, JsValue::from) | |
| 137 | } | |
| 138 | ||
| 139 | impl Identity { | |
| 140 | pub async fn profile(&self, a: UsernameArgs) -> Result<Option<Profile>> { | |
| 141 | Ok(self | |
| 142 | .db | |
| 143 | .prepare(format!("SELECT {PROFILE_COLUMNS} FROM users WHERE username = ?")) | |
| 144 | .bind(&[a.username.trim().to_lowercase().into()])? | |
| 145 | .first::<ProfileRow>(None) | |
| 146 | .await? | |
| 147 | .map(Profile::from)) | |
| 148 | } | |
| 149 | ||
| 150 | pub async fn update_profile(&self, a: UpdateProfileArgs) -> Result<Outcome<Profile>> { | |
| 151 | if a.actor.kind != PrincipalKind::User || a.actor.id.is_empty() { | |
| 152 | return Ok(Outcome::fail( | |
| 153 | FailureCode::Forbidden, | |
| 154 | "Only a person can change their own profile.", | |
| 155 | )); | |
| 156 | } | |
| 157 | let fields = match check(&a) { | |
| 158 | Ok(fields) => fields, | |
| 159 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 160 | }; | |
| 161 | let row = self | |
| 162 | .db | |
| 163 | .prepare(format!( | |
| 164 | "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ? | |
| 165 | WHERE id = ? RETURNING {PROFILE_COLUMNS}" | |
| 166 | )) | |
| 167 | .bind(&[ | |
| 168 | optional(&fields.name), | |
| 169 | optional(&fields.bio), | |
| 170 | optional(&fields.location), | |
| 171 | optional(&fields.website), | |
| 172 | optional(&fields.pronouns), | |
| 173 | a.actor.id.as_str().into(), | |
| 174 | ])? | |
| 175 | .first::<ProfileRow>(None) | |
| 176 | .await?; | |
| 177 | Ok(match row { | |
| 178 | Some(row) => Outcome::Ok(row.into()), | |
| 179 | None => Outcome::fail(FailureCode::NotFound, "There is no such account."), | |
| 180 | }) | |
| 181 | } | |
| 182 | ||
| 183 | /// The workspaces a profile shows to `viewer`. Belonging to a workspace | |
| 184 | /// is private to its members, so a membership is shown only where the | |
| 185 | /// viewer could know it anyway: | |
| 186 | /// | |
| 187 | /// - a workspace the viewer belongs to too, whose members they can list; | |
| 188 | /// - a workspace in `public`, where the person made a public project, | |
| 189 | /// which the project's page shows already. | |
| 190 | /// | |
| 191 | /// Anything else, including every workspace of someone viewed signed | |
| 192 | /// out, is left off. Only real memberships are ever returned: `public` | |
| 193 | /// can narrow what is shown, never add to it. | |
| 194 | pub async fn profile_workspaces(&self, a: ProfileWorkspacesArgs) -> Result<Vec<ProfileWorkspace>> { | |
| 195 | #[derive(Deserialize)] | |
| 196 | struct Row { | |
| 197 | id: String, | |
| 198 | } | |
| 199 | let Some(person) = self | |
| 200 | .db | |
| 201 | .prepare("SELECT id FROM users WHERE username = ?") | |
| 202 | .bind(&[a.username.trim().to_lowercase().into()])? | |
| 203 | .first::<Row>(None) | |
| 204 | .await? | |
| 205 | else { | |
| 206 | return Ok(Vec::new()); | |
| 207 | }; | |
| 208 | let memberships = self.memberships(&person.id).await?; | |
| 209 | let shared = |slug: &str| a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(slug)); | |
| 210 | let public = |slug: &str| a.public.iter().any(|shown| shown.eq_ignore_ascii_case(slug)); | |
| 211 | Ok(memberships | |
| 212 | .into_iter() | |
| 213 | .filter(|membership| shared(&membership.slug) || public(&membership.slug)) | |
| 214 | .map(|membership| ProfileWorkspace { | |
| 215 | name: membership.name.clone().unwrap_or_else(|| membership.slug.clone()), | |
| 216 | slug: membership.slug, | |
| 217 | avatar: membership.avatar, | |
| 218 | }) | |
| 219 | .collect()) | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | #[cfg(test)] | |
| 224 | mod tests { | |
| 225 | use super::*; | |
| 226 | ||
| 227 | #[test] | |
| 228 | fn keeps_https_addresses() { | |
| 229 | assert_eq!(website("https://example.com").unwrap().as_deref(), Some("https://example.com")); | |
| 230 | assert_eq!( | |
| 231 | website("HTTPS://syntaqx.com/about?x=1#me").unwrap().as_deref(), | |
| 232 | Some("https://syntaqx.com/about?x=1#me") | |
| 233 | ); | |
| 234 | assert_eq!(website("example.com/me").unwrap().as_deref(), Some("https://example.com/me")); | |
| 235 | assert_eq!(website("https://a.b.example.dev:8443/").unwrap().as_deref(), Some("https://a.b.example.dev:8443/")); | |
| 236 | assert_eq!(website(" ").unwrap(), None); | |
| 237 | } | |
| 238 | ||
| 239 | #[test] | |
| 240 | fn refuses_anything_else() { | |
| 241 | for refused in [ | |
| 242 | "http://example.com", | |
| 243 | "javascript:alert(1)", | |
| 244 | "javascript://example.com/%0Aalert(1)", | |
| 245 | "data:text/html,<script>", | |
| 246 | "ftp://example.com", | |
| 247 | "https://localhost", | |
| 248 | "https://user:pass@example.com", | |
| 249 | "https://exa mple.com", | |
| 250 | "https://example.com/\"onmouseover=", | |
| 251 | "https://-bad.com", | |
| 252 | "https://example..com", | |
| 253 | "https://example.com:port", | |
| 254 | "https://", | |
| 255 | ] { | |
| 256 | assert!(website(refused).is_err(), "{refused} was kept"); | |
| 257 | } | |
| 258 | assert!(website(&format!("https://example.com/{}", "a".repeat(200))).is_err()); | |
| 259 | } | |
| 260 | ||
| 261 | #[test] | |
| 262 | fn tidies_text_fields() { | |
| 263 | assert_eq!(tidy(" Chase \n Pierce ", 80, "name").unwrap().as_deref(), Some("Chase Pierce")); | |
| 264 | assert_eq!(tidy("\u{0}\u{7}", 80, "name").unwrap(), None); | |
| 265 | assert_eq!(tidy("", 80, "name").unwrap(), None); | |
| 266 | assert!(tidy(&"a".repeat(161), MAX_PROFILE_BIO, "bio").is_err()); | |
| 267 | assert!(tidy(&"é".repeat(160), MAX_PROFILE_BIO, "bio").is_ok()); | |
| 268 | } | |
| 269 | ||
| 270 | #[test] | |
| 271 | fn checks_every_field() { | |
| 272 | let args = UpdateProfileArgs { | |
| 273 | name: "Chase".into(), | |
| 274 | bio: "Builds g1t.".into(), | |
| 275 | website: "http://insecure.example".into(), | |
| 276 | ..UpdateProfileArgs::default() | |
| 277 | }; | |
| 278 | assert!(check(&args).is_err()); | |
| 279 | let args = UpdateProfileArgs { | |
| 280 | website: "syntaqx.com".into(), | |
| 281 | pronouns: "he/him".into(), | |
| 282 | ..args | |
| 283 | }; | |
| 284 | let fields = check(&args).ok().unwrap(); | |
| 285 | assert_eq!(fields.website.as_deref(), Some("https://syntaqx.com")); | |
| 286 | assert_eq!(fields.pronouns.as_deref(), Some("he/him")); | |
| 287 | assert_eq!(fields.location, None); | |
| 288 | } | |
| 289 | } |