git-ents.gitmain
⌘K
foforge
account.rs244 lines · 9.4 KB · rusthistorycomment on this file
1//! `GET /account`, `POST /account`: who the current session is (the
2//! serving identity's enrolled member, `roots.web-signing` -- there is no
3//! login flow, the signing key *is* the identity), followed by the
4//! generic *view* of [`ents_model::Account`] (`crate::render::view`,
5//! reflection-driven, the same mechanism [`super::members`] and
6//! [`super::redactions`] use), paired with this crate's one demonstrated
7//! generic-edit write flow (`roots.web-session`'s signed, CSRF-checked
8//! mutation path).
9//!
10//! Account is the write-flow demo rather than every entity because it is
11//! the simplest possible case -- two string-shaped fields, one fixed ref,
12//! no anchor or recipe machinery to special-case -- so the CSRF/session/
13//! signing plumbing this page exercises is visible without also chasing a
14//! more complex entity's own domain logic. Every other write flow this
15//! crate ships ([`super::comments::add`]) is a legitimate custom page for
16//! exactly the reason `ents-forge`'s own comment command is: anchoring
17//! needs a repository checkout and a projection, not a bare form.
18
19use std::sync::Arc;
20
21use axum::Form;
22use axum::extract::State;
23use axum::response::{IntoResponse, Redirect};
24use ents_model::{Account, Member, MemberId, namespace};
25use ents_receive::propose_entity;
26use gix_object::{Find, Write};
27use maud::html;
28use serde::Deserialize;
29
30use crate::error::{Error, Result};
31use crate::session::Session;
32use crate::state::AppState;
33
34/// `GET /account`: who the current session is, first -- the enrolled
35/// member whose key the serving identity signs with, as the same identity
36/// card `crate::pages::members` renders, or the unenrolled key itself --
37/// then the recorded [`Account`] (the hosted login mapping) with its edit
38/// form. There is no login flow to land on: a local root's identity *is*
39/// the signing key `git ents serve` resolved at startup
40/// (`roots.web-signing`), so this page states that rather than asking for
41/// credentials.
42///
43/// # Errors
44///
45/// Propagates a ref-store or object read failure.
46pub async fn show<O>(
47 State(state): State<Arc<AppState<O>>>,
48 axum::Extension(session): axum::Extension<Session>,
49) -> Result<maud::Markup>
50where
51 O: Find + Write + Send + 'static,
52{
53 let pubkey = state.identity.public_openssh();
54 let enrolled = resolve_member_by_key(&state, &pubkey).ok();
55 let current = read(&state)?;
56 let (member_value, login_value) = match &current {
57 Some(account) => (account.member.as_str().to_owned(), account.login.clone()),
58 None => (String::new(), String::new()),
59 };
60 let view = current
61 .as_ref()
62 .map(crate::render::view)
63 .unwrap_or_else(|| html! { p.muted { "No login mapping recorded." } });
64
65 Ok(super::layout(
66 &super::RepoHeader::from_state(&state),
67 &super::identity_label(&state),
68 super::Tab::Account,
69 "Account",
70 html! {
71 div.readable {
72 h2 { "Signing key" }
73 @match &enrolled {
74 Some((username, member)) => {
75 p {
76 "Signed in as the member below. Every web edit is a "
77 "mutation commit signed with this key, exactly as "
78 code { "git ents" }
79 " itself would sign it — a local root has no separate login."
80 }
81 (super::members::member_card(username.as_str(), member, true))
82 }
83 None => {
84 div.card.member-card {
85 div.member-head {
86 span.member-name { "Unenrolled key" }
87 @if let Some(key_type) = pubkey.split_whitespace().next() {
88 span.key-badge { (key_type) }
89 }
90 }
91 p {
92 "This signing key is not enrolled as a "
93 a href="/members" { "member" }
94 " of this repository. Edits still sign with it; enroll "
95 "the key to have them attributed to a username."
96 }
97 div.member-key {
98 pre { (pubkey) }
99 }
100 }
101 }
102 }
103 h2 { "Hosted login" }
104 p.muted {
105 "A hosted deployment maps an external login to an enrolled "
106 "member so its pushes can be attributed. A local root never "
107 "needs one — the key above is the identity."
108 }
109 (view)
110 details.disclosure {
111 summary { "Edit login mapping" }
112 form method="post" action="/account" {
113 (super::csrf_input(&session))
114 label { "Member" input type="text" name="member" value=(member_value) list="members"; }
115 label { "Login" input type="text" name="login" value=(login_value); }
116 button type="submit" { "Save" }
117 }
118 (super::members_datalist(&state))
119 }
120 }
121 },
122 ))
123}
124
125/// The form fields `POST /account` accepts.
126#[derive(Debug, Deserialize)]
127pub struct AccountForm {
128 /// The member this account belongs to; if blank, resolved from the
129 /// signing identity's own enrolled key (mirrors
130 /// `git_ents::commands::account::create`'s identical default).
131 #[serde(default)]
132 member: String,
133 /// The login identity to record.
134 login: String,
135 /// The per-session CSRF token (`roots.web-session`).
136 csrf: String,
137}
138
139/// `POST /account`: create or update the account, signed
140/// (`roots.web-signing`) on behalf of the current session
141/// (`roots.web-session`).
142///
143/// # Errors
144///
145/// [`Error::BadCsrf`] if `form.csrf` does not match the session's own
146/// token; [`Error::NotFound`] if `member` is blank and the signing
147/// identity's key is not an enrolled member; otherwise propagates a
148/// serialization or `receive` failure.
149// @relation(roots.web-signing, roots.web-session, scope=function)
150pub async fn update<O>(
151 State(state): State<Arc<AppState<O>>>,
152 axum::Extension(session): axum::Extension<Session>,
153 Form(form): Form<AccountForm>,
154) -> Result<impl IntoResponse>
155where
156 O: Find + Write + Send + 'static,
157{
158 super::require_csrf(&session, &form.csrf)?;
159
160 let member = if form.member.trim().is_empty() {
161 resolve_member_by_key(&state, &state.identity.public_openssh())?.0
162 } else {
163 MemberId::new(form.member.trim())
164 };
165 let account = Account {
166 member,
167 login: form.login,
168 };
169
170 #[expect(
171 clippy::expect_used,
172 reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal, mirroring \
173 git_ents::commands::account's identical unguarded conversion"
174 )]
175 let name: gix::refs::FullName = namespace::ACCOUNT_REF
176 .try_into()
177 .expect("fixed, valid refname");
178
179 let identity = state.identity.as_ref();
180 let outcome = propose_entity(
181 state.refs.as_ref(),
182 &*state.objects(),
183 state.events.as_ref(),
184 name,
185 &account,
186 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
187 "Create account (web)",
188 state.mode,
189 )?;
190 crate::error::outcome_to_result(outcome)?;
191 Ok(Redirect::to("/account"))
192}
193
194fn read<O: Find>(state: &AppState<O>) -> Result<Option<Account>> {
195 #[expect(
196 clippy::expect_used,
197 clippy::unwrap_in_result,
198 reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal"
199 )]
200 let name: gix::refs::FullName = namespace::ACCOUNT_REF
201 .try_into()
202 .expect("fixed, valid refname");
203 let Some(tip) = state.refs.get(name.as_ref())? else {
204 return Ok(None);
205 };
206 let tree = super::commit_tree(&*state.objects(), tip)?;
207 Ok(Some(facet_git_tree::deserialize::<Account>(
208 &tree,
209 &*state.objects(),
210 )?))
211}
212
213/// Resolve `pubkey` to the enrolled member whose stored key matches it
214/// (its id and full [`Member`] record), or [`Error::NotFound`] when none
215/// does — shared with `crate::pages::commits::review`, which needs the
216/// same "which member is this session" lookup to key a review's composite
217/// `refs/meta/reviews/<target>/<member>` ref (`model.review`), and with
218/// [`show`]'s own signed-in-as card.
219///
220/// # Errors
221///
222/// [`Error::NotFound`] if no enrolled member's key matches `pubkey`;
223/// otherwise propagates a ref-store or object read failure.
224pub(crate) fn resolve_member_by_key<O: Find>(
225 state: &AppState<O>,
226 pubkey: &str,
227) -> Result<(MemberId, Member)> {
228 for entry in state.refs.iter_prefix("refs/meta/member/")? {
229 let (name, tip) = entry?;
230 let path = name.as_bstr().to_string();
231 let Some(username) = path.strip_prefix("refs/meta/member/") else {
232 continue;
233 };
234 let tree = super::commit_tree(&*state.objects(), tip)?;
235 if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects())
236 && member.key == pubkey
237 {
238 return Ok((MemberId::new(username), member));
239 }
240 }
241 Err(Error::NotFound {
242 what: "member for the current signing identity".to_owned(),
243 })
244}