g1t/services/identity/src/profiles.rs
| 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 | } |