Accounts elsewhere on a profile: Bluesky, Mastodon, GitHub 89e89836cae7
Set under Settings › Profile as a handle, @user@instance or a profile URL, checked and normalised; shown on the profile with the service's icon as rel=me links. closes #105
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 16:47 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
89e89836cae7cc0b392a8a507ad38131ac4a3dbd1f8724d77372878f8e9f6f73- Depends on
- 101740454617 19bb31ff1f5c 2a83f5303458 34beb1a7e376 4ac01a732552 a160b98fb922 d23464c0ac60 e71846aa07b9
- Depended on by
- 1062a0f8e985 3d086bc3d69b b951c58734e8 5d90e0e045e2 1f78655a0466 e8e7158bbc73 83940d53664f
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 12 atoms, 2 tree operations, 9 files
Browse the tree at this change
added crates/hub/migrations/0039_social_links.sql +6 −0
| 1 | + | -- Accounts elsewhere, shown on a profile with the service's icon (#105): |
|
| 2 | + | -- a Bluesky handle, a Mastodon address (user@instance) and a GitHub |
|
| 3 | + | -- username. Empty when not set. |
|
| 4 | + | ALTER TABLE accounts ADD COLUMN bluesky TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | ALTER TABLE accounts ADD COLUMN mastodon TEXT NOT NULL DEFAULT ''; |
|
| 6 | + | ALTER TABLE accounts ADD COLUMN github TEXT NOT NULL DEFAULT ''; |
added crates/hub/src/social.rs +82 −0
| 1 | + | //! Accounts elsewhere on a profile (hub spec §6, oxygenhub#105): a |
|
| 2 | + | //! Bluesky handle, a Mastodon address and a GitHub username, checked and |
|
| 3 | + | //! normalised on the way in, and turned into links on the way out. |
|
| 4 | + | ||
| 5 | + | /// A Bluesky handle: a domain such as `andy.bsky.social`, with or |
|
| 6 | + | /// without a leading `@`. `None` when it is not one. |
|
| 7 | + | pub fn bluesky(input: &str) -> Option<String> { |
|
| 8 | + | let h = input.trim().trim_start_matches('@').trim_end_matches('/'); |
|
| 9 | + | let h = h.strip_prefix("https://bsky.app/profile/").unwrap_or(h).to_ascii_lowercase(); |
|
| 10 | + | (!h.is_empty() && h.len() <= 253 && h.contains('.') && h.split('.').all(label_ok)).then_some(h) |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | /// A Mastodon address as `user@instance`, from `@user@instance`, |
|
| 14 | + | /// `user@instance` or `https://instance/@user`. |
|
| 15 | + | pub fn mastodon(input: &str) -> Option<String> { |
|
| 16 | + | let s = input.trim().trim_end_matches('/'); |
|
| 17 | + | let (user, instance) = if let Some(rest) = s.strip_prefix("https://") { |
|
| 18 | + | let (instance, user) = rest.split_once("/@")?; |
|
| 19 | + | (user, instance) |
|
| 20 | + | } else { |
|
| 21 | + | s.trim_start_matches('@').split_once('@')? |
|
| 22 | + | }; |
|
| 23 | + | let user_ok = !user.is_empty() && user.len() <= 64 && user.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.'); |
|
| 24 | + | let instance = instance.to_ascii_lowercase(); |
|
| 25 | + | let instance_ok = instance.contains('.') && instance.len() <= 253 && instance.split('.').all(label_ok); |
|
| 26 | + | (user_ok && instance_ok).then(|| format!("{user}@{instance}")) |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | /// A GitHub username: 1 to 39 letters, digits and dashes, from the name |
|
| 30 | + | /// or a profile URL. |
|
| 31 | + | pub fn github(input: &str) -> Option<String> { |
|
| 32 | + | let u = input.trim().trim_start_matches('@').trim_end_matches('/'); |
|
| 33 | + | let u = u.strip_prefix("https://github.com/").unwrap_or(u); |
|
| 34 | + | let ok = !u.is_empty() |
|
| 35 | + | && u.len() <= 39 |
|
| 36 | + | && u.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') |
|
| 37 | + | && !u.starts_with('-') |
|
| 38 | + | && !u.ends_with('-'); |
|
| 39 | + | ok.then(|| u.to_string()) |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | fn label_ok(l: &str) -> bool { |
|
| 43 | + | !l.is_empty() && l.len() <= 63 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && !l.starts_with('-') && !l.ends_with('-') |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | pub fn bluesky_url(handle: &str) -> String { |
|
| 47 | + | format!("https://bsky.app/profile/{handle}") |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// `user@instance` → `https://instance/@user`. |
|
| 51 | + | pub fn mastodon_url(address: &str) -> String { |
|
| 52 | + | match address.split_once('@') { |
|
| 53 | + | Some((user, instance)) => format!("https://{instance}/@{user}"), |
|
| 54 | + | None => String::new(), |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | pub fn github_url(user: &str) -> String { |
|
| 59 | + | format!("https://github.com/{user}") |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | #[cfg(test)] |
|
| 63 | + | mod tests { |
|
| 64 | + | use super::*; |
|
| 65 | + | ||
| 66 | + | #[test] |
|
| 67 | + | fn handles_are_checked_and_normalised() { |
|
| 68 | + | assert_eq!(bluesky("@Andy.bsky.social").as_deref(), Some("andy.bsky.social")); |
|
| 69 | + | assert_eq!(bluesky("https://bsky.app/profile/andy.example.org/").as_deref(), Some("andy.example.org")); |
|
| 70 | + | assert_eq!(bluesky("andy"), None, "a handle is a domain"); |
|
| 71 | + | assert_eq!(bluesky("an dy.bsky.social"), None); |
|
| 72 | + | assert_eq!(mastodon("@[email protected]").as_deref(), Some("[email protected]")); |
|
| 73 | + | assert_eq!(mastodon("https://Fosstodon.org/@andy").as_deref(), Some("[email protected]")); |
|
| 74 | + | assert_eq!(mastodon("andy"), None); |
|
| 75 | + | assert_eq!(mastodon("andy@nodot"), None); |
|
| 76 | + | assert_eq!(github("https://github.com/andymccall/").as_deref(), Some("andymccall")); |
|
| 77 | + | assert_eq!(github("@andy-mccall").as_deref(), Some("andy-mccall")); |
|
| 78 | + | assert_eq!(github("-andy"), None); |
|
| 79 | + | assert_eq!(github("andy mccall"), None); |
|
| 80 | + | assert_eq!(mastodon_url("[email protected]"), "https://fosstodon.org/@andy"); |
|
| 81 | + | } |
|
| 82 | + | } |
modified crates/hub/src/db/accounts.rs +20 −0
| … | 32 unchanged lines |
||
| 33 | 33 | pub location: String, |
|
| 34 | 34 | /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded. |
|
| 35 | 35 | pub avatar: String, |
|
| 36 | + | /// Accounts elsewhere (migration 0039, #105): a Bluesky handle, a |
|
| 37 | + | /// Mastodon `user@instance`, a GitHub username; empty when not set. |
|
| 38 | + | #[sqlx(default)] |
|
| 39 | + | pub bluesky: String, |
|
| 40 | + | #[sqlx(default)] |
|
| 41 | + | pub mastodon: String, |
|
| 42 | + | #[sqlx(default)] |
|
| 43 | + | pub github: String, |
|
| 36 | 44 | } |
|
| 37 | 45 | ||
| 38 | 46 | impl Account { |
|
| … | 143 unchanged lines |
||
| 182 | 190 | } |
|
| 183 | 191 | ||
| 184 | 192 | /// The public profile fields. |
|
| 193 | + | /// The accounts elsewhere a profile shows (#105), already normalised. |
|
| 194 | + | pub async fn set_links(pool: &Pool, id: i64, bluesky: &str, mastodon: &str, github: &str) -> sqlx::Result<()> { |
|
| 195 | + | sqlx::query("UPDATE accounts SET bluesky = ?, mastodon = ?, github = ? WHERE id = ?") |
|
| 196 | + | .bind(bluesky) |
|
| 197 | + | .bind(mastodon) |
|
| 198 | + | .bind(github) |
|
| 199 | + | .bind(id) |
|
| 200 | + | .execute(pool) |
|
| 201 | + | .await?; |
|
| 202 | + | Ok(()) |
|
| 203 | + | } |
|
| 204 | + | ||
| 185 | 205 | pub async fn set_bio(pool: &Pool, id: i64, bio: &str, website: &str, location: &str) -> sqlx::Result<()> { |
|
| 186 | 206 | sqlx::query("UPDATE accounts SET bio = ?, website = ?, location = ? WHERE id = ?") |
|
| 187 | 207 | .bind(bio) |
|
| … | 713 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 22 unchanged lines |
||
| 23 | 23 | pub mod render; |
|
| 24 | 24 | pub mod repos; |
|
| 25 | 25 | pub mod secrets; |
|
| 26 | + | pub mod social; |
|
| 26 | 27 | pub mod variables; |
|
| 27 | 28 | pub mod web; |
|
| 28 | 29 | ||
| … | 108 unchanged lines |
modified crates/hub/src/web/settings.rs +26 −0
| … | 163 unchanged lines |
||
| 164 | 164 | website: String, |
|
| 165 | 165 | #[serde(default)] |
|
| 166 | 166 | location: String, |
|
| 167 | + | #[serde(default)] |
|
| 168 | + | bluesky: String, |
|
| 169 | + | #[serde(default)] |
|
| 170 | + | mastodon: String, |
|
| 171 | + | #[serde(default)] |
|
| 172 | + | github: String, |
|
| 167 | 173 | } |
|
| 168 | 174 | ||
| 169 | 175 | pub async fn update_profile( |
|
| … | 11 unchanged lines |
||
| 181 | 187 | SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."), |
|
| 182 | 188 | )); |
|
| 183 | 189 | } |
|
| 190 | + | // Accounts elsewhere: checked and normalised, or refused by name. |
|
| 191 | + | let link = |given: &str, what: &str, parse: fn(&str) -> Option<String>| -> Result<String, String> { |
|
| 192 | + | if given.trim().is_empty() { |
|
| 193 | + | Ok(String::new()) |
|
| 194 | + | } else { |
|
| 195 | + | parse(given).ok_or_else(|| format!("That is not a {what}.")) |
|
| 196 | + | } |
|
| 197 | + | }; |
|
| 198 | + | let links = (|| { |
|
| 199 | + | Ok::<_, String>(( |
|
| 200 | + | link(&f.bluesky, "Bluesky handle (such as andy.bsky.social)", crate::social::bluesky)?, |
|
| 201 | + | link(&f.mastodon, "Mastodon address (such as @[email protected])", crate::social::mastodon)?, |
|
| 202 | + | link(&f.github, "GitHub username", crate::social::github)?, |
|
| 203 | + | )) |
|
| 204 | + | })(); |
|
| 205 | + | let (bluesky, mastodon, github) = match links { |
|
| 206 | + | Ok(l) => l, |
|
| 207 | + | Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(&msg))), |
|
| 208 | + | }; |
|
| 184 | 209 | let bio: String = f.bio.trim().chars().take(280).collect(); |
|
| 185 | 210 | let location: String = f.location.trim().chars().take(80).collect(); |
|
| 186 | 211 | accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim()) |
|
| 187 | 212 | .await |
|
| 188 | 213 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 189 | 214 | accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 215 | + | accounts::set_links(&app.db, me.account.id, &bluesky, &mastodon, &github).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 190 | 216 | let changed = me.account.email != f.email.trim(); |
|
| 191 | 217 | let updated = |
|
| 192 | 218 | accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); |
|
| … | 389 unchanged lines |
modified crates/hub/tests/accounts.rs +29 −0
| … | 789 unchanged lines |
||
| 790 | 790 | assert!(r.status == StatusCode::SEE_OTHER || r.status == StatusCode::OK, "{} {}", r.status, r.body); |
|
| 791 | 791 | assert!(assistants::list(&app.db, Scope::Repo(repo.id)).await.unwrap().is_empty()); |
|
| 792 | 792 | } |
|
| 793 | + | ||
| 794 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 795 | + | async fn accounts_elsewhere_are_checked_and_shown_on_the_profile() { |
|
| 796 | + | let h = hub().await; |
|
| 797 | + | let app = &h.app; |
|
| 798 | + | let andy = register(app, "andy", "").await; |
|
| 799 | + | let page = get(app, "/settings", Some(&andy)).await.body; |
|
| 800 | + | let csrf = csrf_of(&page); |
|
| 801 | + | let base = format!("_csrf={csrf}&display_name=Andy&email=andy%40example.com&bio=&website=&location="); |
|
| 802 | + | let r = post(app, "/settings", Some(&andy), &format!("{base}&bluesky=%40Andy.bsky.social&mastodon=https%3A%2F%2FFosstodon.org%2F%40andy&github=https%3A%2F%2Fgithub.com%2Fandymccall%2F")).await; |
|
| 803 | + | assert!( |
|
| 804 | + | r.body.contains("value=\"andy.bsky.social\"") |
|
| 805 | + | && r.body.contains("value=\"@[email protected]\"") |
|
| 806 | + | && r.body.contains("value=\"andymccall\""), |
|
| 807 | + | "{}", |
|
| 808 | + | r.body |
|
| 809 | + | ); |
|
| 810 | + | let profile = get(app, "/andy", None).await.body; |
|
| 811 | + | assert!(profile.contains("href=\"https://bsky.app/profile/andy.bsky.social\"") && profile.contains("@andy.bsky.social"), "{profile}"); |
|
| 812 | + | assert!(profile.contains("href=\"https://fosstodon.org/@andy\"") && profile.contains("rel=\"nofollow noopener me\""), "{profile}"); |
|
| 813 | + | assert!(profile.contains("href=\"https://github.com/andymccall\"") && profile.contains("title=\"GitHub\""), "{profile}"); |
|
| 814 | + | // A bad one is refused and nothing changes. |
|
| 815 | + | let r = post(app, "/settings", Some(&andy), &format!("{base}&bluesky=not+a+handle&mastodon=&github=")).await; |
|
| 816 | + | assert!(r.body.contains("not a Bluesky handle"), "{}", r.body); |
|
| 817 | + | assert!(get(app, "/andy", None).await.body.contains("andy.bsky.social")); |
|
| 818 | + | // Cleared. |
|
| 819 | + | post(app, "/settings", Some(&andy), &format!("{base}&bluesky=&mastodon=&github=")).await; |
|
| 820 | + | assert!(!get(app, "/andy", None).await.body.contains("class=\"social\"")); |
|
| 821 | + | } |
modified templates/profile.html +3 −0
| … | 9 unchanged lines |
||
| 10 | 10 | <ul class="plain facts-list small"> |
|
| 11 | 11 | {% if !account.location.is_empty() %}<li><span aria-hidden="true">⌖</span> {{ account.location }}</li>{% endif %} |
|
| 12 | 12 | {% if let Some(w) = website_href() %}<li><span aria-hidden="true">↗</span> <a href="{{ w }}" rel="nofollow noopener">{{ w }}</a></li>{% endif %} |
|
| 13 | + | {% if !account.bluesky.is_empty() %}<li class="social"><svg class="icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="#0085ff" d="M12 10.8c-1.1-2.1-4-6.1-6.8-8C2.6 1 1.6 1.3 1 1.6.3 1.9 0 3 0 3.6s.4 5.6.6 6.4c.8 2.6 3.6 3.5 6.2 3.2-3.8.6-7.1 1.9-2.7 6.8 4.8 5 6.6-1.1 7.9-4.3 1.3 3.2 2.8 9.2 7.8 4.3 3.8-3.8 1.1-6.2-2.7-6.8 2.6.3 5.4-.6 6.2-3.2.2-.8.6-5.8.6-6.4S23.7 1.9 23 1.6c-.6-.3-1.6-.6-4.2 1.2-2.8 1.9-5.7 5.9-6.8 8z"/></svg> <a href="{{ crate::social::bluesky_url(account.bluesky) }}" rel="nofollow noopener me" title="Bluesky">@{{ account.bluesky }}</a></li>{% endif %} |
|
| 14 | + | {% if !account.mastodon.is_empty() %}<li class="social"><svg class="icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="#6364ff" d="M23.3 5.3c-.4-2.7-2.7-4.8-5.5-5.2C17.3 0 15.5-.1 12.9 0h-.1c-2.6 0-3.2.1-3.7.2C6.4.6 4.3 2.1 3.5 4.7c-.4 1.3-.5 2.7-.4 4.1-.1 1.9 0 3.9.3 5.8.2 1.4.6 2.8 1.3 4.1 1.1 2.1 3.2 3.5 5.5 3.9 2.5.4 5.1.3 7.5-.3.3-.1.6-.2.8-.3l-.1-2.1c-1.6.5-3.3.7-5 .7-2.5 0-3.3-1.2-3.5-1.7-.1-.4-.2-.9-.3-1.4 2.1.5 4.2.6 6.3.4 3.7-.4 7-1.8 7.5-6.3.1-.4 0-4.3-.1-5.3zm-3.9 9.1h-2.8V8.1c0-1.3-.6-2-1.7-2-1.2 0-1.9.8-1.9 2.4v3.5h-2.8V8.5c0-1.6-.6-2.4-1.9-2.4-1.1 0-1.7.7-1.7 2v6.3H3.8V7.9c0-1.3.3-2.4 1-3.2.7-.8 1.7-1.2 2.9-1.2 1.4 0 2.4.5 3.1 1.6l.7 1.1.7-1.1c.7-1.1 1.7-1.6 3.1-1.6 1.2 0 2.2.4 2.9 1.2.7.8 1 1.9 1 3.2v6.5z"/></svg> <a href="{{ crate::social::mastodon_url(account.mastodon) }}" rel="nofollow noopener me" title="Mastodon">@{{ account.mastodon }}</a></li>{% endif %} |
|
| 15 | + | {% if !account.github.is_empty() %}<li class="social"><svg class="icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg> <a href="{{ crate::social::github_url(account.github) }}" rel="nofollow noopener me" title="GitHub">{{ account.github }}</a></li>{% endif %} |
|
| 13 | 16 | <li class="mute">joined <time title="{{ account.created_ms|when_i }}">{{ account.created_ms|ago_i }}</time></li> |
|
| 14 | 17 | </ul> |
|
| 15 | 18 | {% if is_me %}<a class="go small" href="/settings">edit profile</a>{% endif %} |
|
| … | 17 unchanged lines |
modified templates/settings.html +3 −0
| … | 22 unchanged lines |
||
| 23 | 23 | <label for="bio">Bio</label><textarea id="bio" name="bio" rows="3" maxlength="280">{{ me.bio }}</textarea> |
|
| 24 | 24 | <label for="website">Website</label><input type="url" id="website" name="website" value="{{ me.website }}" placeholder="https://"> |
|
| 25 | 25 | <label for="location">Location</label><input type="text" id="location" name="location" value="{{ me.location }}" maxlength="80"> |
|
| 26 | + | <label for="bluesky">Bluesky</label><input type="text" id="bluesky" name="bluesky" value="{{ me.bluesky }}" placeholder="andy.bsky.social"> |
|
| 27 | + | <label for="mastodon">Mastodon</label><input type="text" id="mastodon" name="mastodon" value="{% if !me.mastodon.is_empty() %}@{{ me.mastodon }}{% endif %}" placeholder="@[email protected]"> |
|
| 28 | + | <label for="github">GitHub</label><input type="text" id="github" name="github" value="{{ me.github }}" placeholder="andymccall"> |
|
| 26 | 29 | <p class="small mute">Shown on <a href="/{{ me.username }}">your profile</a>, with a picture drawn from your username.</p> |
|
| 27 | 30 | <button type="submit">SAVE</button> |
|
| 28 | 31 | </form> |
|
| … | 36 unchanged lines |
modified templates/style.css +1 −0
| … | 370 unchanged lines |
||
| 371 | 371 | .profile .who-card .handle { font-size: 18px; color: var(--mute); margin: 2px 0 10px; } |
|
| 372 | 372 | .profile .who-card .bio { margin: 0 0 10px; } |
|
| 373 | 373 | .profile .who-card .facts-list li { padding: 2px 0; } |
|
| 374 | + | .profile .who-card .facts-list li.social .icon { vertical-align: -2px; } |
|
| 374 | 375 | .profile h2 { font-size: 15px; margin: 0 0 8px; } |
|
| 375 | 376 | .profile h2 ~ h2 { margin-top: 18px; } |
|
| 376 | 377 | .profile .keys li { margin-bottom: 6px; } |
|
| … | 411 unchanged lines |