git-ents.gitmain
⌘K
foforge
toolchains.rs237 lines · 9.1 KB · rusthistorycomment on this file
1//! `GET /toolchains`, `GET /toolchains/{name}`: a custom (not generic)
2//! page family, per this crate's own top-level doc -- a toolchain's
3//! [`ents_kiln::Recipe`] needs domain-specific rendering (`Embedded` vs
4//! `Downloaded`, each with its own provenance shape) that would otherwise
5//! push a `match Recipe::Embedded { .. } => ...` into the generic
6//! reflection walk [`crate::render`] exists to keep type-agnostic.
7//! Directory import stays a `git ents toolchain import` operation (it
8//! takes a local directory path, not form data a browser can supply);
9//! what `POST /toolchains` wires instead is [`toolchain::register`],
10//! taking a recipe as text ([`ents_kiln::Recipe::parse`]'s own format)
11//! -- an `embedded <tree-oid>` line or a `downloaded` component list is
12//! exactly form data.
13
14use std::sync::Arc;
15
16use axum::Form;
17use axum::extract::{Path, State};
18use axum::response::{IntoResponse, Redirect};
19use ents_kiln::toolchain;
20use gix_object::{Find, Write};
21use maud::html;
22use serde::Deserialize;
23
24use crate::error::{Error, Result};
25use crate::session::Session;
26use crate::state::AppState;
27
28/// `GET /toolchains`.
29///
30/// Every name resolves its own recipe (`toolchain::view`) so a name whose
31/// stored tree does not match this build's [`ents_kiln::Toolchain`]/
32/// [`ents_kiln::Recipe`] shape (written by an older schema) surfaces in
33/// the same [`crate::render::unreadable_disclosure`] every other entity
34/// family's list page renders, while its name stays linked in the listing
35/// (its show page renders the unreadable marker card) -- hand-rolled here
36/// since [`toolchain::list`] itself only enumerates ref names, with no
37/// reflected entity for [`crate::render`]'s generic machinery to walk
38/// (this page family's own top-level doc).
39///
40/// # Errors
41///
42/// Propagates a ref-store read failure.
43pub async fn list<O>(
44 State(state): State<Arc<AppState<O>>>,
45 axum::Extension(session): axum::Extension<Session>,
46) -> Result<maud::Markup>
47where
48 O: Find + Write + Send + 'static,
49{
50 let names = toolchain::list(state.refs.as_ref())?;
51 let mut failures = Vec::new();
52 for name in &names {
53 if let Err(error) = toolchain::view(state.refs.as_ref(), &*state.objects(), name) {
54 failures.push((format!("refs/meta/toolchains/{name}"), error.to_string()));
55 }
56 }
57 let listing = if names.is_empty() {
58 super::blankslate(
59 "No toolchains yet",
60 html! { "Import one with " code { "git ents toolchain import" } "." },
61 )
62 } else {
63 crate::render::string_list(&names, |name| format!("/toolchains/{name}"))
64 };
65 Ok(super::layout_meta(
66 &super::RepoHeader::from_state(&state),
67 &super::identity_label(&state),
68 "/toolchains",
69 "Toolchains",
70 html! {
71 (crate::render::unreadable_disclosure(&failures))
72 (listing)
73 div.card {
74 div.card-header { "Import a toolchain" }
75 (import_form(&session))
76 }
77 },
78 ))
79}
80
81/// The import-toolchain form (`POST /toolchains`): a name and a recipe in
82/// [`ents_kiln::Recipe::parse`]'s own text format.
83fn import_form(session: &Session) -> maud::Markup {
84 html! {
85 form method="post" action="/toolchains" {
86 (super::csrf_input(session))
87 label { "Name" input type="text" name="name"; }
88 label {
89 "Recipe"
90 textarea name="recipe"
91 placeholder="embedded <tree-oid>\nor:\ndownloaded\n<url> <sha256> <strip> [dest]" {}
92 }
93 button type="submit" { "Import Toolchain" }
94 }
95 }
96}
97
98/// The form fields `POST /toolchains` accepts.
99#[derive(Debug, Deserialize)]
100pub struct ImportForm {
101 /// Name to record the toolchain under (`refs/meta/toolchains/<name>`).
102 name: String,
103 /// The recipe text ([`ents_kiln::Recipe::parse`]).
104 recipe: String,
105 /// The per-session CSRF token (`roots.web-session`).
106 csrf: String,
107}
108
109/// `POST /toolchains`: record a toolchain from a recipe given as text
110/// ([`toolchain::register`]) as a signed mutation on
111/// `refs/meta/toolchains/<name>` -- the recipe-flow counterpart of
112/// `git ents toolchain import`, whose directory walk cannot arrive as
113/// form data (this module's own top-level doc).
114///
115/// # Errors
116///
117/// [`crate::Error::BadCsrf`] if `form.csrf` does not match;
118/// [`Error::InvalidArgument`] on a recipe that does not parse or a name
119/// that cannot form a ref; otherwise propagates the `receive` proposal's
120/// own failures.
121// @relation(roots.web-signing, roots.web-session, scope=function)
122pub async fn register<O>(
123 State(state): State<Arc<AppState<O>>>,
124 axum::Extension(session): axum::Extension<Session>,
125 Form(form): Form<ImportForm>,
126) -> Result<impl IntoResponse>
127where
128 O: Find + Write + Send + 'static,
129{
130 super::require_csrf(&session, &form.csrf)?;
131 let recipe = ents_kiln::Recipe::parse(&form.recipe)
132 .map_err(|source| Error::InvalidArgument(format!("invalid recipe: {source}")))?;
133 let name = form.name.trim();
134 let identity = state.identity.as_ref();
135 let outcome = toolchain::register(
136 state.refs.as_ref(),
137 &*state.objects(),
138 state.events.as_ref(),
139 name,
140 &recipe,
141 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
142 state.mode,
143 )
144 .map_err(|source| match source {
145 ents_effect::Error::InvalidToolchainName(bad) => {
146 Error::InvalidArgument(format!("invalid toolchain name: {bad}"))
147 }
148 other => Error::from(other),
149 })?;
150 crate::error::outcome_to_result(outcome)?;
151 Ok(Redirect::to(&format!("/toolchains/{name}")))
152}
153
154/// `GET /toolchains/{name}`: the toolchain's recorded recipe and import
155/// log.
156///
157/// # Errors
158///
159/// [`Error::NotFound`] if `name` has no toolchain ref at all
160/// ([`ents_effect::Error::UnknownToolchain`]) -- a toolchain ref that
161/// exists but whose stored tree does not match this build's
162/// [`ents_kiln::Toolchain`]/[`ents_kiln::Recipe`] shape degrades to
163/// [`crate::render::unreadable`] instead (`roots.web-agnostic`'s
164/// graceful-degradation stance). The import log is best-effort once the
165/// recipe itself reads back: a log entry this build cannot decode renders
166/// as an empty log rather than failing the whole page, since the recipe is
167/// this page's primary content.
168pub async fn show<O>(
169 State(state): State<Arc<AppState<O>>>,
170 Path(name): Path<String>,
171) -> Result<maud::Markup>
172where
173 O: Find + Write + Send + 'static,
174{
175 // One `state.objects()` lock, reused for both `view` and `log`: a
176 // `match` scrutinee's own temporaries live for the whole match (arms
177 // included), so a second `state.objects()` inside the `Ok` arm below
178 // would try to lock this non-reentrant `Mutex` while the scrutinee's
179 // own guard is still held, self-deadlocking forever rather than
180 // erroring (see `crate::pages::members::read_all`'s identical
181 // rationale).
182 let objects = state.objects();
183 let body = match toolchain::view(state.refs.as_ref(), &*objects, &name) {
184 Ok((toolchain, recipe)) => {
185 let log = toolchain::log(state.refs.as_ref(), &*objects, &name).unwrap_or_default();
186 html! {
187 div.card {
188 dl.entity-view {
189 dt { "name" } dd { (toolchain.name) }
190 dt { "recipe" } dd { (format!("{recipe:?}")) }
191 }
192 }
193 h2 { "Import Log" }
194 @if log.is_empty() {
195 (super::blankslate(
196 "No import log",
197 html! { "This toolchain has no recorded import history." },
198 ))
199 } @else {
200 div.card {
201 ul.string-list {
202 @for oid in &log {
203 li {
204 code { (super::short_oid(oid)) }
205 // Best-effort per entry: an unreadable
206 // commit in the chain (practically
207 // unreachable -- `toolchain::log` itself
208 // already errors on one) drops just its
209 // own authorship, not the whole log.
210 @if let Ok((author, seconds)) = super::commit_authorship(&*objects, *oid) {
211 span.muted { " · " (author) " · " (super::ago(seconds)) }
212 }
213 }
214 }
215 }
216 }
217 }
218 }
219 }
220 Err(ents_effect::Error::UnknownToolchain(_)) => {
221 return Err(Error::NotFound {
222 what: format!("toolchain {name}"),
223 });
224 }
225 Err(error) => crate::render::unreadable(&error.to_string()),
226 };
227 Ok(super::layout_meta(
228 &super::RepoHeader::from_state(&state),
229 &super::identity_label(&state),
230 "/toolchains",
231 &name,
232 html! {
233 (super::child_crumbs("toolchains", "/toolchains", &name))
234 (body)
235 },
236 ))
237}