Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: public profile at `/{username}`. Avatar, about (rendered markdown), local time and the |
| 2 | //! repositories the viewer is allowed to read. Fixed single-segment routes are registered |
| 3 | //! statically, so they win over this parameter route. |
| 4 | |
| 5 | use forge_core::authz::{Action, authorize}; |
| 6 | use forge_core::tz::local_time_now; |
| 7 | use forge_core::{Repo, User}; |
| 8 | use topcoat::Result; |
| 9 | use topcoat::context::{Cx, app_context}; |
| 10 | use topcoat::router::error::not_found; |
| 11 | use topcoat::router::{page, path_param}; |
| 12 | use topcoat::view::{Unescaped, View, component, view}; |
| 13 | |
| 14 | use crate::auth::current_user; |
| 15 | use crate::i18n::orgs::{self as g, K}; |
| 16 | use crate::i18n::{self, Lang}; |
| 17 | use crate::pages::dashboard::badge_class; |
| 18 | use crate::render::markdown::render_markdown; |
| 19 | use crate::state::AppState; |
| 20 | use crate::views::{avatar_url, now_unix, repo_href, shell}; |
| 21 | |
| 22 | path_param!(username); |
| 23 | |
| 24 | #[page("/{username}")] |
| 25 | async fn profile_page(cx: &Cx) -> Result<impl View> { |
| 26 | let name = path_param::<Username>(cx); |
| 27 | let state = app_context::<AppState>(cx); |
| 28 | let viewer = current_user(cx).await?; |
| 29 | |
| 30 | let Some(user) = state.store.user_by_username(name).await? else { |
| 31 | return Err(not_found().into()); |
| 32 | }; |
| 33 | let avatar = state.store.avatar_hash(&user.id).await?; |
| 34 | let about = user.about.trim(); |
| 35 | let about_html = if about.is_empty() { |
| 36 | String::new() |
| 37 | } else { |
| 38 | render_markdown(about, None) |
| 39 | }; |
| 40 | let local_time = local_time_now(&user.timezone, now_unix()); |
| 41 | |
| 42 | let actor = viewer.as_ref().map(|(u, _)| u); |
| 43 | let mut repos = Vec::new(); |
| 44 | for repo in state.store.list_repos_by_owner(&user.id).await? { |
| 45 | let (role, org_role) = match actor { |
| 46 | Some(u) => ( |
| 47 | state.store.collaborator_role(&repo.id, &u.id).await?, |
| 48 | state |
| 49 | .store |
| 50 | .org_repo_role(&repo.owner_id, &u.id, &repo.id) |
| 51 | .await?, |
| 52 | ), |
| 53 | None => (None, None), |
| 54 | }; |
| 55 | if authorize(actor, &repo, role, org_role, Action::Read).is_ok() { |
| 56 | repos.push(repo); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // Starred repos are the user's bookmarks; only the ones the viewer may read are shown. |
| 61 | let mut starred = Vec::new(); |
| 62 | for repo in state.store.starred_repos(&user.id).await? { |
| 63 | let (role, org_role) = match actor { |
| 64 | Some(u) => ( |
| 65 | state.store.collaborator_role(&repo.id, &u.id).await?, |
| 66 | state |
| 67 | .store |
| 68 | .org_repo_role(&repo.owner_id, &u.id, &repo.id) |
| 69 | .await?, |
| 70 | ), |
| 71 | None => (None, None), |
| 72 | }; |
| 73 | if authorize(actor, &repo, role, org_role, Action::Read).is_ok() { |
| 74 | starred.push(repo); |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // An organization shows its members and how many teams it has. Its `about` is its description. |
| 79 | let org = if user.is_org() { |
| 80 | let members = state.store.org_members(&user.id).await?; |
| 81 | let teams = state.store.teams_for_org(&user.id).await?.len(); |
| 82 | Some(OrgSummary { |
| 83 | members: members |
| 84 | .into_iter() |
| 85 | .map(|(member, _)| member.username) |
| 86 | .collect(), |
| 87 | teams, |
| 88 | }) |
| 89 | } else { |
| 90 | None |
| 91 | }; |
| 92 | |
| 93 | let title = user.username.clone(); |
| 94 | let lang = i18n::lang(cx, viewer.as_ref().map(|(u, _)| u)); |
| 95 | Ok(view! { cx => |
| 96 | shell(title: title.as_str(), viewer: viewer.as_ref(), |
| 97 | profile_card(user: user, avatar: avatar, about_html: about_html, local_time: local_time, repos: repos, starred: starred, org: org, lang: lang)) |
| 98 | }) |
| 99 | } |
| 100 | |
| 101 | /// What an organization's profile adds: its member names and its team count. |
| 102 | struct OrgSummary { |
| 103 | members: Vec<String>, |
| 104 | teams: usize, |
| 105 | } |
| 106 | |
| 107 | #[component] |
| 108 | async fn profile_card( |
| 109 | user: User, |
| 110 | avatar: Option<String>, |
| 111 | about_html: String, |
| 112 | local_time: String, |
| 113 | repos: Vec<Repo>, |
| 114 | starred: Vec<Repo>, |
| 115 | org: Option<OrgSummary>, |
| 116 | lang: Lang, |
| 117 | ) -> Result<impl View> { |
| 118 | let src = avatar_url(&user.username, avatar.as_deref()); |
| 119 | let has_about = !about_html.is_empty(); |
| 120 | let no_repos = repos.is_empty(); |
| 121 | let no_starred = starred.is_empty(); |
| 122 | let is_org = user.is_org(); |
| 123 | let members_href = format!("/org/{}/members", user.username); |
| 124 | let teams_href = format!("/org/{}/teams", user.username); |
| 125 | Ok(view! { |
| 126 | <article class="profile-page"> |
| 127 | <header class="profile-head"> |
| 128 | <img class="profile-avatar" src=(src) alt="" width="256" height="256"> |
| 129 | <div class="profile-id"> |
| 130 | <h1>(user.username.as_str())</h1> |
| 131 | if is_org { |
| 132 | <p class="muted small">(g::t(lang, K::Organization))</p> |
| 133 | } |
| 134 | <p class="muted">(g::t(lang, K::LocalTime)) (local_time.as_str())</p> |
| 135 | </div> |
| 136 | </header> |
| 137 | if has_about { |
| 138 | <section class="profile-about markdown-body">(Unescaped::new_unchecked(about_html))</section> |
| 139 | } |
| 140 | if let Some(summary) = org { |
| 141 | <section class="profile-org"> |
| 142 | <h2>(g::t(lang, K::Members))</h2> |
| 143 | <p class="muted small"> |
| 144 | (g::members_and_teams(lang, summary.members.len(), summary.teams)) |
| 145 | " \u{b7} " |
| 146 | <a href=(members_href.as_str())>(g::t(lang, K::AllMembers))</a> |
| 147 | " \u{b7} " |
| 148 | <a href=(teams_href.as_str())>(g::t(lang, K::Teams))</a> |
| 149 | </p> |
| 150 | if summary.members.is_empty() { |
| 151 | <p class="muted">(g::t(lang, K::NoMembers))</p> |
| 152 | } else { |
| 153 | <ul class="item-list"> |
| 154 | for name in summary.members.iter() { |
| 155 | <li><a href=(format!("/{}", name.as_str()))>(name.as_str())</a></li> |
| 156 | } |
| 157 | </ul> |
| 158 | } |
| 159 | </section> |
| 160 | } |
| 161 | <section class="profile-repos"> |
| 162 | <h2>(g::t(lang, K::Repositories))</h2> |
| 163 | if no_repos { |
| 164 | <p class="muted">(g::t(lang, K::NoVisibleRepos))</p> |
| 165 | } else { |
| 166 | <ul class="repo-list"> |
| 167 | for repo in repos.into_iter() { |
| 168 | <li class="repo-item"> |
| 169 | <a class="repo-name" href=(repo_href(&repo.owner_name, &repo.name, "", ""))> |
| 170 | (format!("{}/{}", repo.owner_name, repo.name)) |
| 171 | </a> |
| 172 | <span class=(badge_class(repo.visibility))>(i18n::visibility_label(lang, repo.visibility))</span> |
| 173 | if !repo.description.is_empty() { |
| 174 | <p class="muted">(repo.description.as_str())</p> |
| 175 | } |
| 176 | </li> |
| 177 | } |
| 178 | </ul> |
| 179 | } |
| 180 | </section> |
| 181 | <section class="profile-starred"> |
| 182 | <h2>(g::t(lang, K::Starred))</h2> |
| 183 | if no_starred { |
| 184 | <p class="muted">(g::t(lang, K::NoVisibleStarred))</p> |
| 185 | } else { |
| 186 | <ul class="repo-list"> |
| 187 | for repo in starred.into_iter() { |
| 188 | <li class="repo-item"> |
| 189 | <a class="repo-name" href=(repo_href(&repo.owner_name, &repo.name, "", ""))> |
| 190 | (format!("{}/{}", repo.owner_name, repo.name)) |
| 191 | </a> |
| 192 | <span class=(badge_class(repo.visibility))>(i18n::visibility_label(lang, repo.visibility))</span> |
| 193 | if !repo.description.is_empty() { |
| 194 | <p class="muted">(repo.description.as_str())</p> |
| 195 | } |
| 196 | </li> |
| 197 | } |
| 198 | </ul> |
| 199 | } |
| 200 | </section> |
| 201 | </article> |
| 202 | }) |
| 203 | } |
| 204 | |
| 205 | #[cfg(test)] |
| 206 | mod tests { |
| 207 | |
| 208 | use forge_core::{Role, Store, Visibility}; |
| 209 | |
| 210 | use topcoat::router::request::Request; |
| 211 | use topcoat::router::{Body, Router, to_bytes}; |
| 212 | |
| 213 | use crate::auth::SESSION_COOKIE; |
| 214 | |
| 215 | const CSRF: &str = "profile-test-csrf"; |
| 216 | |
| 217 | struct Site { |
| 218 | router: Router, |
| 219 | store: Store, |
| 220 | _dir: tempfile::TempDir, |
| 221 | } |
| 222 | |
| 223 | async fn site() -> Site { |
| 224 | let dir = tempfile::tempdir().expect("tempdir"); |
| 225 | let path = dir.path().to_path_buf(); |
| 226 | let state = crate::test_support::test_state(&path).await; |
| 227 | let store = state.store.clone(); |
| 228 | let router = crate::test_support::app_router(state); |
| 229 | Site { |
| 230 | router, |
| 231 | store, |
| 232 | _dir: dir, |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /// Creates a session for `user_id` and returns the cookie header value. |
| 237 | async fn sign_in(store: &Store, user_id: &str, token: &str) -> String { |
| 238 | crate::test_support::create_session(store, user_id, token, CSRF).await; |
| 239 | format!("__Host-{SESSION_COOKIE}={token}") |
| 240 | } |
| 241 | |
| 242 | async fn get(router: &Router, uri: &str, cookie: Option<&str>) -> (u16, String) { |
| 243 | let mut builder = Request::builder().method("GET").uri(uri); |
| 244 | if let Some(cookie) = cookie { |
| 245 | builder = builder.header("cookie", cookie); |
| 246 | } |
| 247 | let res = router.handle(builder.body(Body::empty()).unwrap()).await; |
| 248 | let status = res.status().as_u16(); |
| 249 | let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 250 | (status, String::from_utf8_lossy(&bytes).into_owned()) |
| 251 | } |
| 252 | |
| 253 | /// Alice has a public repo, a private repo and a private repo that carol collaborates on. |
| 254 | async fn seeded() -> Site { |
| 255 | let site = site().await; |
| 256 | let alice = site |
| 257 | .store |
| 258 | .create_user("alice", None, "not-a-real-hash", false) |
| 259 | .await |
| 260 | .unwrap(); |
| 261 | site.store |
| 262 | .set_profile( |
| 263 | &alice.id, |
| 264 | "# Hello heading\n\n<script>alert(1)</script>\n\nSome **bold** text", |
| 265 | "UTC", |
| 266 | ) |
| 267 | .await |
| 268 | .unwrap(); |
| 269 | site.store |
| 270 | .create_repo(&alice.id, "open-repo", "", Visibility::Public, "main") |
| 271 | .await |
| 272 | .unwrap(); |
| 273 | site.store |
| 274 | .create_repo(&alice.id, "secret-repo", "", Visibility::Private, "main") |
| 275 | .await |
| 276 | .unwrap(); |
| 277 | let shared = site |
| 278 | .store |
| 279 | .create_repo(&alice.id, "shared-repo", "", Visibility::Private, "main") |
| 280 | .await |
| 281 | .unwrap(); |
| 282 | let carol = site |
| 283 | .store |
| 284 | .create_user("carol", None, "not-a-real-hash", false) |
| 285 | .await |
| 286 | .unwrap(); |
| 287 | site.store |
| 288 | .set_collaborator(&shared.id, &carol.id, Role::Read) |
| 289 | .await |
| 290 | .unwrap(); |
| 291 | site |
| 292 | } |
| 293 | |
| 294 | #[tokio::test] |
| 295 | async fn about_is_rendered_and_sanitized() { |
| 296 | let site = seeded().await; |
| 297 | let (status, body) = get(&site.router, "/alice", None).await; |
| 298 | assert_eq!(status, 200); |
| 299 | assert!(body.contains("<h1"), "heading rendered: {body}"); |
| 300 | assert!(body.contains("Hello heading")); |
| 301 | assert!(body.contains("<strong>bold</strong>")); |
| 302 | assert!(!body.contains("<script>"), "script stripped: {body}"); |
| 303 | assert!(!body.contains("alert(1)")); |
| 304 | } |
| 305 | |
| 306 | #[tokio::test] |
| 307 | async fn anonymous_and_other_users_see_only_public_repos() { |
| 308 | let site = seeded().await; |
| 309 | let (_, anon) = get(&site.router, "/alice", None).await; |
| 310 | assert!(anon.contains("alice/open-repo")); |
| 311 | assert!(!anon.contains("alice/secret-repo")); |
| 312 | assert!(!anon.contains("alice/shared-repo")); |
| 313 | |
| 314 | let bob = site |
| 315 | .store |
| 316 | .create_user("bob", None, "not-a-real-hash", false) |
| 317 | .await |
| 318 | .unwrap(); |
| 319 | let cookie = sign_in(&site.store, &bob.id, "bob-token").await; |
| 320 | let (_, other) = get(&site.router, "/alice", Some(&cookie)).await; |
| 321 | assert!(other.contains("alice/open-repo")); |
| 322 | assert!(!other.contains("alice/secret-repo")); |
| 323 | assert!(!other.contains("alice/shared-repo")); |
| 324 | } |
| 325 | |
| 326 | #[tokio::test] |
| 327 | async fn owner_sees_private_and_collaborator_sees_their_repo() { |
| 328 | let site = seeded().await; |
| 329 | let alice = site.store.user_by_username("alice").await.unwrap().unwrap(); |
| 330 | let cookie = sign_in(&site.store, &alice.id, "alice-token").await; |
| 331 | let (_, body) = get(&site.router, "/alice", Some(&cookie)).await; |
| 332 | assert!(body.contains("alice/secret-repo")); |
| 333 | assert!(body.contains("alice/shared-repo")); |
| 334 | |
| 335 | let carol = site.store.user_by_username("carol").await.unwrap().unwrap(); |
| 336 | let cookie = sign_in(&site.store, &carol.id, "carol-token").await; |
| 337 | let (_, body) = get(&site.router, "/alice", Some(&cookie)).await; |
| 338 | assert!(body.contains("alice/shared-repo")); |
| 339 | assert!(!body.contains("alice/secret-repo")); |
| 340 | } |
| 341 | |
| 342 | #[tokio::test] |
| 343 | async fn unknown_user_is_404_and_lookup_is_case_insensitive() { |
| 344 | let site = seeded().await; |
| 345 | let (status, _) = get(&site.router, "/nobody-here", None).await; |
| 346 | assert_eq!(status, 404); |
| 347 | let (status, body) = get(&site.router, "/ALICE", None).await; |
| 348 | assert_eq!(status, 200); |
| 349 | assert!(body.contains("alice")); |
| 350 | } |
| 351 | |
| 352 | #[tokio::test] |
| 353 | async fn fixed_routes_are_not_shadowed_by_the_profile() { |
| 354 | let site = seeded().await; |
| 355 | for uri in [ |
| 356 | "/new", |
| 357 | "/login", |
| 358 | "/logout", |
| 359 | "/setup", |
| 360 | "/healthz", |
| 361 | "/settings/profile", |
| 362 | "/settings/keys", |
| 363 | "/admin/users", |
| 364 | "/assets/main.css", |
| 365 | "/avatars/alice", |
| 366 | ] { |
| 367 | let (_, body) = get(&site.router, uri, None).await; |
| 368 | assert!( |
| 369 | !body.contains("<article class=\"profile-page\""), |
| 370 | "{uri} was served by the profile page" |
| 371 | ); |
| 372 | } |
| 373 | // The fixed login page still renders its form, not a profile. |
| 374 | let (status, body) = get(&site.router, "/login", None).await; |
| 375 | assert_eq!(status, 200); |
| 376 | assert!(body.contains("<form")); |
| 377 | // Two-segment repo routes keep working and still 404 for a missing repo. |
| 378 | let (status, body) = get(&site.router, "/alice/missing", None).await; |
| 379 | assert_eq!(status, 404); |
| 380 | assert!(!body.contains("<article class=\"profile-page\"")); |
| 381 | } |
| 382 | } |