OXYGENHUB

Profile and organisation pages, with identicons 101740454617

/{owner} is now a page about the owner. A person's shows an identicon,
display name, bio, location and website (set under Settings; the email
is never shown), linked signing keys, organisations, the repositories
the viewer can see and what the person did lately anywhere the viewer
can see. An organisation's shows its description, members with roles,
teams with sizes, repositories and recent activity in them, with a
settings link for owners.

Identicons come from /avatar/{name}: a mirrored five-by-five pattern
and a hue drawn from BLAKE3 of the lower-cased name. They appear in the
header and the activity feeds too. Migration 0008 adds the profile
fields; avatar and help join the reserved names.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 07:55 UTC
Signature
✔ signed by Andy McCall
Hash
101740454617cd6966001ebd9d7f9f8bc566ca6766325e2f9137f3a9d19220dd
Depends on
145306044896 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 93be9ce4653e b45384c909c1 c40c2d3edb51 d23464c0ac60
Depended on by
ac689cd21adf b951c58734e8 166f6e9bf4f1 16ee5d19d220 8a48dd830178 5d90e0e045e2 2a0c09c09716 2e9188ca4f98 d7d182467382 61ecbd299f22 08e221b77658 62ceb2eec18c 206c1fc1b06a 89e89836cae7 e71846aa07b9 38833ed78707 e1ec9fbf538e a160b98fb922 34beb1a7e376 1553bfc9ae53 9df855f29948
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.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 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
32 atoms, 6 tree operations, 19 files

Browse the tree at this change

added crates/hub/migrations/0008_profiles.sql +4 −0
1 +
-- Public profile fields (hub spec §6: owner pages).
2 +
ALTER TABLE accounts ADD COLUMN bio TEXT NOT NULL DEFAULT '';
3 +
ALTER TABLE accounts ADD COLUMN website TEXT NOT NULL DEFAULT '';
4 +
ALTER TABLE accounts ADD COLUMN location TEXT NOT NULL DEFAULT '';
added crates/hub/src/web/avatar.rs +65 −0
1 +
//! Identicons (hub spec §6): a five-by-five mirrored pattern and a colour
2 +
//! drawn from BLAKE3 of the lower-cased name, so every account and
3 +
//! organisation has a stable picture without uploads.
4 +
5 +
use axum::extract::Path;
6 +
use axum::http::header;
7 +
use axum::response::{IntoResponse, Response};
8 +
9 +
/// The SVG for `name`: 15 bits choose the cells of the left three columns
10 +
/// (mirrored to five), two bytes choose the hue.
11 +
pub fn identicon(name: &str) -> String {
12 +
    let h = blake3::hash(name.to_ascii_lowercase().as_bytes());
13 +
    let b = h.as_bytes();
14 +
    let hue = u16::from_le_bytes([b[0], b[1]]) % 360;
15 +
    let bits = u32::from_le_bytes([b[2], b[3], b[4], b[5]]);
16 +
    let mut cells = String::new();
17 +
    for row in 0..5u32 {
18 +
        for col in 0..3u32 {
19 +
            if bits >> (row * 3 + col) & 1 == 1 {
20 +
                for x in [col, 4 - col] {
21 +
                    cells.push_str(&format!(r#"<rect x="{}" y="{}" width="1" height="1"/>"#, x + 1, row + 1));
22 +
                    if x == 2 {
23 +
                        break;
24 +
                    }
25 +
                }
26 +
            }
27 +
        }
28 +
    }
29 +
    format!(
30 +
        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7 7" shape-rendering="crispEdges"><rect width="7" height="7" fill="hsl({hue},45%,92%)"/><g fill="hsl({hue},65%,45%)">{cells}</g></svg>"#
31 +
    )
32 +
}
33 +
34 +
/// `GET /avatar/{name}`: the identicon, cached for a day.
35 +
pub async fn avatar(Path(name): Path<String>) -> Response {
36 +
    let name = name.trim_end_matches(".svg");
37 +
    ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")], identicon(name)).into_response()
38 +
}
39 +
40 +
#[cfg(test)]
41 +
mod tests {
42 +
    use super::identicon;
43 +
44 +
    #[test]
45 +
    fn identicons_are_stable_symmetric_and_case_blind() {
46 +
        let a = identicon("andy");
47 +
        assert_eq!(a, identicon("Andy"), "names are case-insensitive");
48 +
        assert_ne!(a, identicon("bob"));
49 +
        assert!(a.starts_with("<svg") && a.ends_with("</svg>"));
50 +
        // Every cell has its mirror image.
51 +
        let cells: Vec<(u32, u32)> = a
52 +
            .split(r#"<rect x=""#)
53 +
            .skip(1)
54 +
            .filter_map(|r| {
55 +
                let x: u32 = r.split('"').next()?.parse().ok()?;
56 +
                let y: u32 = r.split(r#"y=""#).nth(1)?.split('"').next()?.parse().ok()?;
57 +
                Some((x, y))
58 +
            })
59 +
            .filter(|(x, _)| (1..=5).contains(x))
60 +
            .collect();
61 +
        for (x, y) in &cells {
62 +
            assert!(cells.contains(&(6 - x, *y)), "({x},{y}) has no mirror in {a}");
63 +
        }
64 +
    }
65 +
}
added templates/feed.html +17 −0
1 +
{% if feed.is_empty() %}
2 +
<p class="empty small">Nothing yet.</p>
3 +
{% else %}
4 +
<ul class="feed">
5 +
{% for a in feed %}
6 +
  <li>
7 +
    <span class="who">{% if a.actor.is_empty() %}the hub{% else %}<img class="avatar xs" src="/avatar/{{ a.actor }}" alt=""> <a href="/{{ a.actor }}">{{ a.actor }}</a>{% endif %}</span>
8 +
    {% if a.kind == "push" %}pushed <span class="lbl lbl-bm">{{ a.event }}</span> to
9 +
    {% else if a.kind == "release" %}published <a href="{{ a.url() }}">{{ a.title }}</a> in
10 +
    {% else %}{{ a.event }} {{ a.kind }} <a href="{{ a.url() }}">#{{ a.number }} {{ a.title }}</a> in
11 +
    {% endif %}
12 +
    <a href="/{{ a.owner }}/{{ a.name }}">{{ a.owner }}/{{ a.name }}</a>
13 +
    <time class="mute small" title="{{ a.at_ms|when_i }}">{{ a.at_ms|ago_i }}</time>
14 +
  </li>
15 +
{% endfor %}
16 +
</ul>
17 +
{% endif %}
added templates/org.html +25 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ org.name }}{% endblock %}
3 +
{% block content %}
4 +
<div class="side-layout profile">
5 +
  <aside class="side who-card">
6 +
    <img class="avatar xl" src="/avatar/{{ org.name }}" alt="">
7 +
    {% if !org.display_name.is_empty() %}<h1 class="display">{{ org.display_name }}</h1>{% endif %}
8 +
    <p class="handle">{{ org.name }} <span class="tag">organisation</span></p>
9 +
    {% if !org.description.is_empty() %}<p class="bio">{{ org.description }}</p>{% endif %}
10 +
    {% if can_admin %}<a class="go small" href="/orgs/{{ org.name }}/settings">settings</a>{% endif %}
11 +
    <h3>Members <small>{{ members.len() }}</small></h3>
12 +
    <ul class="plain">{% for (m, role) in members %}<li><img class="avatar xs" src="/avatar/{{ m.username }}" alt=""> <a href="/{{ m.username }}">{{ m.username }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul>
13 +
    {% if !teams.is_empty() %}
14 +
    <h3>Teams</h3>
15 +
    <ul class="plain">{% for (t, n) in teams %}<li>{{ t.name }} <span class="small mute">{{ n|plural("member", "members") }}</span></li>{% endfor %}</ul>
16 +
    {% endif %}
17 +
  </aside>
18 +
  <div>
19 +
    <h2>Repositories <small>{{ repos.len() }}</small></h2>
20 +
    {% include "repo_list.html" %}
21 +
    <h2>Recent activity</h2>
22 +
    {% include "feed.html" %}
23 +
  </div>
24 +
</div>
25 +
{% endblock %}
added templates/profile.html +31 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ account.username }}{% endblock %}
3 +
{% block content %}
4 +
<div class="side-layout profile">
5 +
  <aside class="side who-card">
6 +
    <img class="avatar xl" src="/avatar/{{ account.username }}" alt="">
7 +
    {% if !account.display_name.is_empty() %}<h1 class="display">{{ account.display_name }}</h1>{% endif %}
8 +
    <p class="handle">{{ account.username }}{% if account.is_admin %} <span class="tag">admin</span>{% endif %}</p>
9 +
    {% if !account.bio.is_empty() %}<p class="bio">{{ account.bio }}</p>{% endif %}
10 +
    <ul class="plain facts-list small">
11 +
      {% if !account.location.is_empty() %}<li><span aria-hidden="true">⌖</span> {{ account.location }}</li>{% endif %}
12 +
      {% if let Some(w) = website_href() %}<li><span aria-hidden="true">↗</span> <a href="{{ w }}" rel="nofollow noopener">{{ w }}</a></li>{% endif %}
13 +
      <li class="mute">joined <time title="{{ account.created_ms|when_i }}">{{ account.created_ms|ago_i }}</time></li>
14 +
    </ul>
15 +
    {% if is_me %}<a class="go small" href="/settings">edit profile</a>{% endif %}
16 +
    {% if !orgs.is_empty() %}
17 +
    <h3>Organisations</h3>
18 +
    <ul class="plain">{% for (o, role) in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul>
19 +
    {% endif %}
20 +
    <h3>Signing keys</h3>
21 +
    {% if keys.is_empty() %}<p class="small mute">None linked.</p>
22 +
    {% else %}<ul class="plain keys small">{% for k in keys %}<li><code title="{{ k.key }}">{{ k.key[..16] }}…</code><br><span class="mute">{{ k.name }}</span></li>{% endfor %}</ul>{% endif %}
23 +
  </aside>
24 +
  <div>
25 +
    <h2>Repositories <small>{{ repos.len() }}</small></h2>
26 +
    {% include "repo_list.html" %}
27 +
    <h2>Recent activity</h2>
28 +
    {% include "feed.html" %}
29 +
  </div>
30 +
</div>
31 +
{% endblock %}
added templates/repo_list.html +16 −0
1 +
{% if repos.is_empty() %}
2 +
<p class="empty small">No repositories you can see.</p>
3 +
{% else %}
4 +
<ul class="rows repos">
5 +
{% for r in repos %}
6 +
  <li>
7 +
    <div>
8 +
      <a class="name" href="/{{ r.owner }}/{{ r.name }}"><strong>{{ r.name }}</strong></a>
9 +
      {% if r.visibility != "public" %}<span class="tag">{{ r.visibility }}</span>{% endif %}
10 +
      {% if !r.description.is_empty() %}<p class="small">{{ r.description }}</p>{% endif %}
11 +
    </div>
12 +
    <div class="right small">updated <time title="{{ r.updated_ms|when_i }}">{{ r.updated_ms|ago_i }}</time></div>
13 +
  </li>
14 +
{% endfor %}
15 +
</ul>
16 +
{% endif %}
modified Cargo.lock +21 −0
…
2037 unchanged lines
2038 2038
 "ignore",
2039 2039
 "ml-dsa",
2040 2040
 "rand 0.8.8",
2041 +
 "rayon",
2041 2042
 "rustls",
2042 2043
 "rustls-pemfile",
2043 2044
 "similar",
…
437 unchanged lines
2481 2482
]
2482 2483
2483 2484
[[package]]
2485 +
name = "rayon"
2486 +
version = "1.12.0"
2487 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2488 +
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
2489 +
dependencies = [
2490 +
 "either",
2491 +
 "rayon-core",
2492 +
]
2493 +
2494 +
[[package]]
2495 +
name = "rayon-core"
2496 +
version = "1.13.0"
2497 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2498 +
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
2499 +
dependencies = [
2500 +
 "crossbeam-deque",
2501 +
 "crossbeam-utils",
2502 +
]
2503 +
2504 +
[[package]]
2484 2505
name = "rcgen"
2485 2506
version = "0.14.10"
2486 2507
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1871 unchanged lines
modified crates/hub/src/db/accounts.rs +19 −0
…
20 unchanged lines
21 21
    pub last_seen_ms: i64,
22 22
    pub email_notifications: bool,
23 23
    pub unsubscribe_key: String,
24 +
    /// Shown on the profile page (migration 0008).
25 +
    #[sqlx(default)]
26 +
    pub bio: String,
27 +
    #[sqlx(default)]
28 +
    pub website: String,
29 +
    #[sqlx(default)]
30 +
    pub location: String,
24 31
}
25 32
26 33
impl Account {
…
129 unchanged lines
156 163
    Ok(())
157 164
}
158 165
166 +
/// The public profile fields.
167 +
pub async fn set_bio(pool: &Pool, id: i64, bio: &str, website: &str, location: &str) -> sqlx::Result<()> {
168 +
    sqlx::query("UPDATE accounts SET bio = ?, website = ?, location = ? WHERE id = ?")
169 +
        .bind(bio)
170 +
        .bind(website)
171 +
        .bind(location)
172 +
        .bind(id)
173 +
        .execute(pool)
174 +
        .await?;
175 +
    Ok(())
176 +
}
177 +
159 178
pub async fn set_password(pool: &Pool, id: i64, password_hash: &str, session_salt: &str) -> sqlx::Result<()> {
160 179
    sqlx::query("UPDATE accounts SET password_hash = ?, session_salt = ? WHERE id = ?")
161 180
        .bind(password_hash)
…
412 unchanged lines
modified crates/hub/src/db/activity.rs +8 −0
…
41 unchanged lines
42 42
43 43
/// The newest `limit` events across the repositories in `repo_ids`.
44 44
pub async fn feed(pool: &Pool, repo_ids: &[i64], limit: i64) -> sqlx::Result<Vec<Activity>> {
45 +
    feed_by(pool, repo_ids, None, limit).await
46 +
}
47 +
48 +
/// Like `feed`, only what `actor` did when one is given (profile pages).
49 +
pub async fn feed_by(pool: &Pool, repo_ids: &[i64], actor: Option<&str>, limit: i64) -> sqlx::Result<Vec<Activity>> {
45 50
    sqlx::query_as(
46 51
        "WITH seen(id) AS (SELECT value FROM json_each(?)), ev AS ( \
47 52
           SELECT repo_id, at_ms, pusher AS actor, 'push' AS kind, reference AS event, '' AS title, 0 AS number FROM pushes WHERE ok = 1 \
…
2 unchanged lines
50 55
           UNION ALL SELECT i.repo_id, e.at_ms, e.actor, 'issue', e.event, i.title, i.number FROM issue_events e JOIN issues i ON i.id = e.issue_id) \
51 56
         SELECT r.owner, r.name, ev.at_ms, ev.actor, ev.kind, ev.event, ev.title, ev.number \
52 57
         FROM ev JOIN repositories r ON r.id = ev.repo_id WHERE ev.repo_id IN (SELECT id FROM seen) \
58 +
           AND (? IS NULL OR ev.actor = ? COLLATE NOCASE) \
53 59
         ORDER BY ev.at_ms DESC LIMIT ?",
54 60
    )
55 61
    .bind(ids_json(repo_ids))
62 +
    .bind(actor)
63 +
    .bind(actor)
56 64
    .bind(limit)
57 65
    .fetch_all(pool)
58 66
    .await
…
71 unchanged lines
modified crates/hub/src/repos/names.rs +2 −0
…
27 unchanged lines
28 28
    "favicon.ico",
29 29
    "favicon.svg",
30 30
    "health",
31 +
    "avatar",
32 +
    "help",
31 33
    "assets",
32 34
    ".well-known",
33 35
];
…
62 unchanged lines
modified crates/hub/src/web/home.rs +71 −8
…
61 unchanged lines
62 62
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None }))
63 63
}
64 64
65 -
pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<HomePage>, Error> {
65 +
/// A person's page (hub spec §6): avatar, names, bio, linked signing
66 +
/// keys, organisations, repositories and what they did lately.
67 +
#[derive(Template)]
68 +
#[template(path = "profile.html")]
69 +
pub struct ProfilePage {
70 +
    shell: Shell,
71 +
    account: db::accounts::Account,
72 +
    keys: Vec<db::accounts::LinkedIdentity>,
73 +
    orgs: Vec<(db::accounts::Organisation, String)>,
74 +
    repos: Vec<db::repos::RepoRow>,
75 +
    feed: Vec<db::activity::Activity>,
76 +
    is_me: bool,
77 +
}
78 +
79 +
impl ProfilePage {
80 +
    /// The website as a link only when it is http(s).
81 +
    fn website_href(&self) -> Option<&str> {
82 +
        let w = self.account.website.trim();
83 +
        (w.starts_with("https://") || w.starts_with("http://")).then_some(w)
84 +
    }
85 +
}
86 +
87 +
/// An organisation's page: description, members with their roles, teams,
88 +
/// repositories and recent activity in them.
89 +
#[derive(Template)]
90 +
#[template(path = "org.html")]
91 +
pub struct OrgPage {
92 +
    shell: Shell,
93 +
    org: db::accounts::Organisation,
94 +
    members: Vec<(db::accounts::Account, String)>,
95 +
    teams: Vec<(db::accounts::Team, usize)>,
96 +
    repos: Vec<db::repos::RepoRow>,
97 +
    feed: Vec<db::activity::Activity>,
98 +
    can_admin: bool,
99 +
}
100 +
101 +
/// How many activity lines an owner page shows.
102 +
const OWNER_FEED: i64 = 20;
103 +
104 +
/// `/{owner}`: a person's profile, an organisation's page, or (for an
105 +
/// owner that is neither, such as repositories an administrator created
106 +
/// for a name nobody holds) the list of its repositories.
107 +
pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Response, Error> {
66 108
    let shell = Shell::new(&app, &format!("/{owner}"), &viewer);
67 109
    if !crate::repos::names::valid_owner(&owner) {
68 110
        return Err(Error::not_found(shell));
69 111
    }
70 -
    let repos =
71 -
        visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?)
72 -
            .await?;
73 -
    let known = db::accounts::account_by_name(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
74 -
        || db::accounts::org_by_name(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some();
75 -
    if repos.is_empty() && !known {
112 +
    let db_err = |e| Error::from_db(shell.clone(), e);
113 +
    let mut repos = visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(db_err)?).await?;
114 +
    repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
115 +
    if let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? {
116 +
        // What they did anywhere the viewer can see, not only in their own repositories.
117 +
        let all = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
118 +
        let ids: Vec<i64> = all.iter().map(|r| r.id).collect();
119 +
        let feed = db::activity::feed_by(&app.db, &ids, Some(&account.username), OWNER_FEED).await.map_err(db_err)?;
120 +
        let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?;
121 +
        let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?;
122 +
        let is_me = viewer.account().is_some_and(|a| a.id == account.id);
123 +
        return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me }).into_response());
124 +
    }
125 +
    if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? {
126 +
        let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?;
127 +
        let mut teams = Vec::new();
128 +
        for t in db::accounts::teams(&app.db, org.id).await.map_err(db_err)? {
129 +
            let n = db::accounts::team_members(&app.db, t.id).await.map_err(db_err)?.len();
130 +
            teams.push((t, n));
131 +
        }
132 +
        let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
133 +
        let feed = db::activity::feed(&app.db, &ids, OWNER_FEED).await.map_err(db_err)?;
134 +
        let can_admin =
135 +
            viewer.is_admin() || viewer.account().is_some_and(|a| members.iter().any(|(m, role)| m.id == a.id && role == "owner"));
136 +
        return Ok(Page(OrgPage { shell, org, members, teams, repos, feed, can_admin }).into_response());
137 +
    }
138 +
    if repos.is_empty() {
76 139
        return Err(Error::not_found(shell));
77 140
    }
78 -
    Ok(Page(HomePage { description: String::new(), shell, repos, owner: Some(owner) }))
141 +
    Ok(Page(HomePage { description: String::new(), shell, repos, owner: Some(owner) }).into_response())
79 142
}
80 143
81 144
/// The repositories among `rows` the viewer may see: public ones, internal
…
36 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
3 unchanged lines
4 4
//! render.
5 5
6 6
mod admin;
7 +
mod avatar;
7 8
pub mod edit;
8 9
pub mod filters;
9 10
pub mod history;
…
27 unchanged lines
37 38
    Router::new()
38 39
        .route("/", get(home::index))
39 40
        .route("/explore", get(home::explore))
41 +
        .route("/avatar/{name}", get(avatar::avatar))
40 42
        .route("/health", get(home::health))
41 43
        .route("/.well-known/oxyhub-key", get(home::hub_key))
42 44
        .route("/style.css", get(statics::style))
…
448 unchanged lines
modified crates/hub/src/web/settings.rs +13 −0
…
109 unchanged lines
110 110
    csrf_token: String,
111 111
    display_name: String,
112 112
    email: String,
113 +
    #[serde(default)]
114 +
    bio: String,
115 +
    #[serde(default)]
116 +
    website: String,
117 +
    #[serde(default)]
118 +
    location: String,
113 119
}
114 120
115 121
pub async fn update_profile(
…
5 unchanged lines
121 127
    let shell = Shell::new(&app, "/settings", &viewer);
122 128
    let me = require(&viewer, &shell)?;
123 129
    csrf(&viewer, &f.csrf_token, &shell)?;
130 +
    let website = f.website.trim();
131 +
    if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 {
132 +
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).error("The website must be an http:// or https:// address.")));
133 +
    }
134 +
    let bio: String = f.bio.trim().chars().take(280).collect();
135 +
    let location: String = f.location.trim().chars().take(80).collect();
124 136
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
125 137
        .await
126 138
        .map_err(|e| Error::from_db(shell.clone(), e))?;
139 +
    accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?;
127 140
    let updated =
128 141
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
129 142
    Ok(Page(SettingsPage::new(shell, updated, &headers).notice("Profile saved.")))
…
378 unchanged lines
modified crates/hub/tests/issues.rs +51 −0
…
378 unchanged lines
379 379
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
380 380
    String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()
381 381
}
382 +
383 +
#[tokio::test(flavor = "multi_thread")]
384 +
async fn profile_and_organisation_pages() {
385 +
    let h = hub().await;
386 +
    let app = &h.app;
387 +
    let andy = register(app, "andy").await;
388 +
    let bob = register(app, "bob").await;
389 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
390 +
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
391 +
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
392 +
    db::repos::create(&app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap();
393 +
    app.repos.create(&RepoName::parse("andy/hidden").unwrap(), None).unwrap();
394 +
    db::repos::create(&app.db, "andy", "hidden", "", hub_api::Visibility::Private).await.unwrap();
395 +
396 +
    // Andy fills in his profile; a script URL and markup are refused or escaped.
397 +
    let csrf = csrf_of(&get(app, "/settings", Some(&andy)).await.body);
398 +
    let r = post(app, "/settings", Some(&andy), &format!("_csrf={csrf}&display_name=Andy+McCall&email=a%40example.com&bio=Builds+%3Cb%3Eoxy%3C%2Fb%3E&website=javascript%3Aalert(1)&location=Earth")).await;
399 +
    assert!(r.body.contains("must be an http"), "{}", r.body);
400 +
    let r = post(app, "/settings", Some(&andy), &format!("_csrf={csrf}&display_name=Andy+McCall&email=a%40example.com&bio=Builds+%3Cb%3Eoxy%3C%2Fb%3E&website=https%3A%2F%2Fexample.org&location=Earth")).await;
401 +
    assert!(r.body.contains("Profile saved"), "{}", r.body);
402 +
403 +
    let org = db::accounts::create_org(&app.db, "retro", "Retro Club", andy_id).await.unwrap();
404 +
    db::accounts::set_org_member(&app.db, org.id, bob_id, "member").await.unwrap();
405 +
    let team = db::accounts::create_team(&app.db, org.id, "core").await.unwrap();
406 +
    db::accounts::add_team_member(&app.db, team.id, bob_id).await.unwrap();
407 +
408 +
    let page = get(app, "/andy", Some(&bob)).await;
409 +
    assert_eq!(page.status, StatusCode::OK);
410 +
    for want in ["Andy McCall", "Earth", r#"href="https://example.org""#, "/avatar/andy", "Signing keys", "/retro", "demo"] {
411 +
        assert!(page.body.contains(want), "profile lacks {want}: {}", page.body);
412 +
    }
413 +
    assert!(!page.body.contains("<b>oxy</b>"), "the bio is escaped");
414 +
    assert!(!page.body.contains("[email protected]"), "the email stays private");
415 +
    assert!(!page.body.contains("/andy/hidden"), "bob cannot see the private repository");
416 +
    assert!(!page.body.contains("edit profile"), "only the owner gets the edit link");
417 +
    assert!(get(app, "/andy", Some(&andy)).await.body.contains("/andy/hidden"), "andy sees his own private repository");
418 +
419 +
    let page = get(app, "/retro", Some(&bob)).await;
420 +
    assert_eq!(page.status, StatusCode::OK);
421 +
    for want in ["Retro Club", "organisation", "Members <small>2</small>", "core", "1 member"] {
422 +
        assert!(page.body.contains(want), "org page lacks {want}: {}", page.body);
423 +
    }
424 +
    assert!(!page.body.contains("/orgs/retro/settings"), "bob is not an owner");
425 +
    assert!(get(app, "/retro", Some(&andy)).await.body.contains("/orgs/retro/settings"));
426 +
427 +
    let svg = get(app, "/avatar/andy", None).await;
428 +
    assert_eq!(svg.status, StatusCode::OK);
429 +
    assert!(svg.body.starts_with("<svg"));
430 +
    assert_eq!(svg.body, get(app, "/avatar/ANDY", None).await.body);
431 +
    assert_eq!(get(app, "/nobody-here", None).await.status, StatusCode::NOT_FOUND);
432 +
}
modified docs/spec.md +1 −1
…
226 unchanged lines
227 227
|---|---|
228 228
| `/` | Signed in: dashboard (your repositories, proposals awaiting your review, assigned issues, recent activity). Anonymous: explore. |
229 229
| `/explore` | Public repositories, sorted by activity; language and topic filters |
230 -
| `/{owner}` | Profile or organisation: repositories, linked keys with fingerprints, activity |
230 +
| `/{owner}` | A person: identicon, display name, bio, location, website, linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). |
231 231
| `/{owner}/{name}` | Repository overview (§6.4); while empty, a quickstart with the exact commands for this hub (remote, certificate, token, push; start from nothing; import from Git) |
232 232
| `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag or change (§6.5) |
233 233
| `/{owner}/{name}/file/{ref}/{path}` | File view, `?plain=1` raw, `?blame=1` blame (§6.6) |
…
311 unchanged lines
modified templates/base.html +1 −1
…
16 unchanged lines
17 17
    {% if let Some(u) = shell.user %}
18 18
    <a class="go" href="/new" title="New repository">+ NEW</a>
19 19
    <a class="bell" href="/notifications" title="Notifications">✉{% if shell.unread > 0 %}<span class="count">{{ shell.unread }}</span>{% endif %}</a>
20 -
    <a class="me" href="/{{ u }}">{{ u }}</a>
20 +
    <a class="me" href="/{{ u }}"><img class="avatar xs" src="/avatar/{{ u }}" alt=""> {{ u }}</a>
21 21
    <form class="inline" method="post" action="/logout"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>SIGN OUT</button></form>
22 22
    {% else %}
23 23
    <a class="go" href="/login">SIGN IN</a>
…
35 unchanged lines
modified templates/dashboard.html +1 −1
…
58 unchanged lines
59 59
    <ul class="feed">
60 60
    {% for a in feed %}
61 61
      <li>
62 -
        <span class="who">{% if a.actor.is_empty() %}the hub{% else %}<a href="/{{ a.actor }}">{{ a.actor }}</a>{% endif %}</span>
62 +
        <span class="who">{% if a.actor.is_empty() %}the hub{% else %}<img class="avatar xs" src="/avatar/{{ a.actor }}" alt=""> <a href="/{{ a.actor }}">{{ a.actor }}</a>{% endif %}</span>
63 63
        {% if a.kind == "push" %}pushed <span class="lbl lbl-bm">{{ a.event }}</span> to
64 64
        {% else if a.kind == "release" %}published <a href="{{ a.url() }}">{{ a.title }}</a> in
65 65
        {% else %}{{ a.event }} {{ a.kind }} <a href="{{ a.url() }}">#{{ a.number }} {{ a.title }}</a> in
…
22 unchanged lines
modified templates/settings.html +5 −0
…
9 unchanged lines
10 10
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
11 11
  <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name" value="{{ me.display_name }}">
12 12
  <label for="email">Email</label><input type="email" id="email" name="email" value="{{ me.email }}">
13 +
  <p class="small mute">Your email is never shown on your profile.</p>
14 +
  <label for="bio">Bio</label><textarea id="bio" name="bio" rows="3" maxlength="280">{{ me.bio }}</textarea>
15 +
  <label for="website">Website</label><input type="url" id="website" name="website" value="{{ me.website }}" placeholder="https://">
16 +
  <label for="location">Location</label><input type="text" id="location" name="location" value="{{ me.location }}" maxlength="80">
17 +
  <p class="small mute">Shown on <a href="/{{ me.username }}">your profile</a>, with a picture drawn from your username.</p>
13 18
  <button type="submit">SAVE</button>
14 19
</form>
15 20
<form class="card" method="post" action="/settings/appearance">
…
19 unchanged lines
modified templates/style.css +15 −0
…
320 unchanged lines
321 321
.hits .hit code { background: none; }
322 322
323 323
.scopes label { display: block; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--ink); margin: 4px 0; }
324 +
325 +
/* avatars and owner pages */
326 +
img.avatar { display: inline-block; vertical-align: middle; border: 1px solid var(--line); image-rendering: pixelated; }
327 +
img.avatar.xs { width: 18px; height: 18px; }
328 +
img.avatar.sm { width: 24px; height: 24px; }
329 +
img.avatar.xl { width: 100%; max-width: 240px; height: auto; aspect-ratio: 1; }
330 +
.profile .who-card h1.display { font-size: 24px; font-weight: 600; margin: 12px 0 0; }
331 +
.profile .who-card .handle { font-size: 18px; color: var(--mute); margin: 2px 0 10px; }
332 +
.profile .who-card .bio { margin: 0 0 10px; }
333 +
.profile .who-card .facts-list li { padding: 2px 0; }
334 +
.profile h2 { font-size: 15px; margin: 0 0 8px; }
335 +
.profile h2 ~ h2 { margin-top: 18px; }
336 +
.profile .keys li { margin-bottom: 6px; }
337 +
.profile .side-layout, .side-layout.profile { grid-template-columns: 260px 1fr; }
338 +
@media (max-width: 760px) { .side-layout.profile { grid-template-columns: 1fr; } img.avatar.xl { max-width: 120px; } }