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; } } |