Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! `GET /{owner}` — a user or organization profile (spec §7).
2//!
3//! Users and organizations share one handle namespace (enforced by a trigger in
4//! the schema), so one route serves both and the lookup tries users first.
5//!
6//! The repository list is filtered by the same rule the repository pages use:
7//! public repos to everyone, private repos only where the viewer has a
8//! collaborator row, an org membership, ownership, or site admin. Getting this
9//! query wrong is the private-repo leak in its most direct form — a profile page
10//! that enumerates repositories the viewer cannot open.
11
12use axum::extract::{Path as UrlPath, State};
13use axum::response::{IntoResponse, Response};
14use serde::Deserialize;
15use uuid::Uuid;
16
17use crate::error::{AppError, AppResult};
18use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
19use crate::views::settings as v;
20use crate::views::{self, Chrome};
21
22/// `GET /{owner}`
23pub async fn show(
24 State(state): State<AppState>,
25 UrlPath(handle): UrlPath<String>,
26 CurrentUser(user): CurrentUser,
27 CsrfToken(csrf): CsrfToken,
28 Nonce(nonce): Nonce,
29) -> AppResult<Response> {
30 let viewer = user.as_ref().map(|u| u.id);
31 let is_site_admin = user.as_ref().is_some_and(|u| u.is_admin);
32
33 let owner = load_owner(&state, &handle).await?.ok_or(AppError::NotFound)?;
34
35 let repos = load_repos(&state, &owner, viewer, is_site_admin).await?;
36
37 let members = if owner.is_org {
38 load_members(&state, owner.id).await?
39 } else {
40 Vec::new()
41 };
42
43 Ok(views::page(
44 Chrome {
45 title: &owner.handle,
46 user: user.as_deref(),
47 csrf: &csrf,
48 nonce: &nonce,
49 },
50 v::profile(v::Profile {
51 handle: &owner.handle,
52 display_name: owner.display_name.as_deref(),
53 description: owner.description.as_deref(),
54 is_org: owner.is_org,
55 repos: &repos,
56 members: &members,
57 joined: owner.created_at,
58 }),
59 )
60 .into_response())
61}
62
63struct Owner {
64 id: Uuid,
65 handle: String,
66 display_name: Option<String>,
67 description: Option<String>,
68 is_org: bool,
69 created_at: chrono::DateTime<chrono::Utc>,
70}
71
72#[derive(sqlx::FromRow)]
73struct OwnerRow {
74 id: Uuid,
75 handle: String,
76 display_name: Option<String>,
77 description: Option<String>,
78 is_org: bool,
79 created_at: chrono::DateTime<chrono::Utc>,
80}
81
82async fn load_owner(state: &AppState, handle: &str) -> AppResult<Option<Owner>> {
83 // One statement over both tables. The handle namespace is shared, so at most
84 // one row can match — and doing it in one query means a user and an org can
85 // never both be found and silently resolved in some arbitrary order.
86 let row: Option<OwnerRow> = sqlx::query_as(
87 r#"
88 SELECT id, handle::text AS handle, display_name, NULL::text AS description,
89 false AS is_org, created_at
90 FROM users WHERE handle = $1
91 UNION ALL
92 SELECT id, handle::text AS handle, display_name, description,
93 true AS is_org, created_at
94 FROM orgs WHERE handle = $1
95 "#,
96 )
97 .bind(handle)
98 .fetch_optional(&state.db)
99 .await?;
100
101 Ok(row.map(|r| Owner {
102 id: r.id,
103 handle: r.handle,
104 display_name: r.display_name,
105 description: r.description,
106 is_org: r.is_org,
107 created_at: r.created_at,
108 }))
109}
110
111#[derive(Deserialize, sqlx::FromRow)]
112struct RepoRow {
113 name: String,
114 description: Option<String>,
115 private: bool,
116 pushed_at: Option<chrono::DateTime<chrono::Utc>>,
117}
118
119async fn load_repos(
120 state: &AppState,
121 owner: &Owner,
122 viewer: Option<Uuid>,
123 is_site_admin: bool,
124) -> AppResult<Vec<v::ProfileRepo>> {
125 // The visibility predicate is written out here rather than filtered in Rust
126 // so a private repository is never loaded in the first place. Every branch
127 // after the first requires `$2` (the viewer) to be non-null, so an anonymous
128 // request can only ever match the public branch.
129 let rows: Vec<RepoRow> = sqlx::query_as(
130 r#"
131 SELECT r.name::text AS name,
132 r.description,
133 (r.visibility = 'private') AS private,
134 r.pushed_at
135 FROM repos r
136 WHERE (r.owner_user_id = $1 OR r.owner_org_id = $1)
137 AND (
138 r.visibility = 'public'
139 OR $3
140 OR r.owner_user_id = $2
141 OR EXISTS (SELECT 1 FROM repo_collaborators c
142 WHERE c.repo_id = r.id AND c.user_id = $2)
143 OR EXISTS (SELECT 1 FROM org_members m
144 WHERE m.org_id = r.owner_org_id AND m.user_id = $2)
145 )
146 ORDER BY r.pushed_at DESC NULLS LAST, r.name
147 LIMIT 200
148 "#,
149 )
150 .bind(owner.id)
151 .bind(viewer)
152 .bind(is_site_admin)
153 .fetch_all(&state.db)
154 .await?;
155
156 Ok(rows
157 .into_iter()
158 .map(|r| v::ProfileRepo {
159 name: r.name,
160 description: r.description,
161 private: r.private,
162 pushed_at: r.pushed_at,
163 })
164 .collect())
165}
166
167async fn load_members(
168 state: &AppState,
169 org_id: Uuid,
170) -> AppResult<Vec<(String, df_db::models::OrgRole)>> {
171 Ok(sqlx::query_as(
172 "SELECT u.handle::text, m.role
173 FROM org_members m JOIN users u ON u.id = m.user_id
174 WHERE m.org_id = $1
175 ORDER BY m.role DESC, u.handle",
176 )
177 .bind(org_id)
178 .fetch_all(&state.db)
179 .await?)
180}

180 lines · Rust