forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.3 KiB
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
5use forge_core::authz::{Action, authorize};
6use forge_core::tz::local_time_now;
7use forge_core::{Repo, User};
8use topcoat::Result;
9use topcoat::context::{Cx, app_context};
10use topcoat::router::error::not_found;
11use topcoat::router::{page, path_param};
12use topcoat::view::{Unescaped, View, component, view};
13
14use crate::auth::current_user;
15use crate::i18n::orgs::{self as g, K};
16use crate::i18n::{self, Lang};
17use crate::pages::dashboard::badge_class;
18use crate::render::markdown::render_markdown;
19use crate::state::AppState;
20use crate::views::{avatar_url, now_unix, repo_href, shell};
21
22path_param!(username);
23
24#[page("/{username}")]
25async 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.
102struct OrgSummary {
103 members: Vec<String>,
104 teams: usize,
105}
106
107#[component]
108async 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)]
206mod 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}