Skip to main content

max / makenotwork

14.5 KB · 400 lines History Blame Raw
1 //! One person's repository listing at `/git/{owner}`, described.
2 //!
3 //! The seventh public document, and the first behind a rate limiter. It
4 //! replaces `templates/pages/git/repos.html`, `GitUserReposTemplate` and
5 //! `browsing::user_repos`.
6 //!
7 //! # The limiter goes on the mount, at the call site
8 //!
9 //! The git browse tree carries `route_layer(GovernorLayer::new(browse_rate_limit))`
10 //! over every read, and a described document that took the address without it
11 //! would quietly remove a per-IP cap from a route that walks bare repositories
12 //! on disk. [`super::public_document_mount`] returns an `axum::Router`, so the
13 //! layer is one call at the registration site and needs no new parameter -- see
14 //! [`super::public_document_mounts`], which rebuilds the same limiter from the
15 //! same constants.
16 //!
17 //! That was the whole of the supposed blocker. Recorded because it reads as an
18 //! architectural limit and is a line of wiring.
19 //!
20 //! # What the owner sees that a visitor does not
21 //!
22 //! Two things, and both come off the same `is_owner` comparison the shipped
23 //! handler made: private repositories are listed at all, and each one carries
24 //! its visibility. A visitor gets the public set with no badges, because a badge
25 //! saying `public` on every row of a list that contains nothing else is noise.
26 //!
27 //! The empty state differs too. An owner with no repositories is shown how to
28 //! push one; a visitor looking at an empty account is told there is nothing
29 //! here, because the push instructions are not theirs to act on.
30
31 use makeover_layout as layout;
32 use quasi_declare::declare;
33 use quasi_router::screen::Tag;
34 use quasi_router::{Document, Request, Response, RouteError};
35 use quasi_webview::Webview;
36
37 use crate::db;
38
39 /// The address, registered whole. See [`super::public_document_mount`].
40 pub const PATH: &str = "/git/{owner}";
41
42 /// The page's own region, and what the skip link points at.
43 pub const PAGE_REGION: &str = "git-repos";
44
45 const MEASURE: layout::Measure = layout::Measure::Wide;
46
47 /// The region an account with nothing published is drawn in.
48 const EMPTY: &str = "git-repos-empty";
49
50 /// Everything the screen draws, resolved before it is drawn.
51 pub(crate) struct Loaded {
52 owner: String,
53 is_owner: bool,
54 repos: Vec<Repo>,
55 }
56
57 /// One repository, as the listing shows it.
58 pub(crate) struct Repo {
59 name: String,
60 description: String,
61 /// `None` unless the reader owns the account and the repository is not
62 /// public. See the module header.
63 visibility: Option<String>,
64 }
65
66 impl Repo {
67 /// What the visibility cell says, or the empty string.
68 ///
69 /// The cell is guarded on the `Option` and this reads it, because a
70 /// description reads a value and does not bind one out of a pattern.
71 fn visibility_label(&self) -> &str {
72 self.visibility.as_deref().unwrap_or_default()
73 }
74 }
75
76 /// The page.
77 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
78 // Moved out because the handler signature is quasi's: the request is
79 // consumed here rather than borrowed from.
80 let captures = request.captures;
81 let owner = captures
82 .get("owner")
83 .ok_or_else(|| RouteError::not_found("no such account"))?;
84
85 let loaded = load(viewer, owner)?;
86
87 Ok(page_screen(&loaded).into())
88 }
89
90 /// The one read this page makes, for the mount that serves it from a residual.
91 pub(crate) fn reading(
92 viewer: &super::Viewer,
93 carried: &super::Carried,
94 ) -> Result<Loaded, RouteError> {
95 load(viewer, carried.capture("owner")?)
96 }
97
98 /// Resolve the account and the repositories this reader may see.
99 fn load(viewer: &super::Viewer, owner: &str) -> Result<Loaded, RouteError> {
100 let missing = || RouteError::not_found("no such account");
101
102 let username = db::Username::new(owner).map_err(|_| missing())?;
103 let db_user = viewer
104 .block_on(db::users::get_user_by_username(&viewer.app.db, &username))
105 .map_err(|_| RouteError::internal("that account could not be read"))?
106 .ok_or_else(missing)?;
107
108 let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == db_user.id);
109
110 // The owner sees everything; everybody else sees what has been published.
111 // Two queries rather than one filtered in Rust, which is what shipped: the
112 // visibility rule belongs in the statement, where it cannot be forgotten.
113 let repos = viewer
114 .block_on(async {
115 if is_owner {
116 db::git_repos::get_repos_by_user(&viewer.app.db, db_user.id).await
117 } else {
118 db::git_repos::get_public_repos_by_user(&viewer.app.db, db_user.id).await
119 }
120 })
121 .map_err(|_| RouteError::internal("those repositories could not be read"))?;
122
123 Ok(Loaded {
124 owner: db_user.username.to_string(),
125 is_owner,
126 repos: repos
127 .iter()
128 .map(|repo| Repo {
129 name: repo.name.clone(),
130 description: repo.description.clone(),
131 visibility: (is_owner && repo.visibility != db::Visibility::Public)
132 .then(|| repo.visibility.to_string()),
133 })
134 .collect(),
135 })
136 }
137
138 declare! {
139 /// The whole document: the title, the measure, the body.
140 pub(crate) shape page_screen(loaded: &Loaded) -> Screen;
141
142 let heading = "{loaded.owner}'s Repositories";
143
144 screen single "{heading} - Git - Makenotwork" {
145 measured MEASURE;
146 documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
147
148 include page_region(loaded);
149 }
150 }
151
152 declare! {
153 /// The page's one region, split out so it can be staged.
154 #[staged]
155 pub(crate) shape page_region(loaded: &Loaded) -> Slot;
156
157 region PAGE_REGION as Pane {
158 page "{loaded.owner}'s Repositories";
159 include empty(loaded.is_owner, &loaded.owner) when loaded.repos.is_empty();
160 include owner_listing(loaded) when loaded.is_owner and not loaded.repos.is_empty();
161 include listing(loaded) unless loaded.is_owner or loaded.repos.is_empty();
162 }
163 }
164
165 declare! {
166 /// An account with nothing published.
167 ///
168 /// The owner gets the two commands that fix it; a visitor gets the fact.
169 /// The shipped template made the same split and it is worth keeping: `git
170 /// remote add` is not advice a stranger can take.
171 ///
172 /// # The prose is written here rather than passed to `own_prose`
173 ///
174 /// `own_prose` takes the markdown **source** as one parameter, so a caller
175 /// that formats the string first hands a staged shape one sentinel where
176 /// the whole document should be: the derivation renders `<p>ZQH...HQZ</p>`
177 /// and the filler writes the request's markdown into that paragraph
178 /// unparsed. See [`super::own_prose`], where the rule is.
179 ///
180 /// Said here, the account's name is a hole **inside** the source, so the
181 /// derivation parses the fence with a sentinel in it and the residual holds
182 /// docengine's `<pre><code>` around a gap. Same markup as before, and the
183 /// two lines that are `own_prose`'s body are inlined with it.
184 #[staged]
185 shape empty(is_owner: bool, owner: &str) -> Slot;
186
187 region EMPTY as Group {
188 empty "No repositories yet.";
189 rich "Push a new repository:\n\n```\ngit remote add origin \
190 https://makenot.work/git/{owner}/my-repo.git\ngit push -u origin main\n```"
191 when is_owner
192 {
193 trust quasi_router::Trust::Trusted;
194 }
195 }
196 }
197
198 declare! {
199 /// The repositories, as a table, in the shape this reader is owed.
200 ///
201 /// The template drew a `<ul>` of two-line entries. As a table the
202 /// description says which column carries the identity and which can be
203 /// dropped on a narrow viewport, rather than leaving a stack of divs to wrap
204 /// however it wraps.
205 ///
206 /// Nobody but the owner is shown a visibility column, because for everybody
207 /// else every row in it would say the same word.
208 ///
209 /// # One question, asked once, and the seam is what insisted
210 ///
211 /// This was one table with a guarded `Visibility` column and a guarded
212 /// visibility cell, both reading ownership. That is two conditionals that
213 /// have to agree, and the module used to note that nothing checked they
214 /// did. Something does now: **a derivation renders combinations the screen
215 /// cannot produce.** It varies one guard at a time to read each span, so it
216 /// renders the column absent with the cell present, and a cell naming a
217 /// column that is not there is a cell silently lost -- which the renderer
218 /// panics on rather than dropping.
219 ///
220 /// So the rule the seam imposes is that a screen's guards are independent,
221 /// and two that must agree are one question written twice. Here the
222 /// question is ownership and it is asked in [`page_region`], which picks
223 /// the table rather than patching one. The two column lists repeat two
224 /// lines and buy back a coupling nothing was holding.
225 #[staged]
226 shape owner_listing(loaded: &Loaded) -> Node;
227
228 table {
229 column "Repository" {
230 width Content;
231 priority Essential;
232 }
233 column "Description" {
234 width Fill;
235 }
236 column "Visibility" {
237 width Content;
238 }
239
240 for repo in loaded.repos.iter() {
241 cells {
242 cell at "Repository" repo.name.clone();
243 cell at "Description" repo.description.clone();
244 // Absent for a public repository, which is the owner's common
245 // case and the reason the column is theirs alone. The guard is
246 // on the cell rather than on the token because a token is a
247 // setting on the cell, and a staged shape cannot guard one: a
248 // setting renders in its container's opening tag, so the branch
249 // it makes is not where the declaration wrote it.
250 cell at "Visibility" "" when repo.visibility.is_some() {
251 token Tag::badge(repo.visibility_label());
252 }
253
254 activate to get "/git/{loaded.owner}/{repo.name}" navigating;
255 }
256 }
257 }
258 }
259
260 declare! {
261 /// The same listing for everybody else, which is the public set with no
262 /// visibility to report. See [`owner_listing`] for why this is a second
263 /// shape rather than a guard.
264 #[staged]
265 shape listing(loaded: &Loaded) -> Node;
266
267 table {
268 column "Repository" {
269 width Content;
270 priority Essential;
271 }
272 column "Description" {
273 width Fill;
274 }
275
276 for repo in loaded.repos.iter() {
277 cells {
278 cell at "Repository" repo.name.clone();
279 cell at "Description" repo.description.clone();
280
281 activate to get "/git/{loaded.owner}/{repo.name}" navigating;
282 }
283 }
284 }
285 }
286
287 /// The document this screen is drawn in.
288 #[must_use]
289 pub fn renderer(viewer: &super::Viewer) -> Webview {
290 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
291 "{}{}",
292 crate::shell::skip_link(PAGE_REGION),
293 crate::shell::site_header(viewer.user.as_ref()),
294 )))
295 }
296
297 /// An account as the tests draw it, with `count` repositories.
298 ///
299 /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
300 /// one too, and `Loaded` is this module's own type. Test-only.
301 #[cfg(test)]
302 pub(crate) fn sample(count: usize, is_owner: bool) -> Loaded {
303 Loaded {
304 owner: "ada".into(),
305 is_owner,
306 repos: (0..count)
307 .map(|n| Repo {
308 name: format!("repo{n}"),
309 description: format!("Number {n}"),
310 visibility: (is_owner && n == 0).then(|| "private".to_string()),
311 })
312 .collect(),
313 }
314 }
315
316 #[cfg(test)]
317 mod tests {
318 use super::*;
319
320 use super::sample as loaded;
321
322 fn html(loaded: &Loaded) -> String {
323 use quasi_axum::Serves as _;
324
325 Webview::new().screen(&page_screen(loaded))
326 }
327
328 /// `2790e5c4`. This template carried the measure alone, so the slice is
329 /// empty and the class is just the measure.
330 #[test]
331 fn the_document_carries_the_class_the_template_carried() {
332 let screen = page_screen(&loaded(1, false));
333
334 assert_eq!(screen.document.body_class.as_deref(), Some("padded-page"));
335 let rendered = html(&loaded(1, false));
336 assert!(rendered.contains("class=\"padded-page\""), "{rendered}");
337 }
338
339 /// The title names whose repositories these are, as the template's did.
340 #[test]
341 fn the_document_is_titled_for_the_account() {
342 let screen = page_screen(&loaded(1, false));
343
344 assert_eq!(screen.title, "ada's Repositories - Git - Makenotwork");
345 }
346
347 /// Every repository is a row that opens it.
348 #[test]
349 fn every_repository_is_a_row_that_opens_it() {
350 let html = html(&loaded(3, false));
351
352 for n in 0..3 {
353 assert!(html.contains(&format!("repo{n}")), "{html}");
354 assert!(html.contains(&format!("/git/ada/repo{n}")), "{html}");
355 }
356 }
357
358 /// A visitor gets no visibility column, because every row of it would say
359 /// the same word.
360 #[test]
361 fn a_visitor_is_shown_no_visibility_column() {
362 assert!(!html(&loaded(2, false)).contains("Visibility"));
363 assert!(html(&loaded(2, true)).contains("Visibility"));
364 }
365
366 /// The owner's private repository is marked; their public one is not.
367 #[test]
368 fn the_owner_sees_which_of_their_repositories_are_not_public() {
369 let html = html(&loaded(2, true));
370
371 assert!(html.contains("private"), "{html}");
372 }
373
374 /// The push instructions are the owner's. A stranger looking at an empty
375 /// account is told the fact and not given a command they cannot run.
376 #[test]
377 fn only_the_owner_is_told_how_to_push() {
378 let owner = html(&loaded(0, true));
379 let visitor = html(&loaded(0, false));
380
381 assert!(owner.contains("git remote add origin"), "{owner}");
382 assert!(
383 owner.contains("makenot.work/git/ada/my-repo.git"),
384 "{owner}"
385 );
386 assert!(!visitor.contains("git remote add"), "{visitor}");
387 assert!(visitor.contains("No repositories yet"), "{visitor}");
388 }
389
390 /// `736f45a5`: this screen's markup carries none of the four spellings.
391 #[test]
392 fn the_page_spells_no_spinner() {
393 let html = html(&loaded(2, true));
394
395 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
396 assert!(!html.contains(spelling), "{spelling} survives in {html}");
397 }
398 }
399 }
400