//! One person's repository listing at `/git/{owner}`, described. //! //! The seventh public document, and the first behind a rate limiter. It //! replaces `templates/pages/git/repos.html`, `GitUserReposTemplate` and //! `browsing::user_repos`. //! //! # The limiter goes on the mount, at the call site //! //! The git browse tree carries `route_layer(GovernorLayer::new(browse_rate_limit))` //! over every read, and a described document that took the address without it //! would quietly remove a per-IP cap from a route that walks bare repositories //! on disk. [`super::public_document_mount`] returns an `axum::Router`, so the //! layer is one call at the registration site and needs no new parameter -- see //! [`super::public_document_mounts`], which rebuilds the same limiter from the //! same constants. //! //! That was the whole of the supposed blocker. Recorded because it reads as an //! architectural limit and is a line of wiring. //! //! # What the owner sees that a visitor does not //! //! Two things, and both come off the same `is_owner` comparison the shipped //! handler made: private repositories are listed at all, and each one carries //! its visibility. A visitor gets the public set with no badges, because a badge //! saying `public` on every row of a list that contains nothing else is noise. //! //! The empty state differs too. An owner with no repositories is shown how to //! push one; a visitor looking at an empty account is told there is nothing //! here, because the push instructions are not theirs to act on. use makeover_layout as layout; use quasi_router::screen::{Cell, Cells, Column, Tag}; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, }; use quasi_webview::Webview; use crate::db; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/git/{owner}"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "git-repos"; const MEASURE: layout::Measure = layout::Measure::Wide; /// Everything the screen draws, resolved before it is drawn. struct Loaded { owner: String, is_owner: bool, repos: Vec, } /// One repository, as the listing shows it. struct Repo { name: String, description: String, /// `None` unless the reader owns the account and the repository is not /// public. See the module header. visibility: Option, } /// The page. pub fn screen(viewer: &super::Viewer, request: Request) -> Result { // Moved out because the handler signature is quasi's: the request is // consumed here rather than borrowed from. let captures = request.captures; let owner = captures .get("owner") .ok_or_else(|| RouteError::not_found("no such account"))?; let loaded = load(viewer, owner)?; Ok(page_screen(&loaded).into()) } /// Resolve the account and the repositories this reader may see. fn load(viewer: &super::Viewer, owner: &str) -> Result { let missing = || RouteError::not_found("no such account"); let username = db::Username::new(owner).map_err(|_| missing())?; let db_user = viewer .block_on(db::users::get_user_by_username(&viewer.app.db, &username)) .map_err(|_| RouteError::internal("that account could not be read"))? .ok_or_else(missing)?; let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == db_user.id); // The owner sees everything; everybody else sees what has been published. // Two queries rather than one filtered in Rust, which is what shipped: the // visibility rule belongs in the statement, where it cannot be forgotten. let repos = viewer .block_on(async { if is_owner { db::git_repos::get_repos_by_user(&viewer.app.db, db_user.id).await } else { db::git_repos::get_public_repos_by_user(&viewer.app.db, db_user.id).await } }) .map_err(|_| RouteError::internal("those repositories could not be read"))?; Ok(Loaded { owner: db_user.username.to_string(), is_owner, repos: repos .iter() .map(|repo| Repo { name: repo.name.clone(), description: repo.description.clone(), visibility: (is_owner && repo.visibility != db::Visibility::Public) .then(|| repo.visibility.to_string()), }) .collect(), }) } /// The whole document: the title, the measure, the body. fn page_screen(loaded: &Loaded) -> Described { let heading = format!("{}'s Repositories", loaded.owner); let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(heading.clone())); page = if loaded.repos.is_empty() { empty(page, loaded.is_owner, &loaded.owner) } else { page.with(listing(loaded)) }; Described::single(format!("{heading} - Git - Makenotwork")) .measured(MEASURE) .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) .with(page) } /// An account with nothing published. /// /// The owner gets the two commands that fix it; a visitor gets the fact. The /// shipped template made the same split and it is worth keeping: `git remote /// add` is not advice a stranger can take. fn empty(page: Slot, is_owner: bool, owner: &str) -> Slot { let page = page.with(Node::empty("No repositories yet.")); if !is_owner { return page; } // `own_prose` rather than `Node::rich`, and the interpolation is the reason // to say why: `owner` is a `Username`, which `validate_username` restricts // to letters, digits and underscores, so it cannot carry markup into a // source the renderer no longer hardens. A value that could would want // `Node::rich` instead, whatever else is in the string. page.with(super::own_prose(format!( "Push a new repository:\n\ \n\ ```\n\ git remote add origin https://makenot.work/git/{owner}/my-repo.git\n\ git push -u origin main\n\ ```" ))) } /// The repositories, as a table. /// /// The template drew a `
    ` of two-line entries. As a table the description /// says which column carries the identity and which can be dropped on a narrow /// viewport, rather than leaving a stack of divs to wrap however it wraps. fn listing(loaded: &Loaded) -> Node { let mut columns = vec![ Column::new("Repository") .width(layout::Width::Content) .priority(layout::Priority::Essential), Column::new("Description").width(layout::Width::Fill), ]; // Nobody but the owner is shown a visibility column, because for everybody // else every row in it would say the same word. if loaded.is_owner { columns.push(Column::new("Visibility").width(layout::Width::Content)); } Node::Table { columns, rows: loaded .repos .iter() .map(|repo| { let mut cells = vec![ Cell::new(repo.name.clone()), Cell::new(repo.description.clone()), ]; if loaded.is_owner { cells.push(match repo.visibility.as_deref() { Some(visibility) => Cell::new(String::new()).token(Tag::badge(visibility)), None => Cell::new(String::new()), }); } Cells::new(cells).activate( Action::get(format!("/git/{}/{}", loaded.owner, repo.name)).navigating(), ) }) .collect(), more: None, } } /// The document this screen is drawn in. #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer.user.as_ref()), ))) } #[cfg(test)] mod tests { use super::*; fn loaded(count: usize, is_owner: bool) -> Loaded { Loaded { owner: "ada".into(), is_owner, repos: (0..count) .map(|n| Repo { name: format!("repo{n}"), description: format!("Number {n}"), visibility: (is_owner && n == 0).then(|| "private".to_string()), }) .collect(), } } fn html(loaded: &Loaded) -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(loaded)) } /// `2790e5c4`. This template carried the measure alone, so the slice is /// empty and the class is just the measure. #[test] fn the_document_carries_the_class_the_template_carried() { let screen = page_screen(&loaded(1, false)); assert_eq!(screen.document.body_class.as_deref(), Some("padded-page")); let rendered = html(&loaded(1, false)); assert!(rendered.contains("class=\"padded-page\""), "{rendered}"); } /// The title names whose repositories these are, as the template's did. #[test] fn the_document_is_titled_for_the_account() { let screen = page_screen(&loaded(1, false)); assert_eq!(screen.title, "ada's Repositories - Git - Makenotwork"); } /// Every repository is a row that opens it. #[test] fn every_repository_is_a_row_that_opens_it() { let html = html(&loaded(3, false)); for n in 0..3 { assert!(html.contains(&format!("repo{n}")), "{html}"); assert!(html.contains(&format!("/git/ada/repo{n}")), "{html}"); } } /// A visitor gets no visibility column, because every row of it would say /// the same word. #[test] fn a_visitor_is_shown_no_visibility_column() { assert!(!html(&loaded(2, false)).contains("Visibility")); assert!(html(&loaded(2, true)).contains("Visibility")); } /// The owner's private repository is marked; their public one is not. #[test] fn the_owner_sees_which_of_their_repositories_are_not_public() { let html = html(&loaded(2, true)); assert!(html.contains("private"), "{html}"); } /// The push instructions are the owner's. A stranger looking at an empty /// account is told the fact and not given a command they cannot run. #[test] fn only_the_owner_is_told_how_to_push() { let owner = html(&loaded(0, true)); let visitor = html(&loaded(0, false)); assert!(owner.contains("git remote add origin"), "{owner}"); assert!( owner.contains("makenot.work/git/ada/my-repo.git"), "{owner}" ); assert!(!visitor.contains("git remote add"), "{visitor}"); assert!(visitor.contains("No repositories yet"), "{visitor}"); } /// `736f45a5`: this screen's markup carries none of the four spellings. #[test] fn the_page_spells_no_spinner() { let html = html(&loaded(2, true)); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }