OXYGENHUB

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