flagon-io/g1t

public

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

289 lines10,654 bytesCodeBlame

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>,
24 avatar: Option<String>,
25 created_at: String,
26}
27
28impl 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
43const 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.
48fn 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.
71pub 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.
117pub 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
125pub 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
135fn optional(value: &Option<String>) -> JsValue {
136 value.as_deref().map_or(JsValue::NULL, JsValue::from)
137}
138
139impl 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)]
224mod 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}