crates/cli/ents-web/src/pages/members.rs
members.rshistorycomment on this file
| 1 | //! `GET /members`, `GET /members/{username}`: the member surface -- an |
| 2 | //! identity card per enrolled key rather than [`crate::render`]'s generic |
| 3 | //! table (an SSH public key's base64 body defeats a table cell; the card |
| 4 | //! shows the key type as a badge and the material truncated through the |
| 5 | //! middle, with the full line behind a `<details>` toggle). Read-only in |
| 6 | //! this phase (enrollment stays a `git ents members add` operation; see |
| 7 | //! this crate's own top-level doc for why write flows are demonstrated on |
| 8 | //! [`super::account`] rather than duplicated per entity). |
| 9 | |
| 10 | use std::sync::Arc; |
| 11 | |
| 12 | use axum::extract::{Path, State}; |
| 13 | use ents_model::{Member, Provenance}; |
| 14 | use gix_object::{Find, Write}; |
| 15 | use maud::{Markup, html}; |
| 16 | |
| 17 | use crate::error::{Error, Result}; |
| 18 | use crate::state::AppState; |
| 19 | |
| 20 | /// `GET /members`. |
| 21 | /// |
| 22 | /// # Errors |
| 23 | /// |
| 24 | /// Propagates a ref-store or object read failure. |
| 25 | pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup> |
| 26 | where |
| 27 | O: Find + Write + Send + 'static, |
| 28 | { |
| 29 | let mut rows = Vec::new(); |
| 30 | let mut failures = Vec::new(); |
| 31 | for (username, member) in read_all(&state)? { |
| 32 | match member { |
| 33 | Ok(member) => rows.push((username, member)), |
| 34 | Err(error) => failures.push((format!("refs/meta/member/{username}"), error)), |
| 35 | } |
| 36 | } |
| 37 | let body = if rows.is_empty() { |
| 38 | super::blankslate( |
| 39 | "No members yet", |
| 40 | maud::html! { "Enroll one with " code { "git ents members add" } "." }, |
| 41 | ) |
| 42 | } else { |
| 43 | html! { |
| 44 | @for (username, member) in &rows { |
| 45 | (member_card(username, member, true)) |
| 46 | } |
| 47 | } |
| 48 | }; |
| 49 | Ok(super::layout_meta( |
| 50 | &super::RepoHeader::from_state(&state), |
| 51 | &super::identity_label(&state), |
| 52 | "/members", |
| 53 | "Members", |
| 54 | maud::html! { |
| 55 | (crate::render::unreadable_disclosure(&failures)) |
| 56 | (body) |
| 57 | }, |
| 58 | )) |
| 59 | } |
| 60 | |
| 61 | /// `GET /members/{username}`. |
| 62 | /// |
| 63 | /// # Errors |
| 64 | /// |
| 65 | /// [`Error::NotFound`] if `username` has no member ref at all -- a member |
| 66 | /// ref that exists but whose stored tree does not match this build's |
| 67 | /// [`Member`] shape degrades to [`crate::render::unreadable`] instead |
| 68 | /// (`roots.web-agnostic`'s graceful-degradation stance). |
| 69 | pub async fn show<O>( |
| 70 | State(state): State<Arc<AppState<O>>>, |
| 71 | Path(username): Path<String>, |
| 72 | ) -> Result<maud::Markup> |
| 73 | where |
| 74 | O: Find + Write + Send + 'static, |
| 75 | { |
| 76 | let (_, member) = read_all(&state)? |
| 77 | .into_iter() |
| 78 | .find(|(name, _)| *name == username) |
| 79 | .ok_or_else(|| Error::NotFound { |
| 80 | what: format!("member {username}"), |
| 81 | })?; |
| 82 | let body = match member { |
| 83 | Ok(member) => member_card(&username, &member, false), |
| 84 | Err(detail) => crate::render::unreadable(&detail), |
| 85 | }; |
| 86 | Ok(super::layout_meta( |
| 87 | &super::RepoHeader::from_state(&state), |
| 88 | &super::identity_label(&state), |
| 89 | "/members", |
| 90 | &username, |
| 91 | maud::html! { |
| 92 | (super::child_crumbs("members", "/members", &username)) |
| 93 | (body) |
| 94 | }, |
| 95 | )) |
| 96 | } |
| 97 | |
| 98 | /// One member's identity card: [`super::avatar`] beside the username |
| 99 | /// prominent (a link on the list page, plain on the member's own page), |
| 100 | /// the key type as a badge, the state and provenance as muted badges, and |
| 101 | /// the key material truncated through the middle ([`truncate_middle`]) |
| 102 | /// with the full key line behind a `<details>` toggle -- no digest |
| 103 | /// dependency, so no fingerprint; the truncated material plus the |
| 104 | /// expandable full line is the identity a reader compares. Also |
| 105 | /// `crate::pages::account`'s signed-in-as card, so "you" and "a member" |
| 106 | /// render identically. |
| 107 | pub(crate) fn member_card(username: &str, member: &Member, link: bool) -> Markup { |
| 108 | let (key_type, material) = split_key(&member.key); |
| 109 | html! { |
| 110 | div.card.member-card { |
| 111 | div.member-head { |
| 112 | (super::avatar(username)) |
| 113 | @if link { |
| 114 | a.member-name href={ "/members/" (username) } { (username) } |
| 115 | } @else { |
| 116 | span.member-name { (username) } |
| 117 | } |
| 118 | @if let Some(key_type) = key_type { |
| 119 | span.key-badge { (key_type) } |
| 120 | } |
| 121 | span.badge { (member.state) } |
| 122 | span.badge { (provenance_label(member.provenance)) } |
| 123 | } |
| 124 | div.member-key { |
| 125 | code { (truncate_middle(material)) } |
| 126 | details { |
| 127 | summary { "full key" } |
| 128 | pre { (member.key) } |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// A member's key line split into its type token (`ssh-ed25519`, ...) and |
| 136 | /// key material -- `(None, whole line)` when the line has no second token |
| 137 | /// to badge (`ents-model` treats the key as opaque text, so this only ever |
| 138 | /// assumes the OpenSSH `type material [comment]` shape when it actually |
| 139 | /// sees one). |
| 140 | fn split_key(key: &str) -> (Option<&str>, &str) { |
| 141 | let mut parts = key.split_whitespace(); |
| 142 | let first = parts.next().unwrap_or(""); |
| 143 | match parts.next() { |
| 144 | Some(material) => (Some(first), material), |
| 145 | None => (None, first), |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | /// Key material truncated through the middle (`AAAA…zM7f`), leaving the |
| 150 | /// start and end a reader actually compares -- the full line stays one |
| 151 | /// `<details>` toggle away. |
| 152 | fn truncate_middle(material: &str) -> String { |
| 153 | const HEAD: usize = 12; |
| 154 | const TAIL: usize = 8; |
| 155 | let count = material.chars().count(); |
| 156 | if count <= HEAD.saturating_add(TAIL).saturating_add(1) { |
| 157 | return material.to_owned(); |
| 158 | } |
| 159 | let head: String = material.chars().take(HEAD).collect(); |
| 160 | let tail: String = material.chars().skip(count.saturating_sub(TAIL)).collect(); |
| 161 | format!("{head}\u{2026}{tail}") |
| 162 | } |
| 163 | |
| 164 | /// [`Provenance`] as its badge text. |
| 165 | fn provenance_label(provenance: Provenance) -> &'static str { |
| 166 | match provenance { |
| 167 | Provenance::AdminRegistered => "admin-registered", |
| 168 | Provenance::SelfAttested => "self-attested", |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | /// Every `refs/meta/member/*` ref, with its tip's tree deserialized as a |
| 173 | /// [`Member`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]` |
| 174 | /// shape could not read back, kept in the listing (not dropped) so |
| 175 | /// [`list`] can surface it through |
| 176 | /// [`crate::render::unreadable_disclosure`] and [`show`] as |
| 177 | /// [`crate::render::unreadable`]'s marker card, rather than silently |
| 178 | /// omitting it (`roots.web-agnostic`: a reader surfaces a marker, never an |
| 179 | /// error or a silent gap, for one entity written by a schema this build no |
| 180 | /// longer speaks). |
| 181 | pub(crate) fn read_all<O: Find>( |
| 182 | state: &AppState<O>, |
| 183 | ) -> Result<Vec<(String, std::result::Result<Member, String>)>> { |
| 184 | let mut out = Vec::new(); |
| 185 | for entry in state.refs.iter_prefix("refs/meta/member/")? { |
| 186 | let (name, tip) = entry?; |
| 187 | let path = name.as_bstr().to_string(); |
| 188 | let Some(username) = path.strip_prefix("refs/meta/member/") else { |
| 189 | continue; |
| 190 | }; |
| 191 | // One `state.objects()` lock per iteration, reused for both reads: |
| 192 | // `state.objects()` a second time *within the same statement* |
| 193 | // would try to lock this non-reentrant `Mutex` while the first |
| 194 | // guard is still alive (a `let`'s temporaries live to its own |
| 195 | // `;`), self-deadlocking forever rather than erroring. |
| 196 | let objects = state.objects(); |
| 197 | let member = super::commit_tree(&*objects, tip) |
| 198 | .map_err(|error| error.to_string()) |
| 199 | .and_then(|tree| { |
| 200 | facet_git_tree::deserialize::<Member>(&tree, &*objects) |
| 201 | .map_err(|error| error.to_string()) |
| 202 | }); |
| 203 | out.push((username.to_owned(), member)); |
| 204 | } |
| 205 | Ok(out) |
| 206 | } |
| 207 | |
| 208 | #[cfg(test)] |
| 209 | mod tests { |
| 210 | #![allow(clippy::expect_used, reason = "unit test")] |
| 211 | |
| 212 | use rstest::rstest; |
| 213 | |
| 214 | use super::*; |
| 215 | |
| 216 | #[rstest] |
| 217 | #[case::openssh( |
| 218 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJq4 jdc@host", |
| 219 | Some("ssh-ed25519"), |
| 220 | "AAAAC3NzaC1lZDI1NTE5AAAAIJq4" |
| 221 | )] |
| 222 | #[case::bare_token("opaquekeymaterial", None, "opaquekeymaterial")] |
| 223 | fn split_key_badges_only_a_typed_key_line( |
| 224 | #[case] key: &str, |
| 225 | #[case] key_type: Option<&str>, |
| 226 | #[case] material: &str, |
| 227 | ) { |
| 228 | assert_eq!(split_key(key), (key_type, material)); |
| 229 | } |
| 230 | |
| 231 | #[test] |
| 232 | fn truncate_middle_keeps_the_start_and_end_of_a_long_key() { |
| 233 | let material = "AAAAC3NzaC1lZDI1NTE5AAAAIJq4rB5zM7f"; |
| 234 | let shown = truncate_middle(material); |
| 235 | assert!(shown.starts_with("AAAAC3NzaC1l")); |
| 236 | assert!(shown.ends_with("rB5zM7f")); |
| 237 | assert!(shown.contains('\u{2026}')); |
| 238 | assert_eq!( |
| 239 | truncate_middle("short"), |
| 240 | "short", |
| 241 | "a short token is left whole" |
| 242 | ); |
| 243 | } |
| 244 | } |