//! Page: public profile at `/{username}`. Avatar, about (rendered markdown), local time and the //! repositories the viewer is allowed to read. Fixed single-segment routes are registered //! statically, so they win over this parameter route. use forge_core::authz::{Action, authorize}; use forge_core::tz::local_time_now; use forge_core::{Repo, User}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::{page, path_param}; use topcoat::view::{Unescaped, View, component, view}; use crate::auth::current_user; use crate::i18n::orgs::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::dashboard::badge_class; use crate::render::markdown::render_markdown; use crate::state::AppState; use crate::views::{avatar_url, now_unix, repo_href, shell}; path_param!(username); #[page("/{username}")] async fn profile_page(cx: &Cx) -> Result { let name = path_param::(cx); let state = app_context::(cx); let viewer = current_user(cx).await?; let Some(user) = state.store.user_by_username(name).await? else { return Err(not_found().into()); }; let avatar = state.store.avatar_hash(&user.id).await?; let about = user.about.trim(); let about_html = if about.is_empty() { String::new() } else { render_markdown(about, None) }; let local_time = local_time_now(&user.timezone, now_unix()); let actor = viewer.as_ref().map(|(u, _)| u); let mut repos = Vec::new(); for repo in state.store.list_repos_by_owner(&user.id).await? { let (role, org_role) = match actor { Some(u) => ( state.store.collaborator_role(&repo.id, &u.id).await?, state .store .org_repo_role(&repo.owner_id, &u.id, &repo.id) .await?, ), None => (None, None), }; if authorize(actor, &repo, role, org_role, Action::Read).is_ok() { repos.push(repo); } } // Starred repos are the user's bookmarks; only the ones the viewer may read are shown. let mut starred = Vec::new(); for repo in state.store.starred_repos(&user.id).await? { let (role, org_role) = match actor { Some(u) => ( state.store.collaborator_role(&repo.id, &u.id).await?, state .store .org_repo_role(&repo.owner_id, &u.id, &repo.id) .await?, ), None => (None, None), }; if authorize(actor, &repo, role, org_role, Action::Read).is_ok() { starred.push(repo); } } // An organization shows its members and how many teams it has. Its `about` is its description. let org = if user.is_org() { let members = state.store.org_members(&user.id).await?; let teams = state.store.teams_for_org(&user.id).await?.len(); Some(OrgSummary { members: members .into_iter() .map(|(member, _)| member.username) .collect(), teams, }) } else { None }; let title = user.username.clone(); let lang = i18n::lang(cx, viewer.as_ref().map(|(u, _)| u)); Ok(view! { cx => shell(title: title.as_str(), viewer: viewer.as_ref(), profile_card(user: user, avatar: avatar, about_html: about_html, local_time: local_time, repos: repos, starred: starred, org: org, lang: lang)) }) } /// What an organization's profile adds: its member names and its team count. struct OrgSummary { members: Vec, teams: usize, } #[component] async fn profile_card( user: User, avatar: Option, about_html: String, local_time: String, repos: Vec, starred: Vec, org: Option, lang: Lang, ) -> Result { let src = avatar_url(&user.username, avatar.as_deref()); let has_about = !about_html.is_empty(); let no_repos = repos.is_empty(); let no_starred = starred.is_empty(); let is_org = user.is_org(); let members_href = format!("/org/{}/members", user.username); let teams_href = format!("/org/{}/teams", user.username); Ok(view! {

(user.username.as_str())

if is_org {

(g::t(lang, K::Organization))

}

(g::t(lang, K::LocalTime)) (local_time.as_str())

if has_about {
(Unescaped::new_unchecked(about_html))
} if let Some(summary) = org {

(g::t(lang, K::Members))

(g::members_and_teams(lang, summary.members.len(), summary.teams)) " \u{b7} " (g::t(lang, K::AllMembers)) " \u{b7} " (g::t(lang, K::Teams))

if summary.members.is_empty() {

(g::t(lang, K::NoMembers))

} else { }
}

(g::t(lang, K::Repositories))

if no_repos {

(g::t(lang, K::NoVisibleRepos))

} else { }

(g::t(lang, K::Starred))

if no_starred {

(g::t(lang, K::NoVisibleStarred))

} else { }
}) } #[cfg(test)] mod tests { use forge_core::{Role, Store, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; const CSRF: &str = "profile-test-csrf"; struct Site { router: Router, store: Store, _dir: tempfile::TempDir, } async fn site() -> Site { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().to_path_buf(); let state = crate::test_support::test_state(&path).await; let store = state.store.clone(); let router = crate::test_support::app_router(state); Site { router, store, _dir: dir, } } /// Creates a session for `user_id` and returns the cookie header value. async fn sign_in(store: &Store, user_id: &str, token: &str) -> String { crate::test_support::create_session(store, user_id, token, CSRF).await; format!("__Host-{SESSION_COOKIE}={token}") } async fn get(router: &Router, uri: &str, cookie: Option<&str>) -> (u16, String) { let mut builder = Request::builder().method("GET").uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let res = router.handle(builder.body(Body::empty()).unwrap()).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) } /// Alice has a public repo, a private repo and a private repo that carol collaborates on. async fn seeded() -> Site { let site = site().await; let alice = site .store .create_user("alice", None, "not-a-real-hash", false) .await .unwrap(); site.store .set_profile( &alice.id, "# Hello heading\n\n\n\nSome **bold** text", "UTC", ) .await .unwrap(); site.store .create_repo(&alice.id, "open-repo", "", Visibility::Public, "main") .await .unwrap(); site.store .create_repo(&alice.id, "secret-repo", "", Visibility::Private, "main") .await .unwrap(); let shared = site .store .create_repo(&alice.id, "shared-repo", "", Visibility::Private, "main") .await .unwrap(); let carol = site .store .create_user("carol", None, "not-a-real-hash", false) .await .unwrap(); site.store .set_collaborator(&shared.id, &carol.id, Role::Read) .await .unwrap(); site } #[tokio::test] async fn about_is_rendered_and_sanitized() { let site = seeded().await; let (status, body) = get(&site.router, "/alice", None).await; assert_eq!(status, 200); assert!(body.contains("bold")); assert!(!body.contains("