git-ents.gitmain
⌘K
foforge
members.rs244 lines · 8.5 KB · rusthistorycomment 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
10use std::sync::Arc;
11
12use axum::extract::{Path, State};
13use ents_model::{Member, Provenance};
14use gix_object::{Find, Write};
15use maud::{Markup, html};
16
17use crate::error::{Error, Result};
18use crate::state::AppState;
19
20/// `GET /members`.
21///
22/// # Errors
23///
24/// Propagates a ref-store or object read failure.
25pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
26where
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).
69pub async fn show<O>(
70 State(state): State<Arc<AppState<O>>>,
71 Path(username): Path<String>,
72) -> Result<maud::Markup>
73where
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.
107pub(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).
140fn 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.
152fn 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.
165fn 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).
181pub(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)]
209mod 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}