Skip to main content

max / makenotwork

11.1 KB · 312 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_router::screen::{Cell, Cells, Column, Tag};
33 use quasi_router::{
34 Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
35 };
36 use quasi_webview::Webview;
37
38 use crate::db;
39
40 /// The address, registered whole. See [`super::public_document_mount`].
41 pub const PATH: &str = "/git/{owner}";
42
43 /// The page's own region, and what the skip link points at.
44 pub const PAGE_REGION: &str = "git-repos";
45
46 const MEASURE: layout::Measure = layout::Measure::Wide;
47
48 /// Everything the screen draws, resolved before it is drawn.
49 struct Loaded {
50 owner: String,
51 is_owner: bool,
52 repos: Vec<Repo>,
53 }
54
55 /// One repository, as the listing shows it.
56 struct Repo {
57 name: String,
58 description: String,
59 /// `None` unless the reader owns the account and the repository is not
60 /// public. See the module header.
61 visibility: Option<String>,
62 }
63
64 /// The page.
65 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
66 // Moved out because the handler signature is quasi's: the request is
67 // consumed here rather than borrowed from.
68 let captures = request.captures;
69 let owner = captures
70 .get("owner")
71 .ok_or_else(|| RouteError::not_found("no such account"))?;
72
73 let loaded = load(viewer, owner)?;
74
75 Ok(page_screen(&loaded).into())
76 }
77
78 /// Resolve the account and the repositories this reader may see.
79 fn load(viewer: &super::Viewer, owner: &str) -> Result<Loaded, RouteError> {
80 let missing = || RouteError::not_found("no such account");
81
82 let username = db::Username::new(owner).map_err(|_| missing())?;
83 let db_user = viewer
84 .block_on(db::users::get_user_by_username(&viewer.app.db, &username))
85 .map_err(|_| RouteError::internal("that account could not be read"))?
86 .ok_or_else(missing)?;
87
88 let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == db_user.id);
89
90 // The owner sees everything; everybody else sees what has been published.
91 // Two queries rather than one filtered in Rust, which is what shipped: the
92 // visibility rule belongs in the statement, where it cannot be forgotten.
93 let repos = viewer
94 .block_on(async {
95 if is_owner {
96 db::git_repos::get_repos_by_user(&viewer.app.db, db_user.id).await
97 } else {
98 db::git_repos::get_public_repos_by_user(&viewer.app.db, db_user.id).await
99 }
100 })
101 .map_err(|_| RouteError::internal("those repositories could not be read"))?;
102
103 Ok(Loaded {
104 owner: db_user.username.to_string(),
105 is_owner,
106 repos: repos
107 .iter()
108 .map(|repo| Repo {
109 name: repo.name.clone(),
110 description: repo.description.clone(),
111 visibility: (is_owner && repo.visibility != db::Visibility::Public)
112 .then(|| repo.visibility.to_string()),
113 })
114 .collect(),
115 })
116 }
117
118 /// The whole document: the title, the measure, the body.
119 fn page_screen(loaded: &Loaded) -> Described {
120 let heading = format!("{}'s Repositories", loaded.owner);
121
122 let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(heading.clone()));
123
124 page = if loaded.repos.is_empty() {
125 empty(page, loaded.is_owner, &loaded.owner)
126 } else {
127 page.with(listing(loaded))
128 };
129
130 Described::single(format!("{heading} - Git - Makenotwork"))
131 .measured(MEASURE)
132 .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
133 .with(page)
134 }
135
136 /// An account with nothing published.
137 ///
138 /// The owner gets the two commands that fix it; a visitor gets the fact. The
139 /// shipped template made the same split and it is worth keeping: `git remote
140 /// add` is not advice a stranger can take.
141 fn empty(page: Slot, is_owner: bool, owner: &str) -> Slot {
142 let page = page.with(Node::empty("No repositories yet."));
143
144 if !is_owner {
145 return page;
146 }
147
148 // `own_prose` rather than `Node::rich`, and the interpolation is the reason
149 // to say why: `owner` is a `Username`, which `validate_username` restricts
150 // to letters, digits and underscores, so it cannot carry markup into a
151 // source the renderer no longer hardens. A value that could would want
152 // `Node::rich` instead, whatever else is in the string.
153 page.with(super::own_prose(format!(
154 "Push a new repository:\n\
155 \n\
156 ```\n\
157 git remote add origin https://makenot.work/git/{owner}/my-repo.git\n\
158 git push -u origin main\n\
159 ```"
160 )))
161 }
162
163 /// The repositories, as a table.
164 ///
165 /// The template drew a `<ul>` of two-line entries. As a table the description
166 /// says which column carries the identity and which can be dropped on a narrow
167 /// viewport, rather than leaving a stack of divs to wrap however it wraps.
168 fn listing(loaded: &Loaded) -> Node {
169 let mut columns = vec![
170 Column::new("Repository")
171 .width(layout::Width::Content)
172 .priority(layout::Priority::Essential),
173 Column::new("Description").width(layout::Width::Fill),
174 ];
175 // Nobody but the owner is shown a visibility column, because for everybody
176 // else every row in it would say the same word.
177 if loaded.is_owner {
178 columns.push(Column::new("Visibility").width(layout::Width::Content));
179 }
180
181 Node::Table {
182 columns,
183 rows: loaded
184 .repos
185 .iter()
186 .map(|repo| {
187 let mut cells = vec![
188 Cell::new(repo.name.clone()),
189 Cell::new(repo.description.clone()),
190 ];
191 if loaded.is_owner {
192 cells.push(match repo.visibility.as_deref() {
193 Some(visibility) => Cell::new(String::new()).token(Tag::badge(visibility)),
194 None => Cell::new(String::new()),
195 });
196 }
197 Cells::new(cells).activate(
198 Action::get(format!("/git/{}/{}", loaded.owner, repo.name)).navigating(),
199 )
200 })
201 .collect(),
202 more: None,
203 }
204 }
205
206 /// The document this screen is drawn in.
207 #[must_use]
208 pub fn renderer(viewer: &super::Viewer) -> Webview {
209 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
210 "{}{}",
211 crate::shell::skip_link(PAGE_REGION),
212 crate::shell::site_header(viewer.user.as_ref()),
213 )))
214 }
215
216 #[cfg(test)]
217 mod tests {
218 use super::*;
219
220 fn loaded(count: usize, is_owner: bool) -> Loaded {
221 Loaded {
222 owner: "ada".into(),
223 is_owner,
224 repos: (0..count)
225 .map(|n| Repo {
226 name: format!("repo{n}"),
227 description: format!("Number {n}"),
228 visibility: (is_owner && n == 0).then(|| "private".to_string()),
229 })
230 .collect(),
231 }
232 }
233
234 fn html(loaded: &Loaded) -> String {
235 use quasi_axum::Serves as _;
236
237 Webview::new().screen(&page_screen(loaded))
238 }
239
240 /// `2790e5c4`. This template carried the measure alone, so the slice is
241 /// empty and the class is just the measure.
242 #[test]
243 fn the_document_carries_the_class_the_template_carried() {
244 let screen = page_screen(&loaded(1, false));
245
246 assert_eq!(screen.document.body_class.as_deref(), Some("padded-page"));
247 let rendered = html(&loaded(1, false));
248 assert!(rendered.contains("class=\"padded-page\""), "{rendered}");
249 }
250
251 /// The title names whose repositories these are, as the template's did.
252 #[test]
253 fn the_document_is_titled_for_the_account() {
254 let screen = page_screen(&loaded(1, false));
255
256 assert_eq!(screen.title, "ada's Repositories - Git - Makenotwork");
257 }
258
259 /// Every repository is a row that opens it.
260 #[test]
261 fn every_repository_is_a_row_that_opens_it() {
262 let html = html(&loaded(3, false));
263
264 for n in 0..3 {
265 assert!(html.contains(&format!("repo{n}")), "{html}");
266 assert!(html.contains(&format!("/git/ada/repo{n}")), "{html}");
267 }
268 }
269
270 /// A visitor gets no visibility column, because every row of it would say
271 /// the same word.
272 #[test]
273 fn a_visitor_is_shown_no_visibility_column() {
274 assert!(!html(&loaded(2, false)).contains("Visibility"));
275 assert!(html(&loaded(2, true)).contains("Visibility"));
276 }
277
278 /// The owner's private repository is marked; their public one is not.
279 #[test]
280 fn the_owner_sees_which_of_their_repositories_are_not_public() {
281 let html = html(&loaded(2, true));
282
283 assert!(html.contains("private"), "{html}");
284 }
285
286 /// The push instructions are the owner's. A stranger looking at an empty
287 /// account is told the fact and not given a command they cannot run.
288 #[test]
289 fn only_the_owner_is_told_how_to_push() {
290 let owner = html(&loaded(0, true));
291 let visitor = html(&loaded(0, false));
292
293 assert!(owner.contains("git remote add origin"), "{owner}");
294 assert!(
295 owner.contains("makenot.work/git/ada/my-repo.git"),
296 "{owner}"
297 );
298 assert!(!visitor.contains("git remote add"), "{visitor}");
299 assert!(visitor.contains("No repositories yet"), "{visitor}");
300 }
301
302 /// `736f45a5`: this screen's markup carries none of the four spellings.
303 #[test]
304 fn the_page_spells_no_spinner() {
305 let html = html(&loaded(2, true));
306
307 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
308 assert!(!html.contains(spelling), "{spelling} survives in {html}");
309 }
310 }
311 }
312