Commit 388142116c

388142116c9926c40deb8bbcdc74924036009eec

parent: b09bf9f4c0

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 02:24 UTC

web: owner pages at /{owner}

Users and orgs get a profile page: repositories filtered by viewer
visibility (anonymous sees public only), org pages list members, user
pages list org memberships; unknown owners 404. Owner names in the
index and repo headers now link to it.

Layout: unified · split

e2e/org_test.go +4
@@ -151,4 +151,8 @@ func TestOrganizations(t *testing.T) {
151 if status, _ := inst.get(t, "/puborg/site"); status != 200 { 151 if status, _ := inst.get(t, "/puborg/site"); status != 200 {
152 t.Fatalf("org repo page: %d", status) 152 t.Fatalf("org repo page: %d", status)
153 } 153 }
154 status, body = inst.get(t, "/puborg")
155 if status != 200 || !strings.Contains(body, "org") || !strings.Contains(body, "alice") || !strings.Contains(body, ">site<") {
156 t.Fatalf("org owner page: %d\n%s", status, body)
157 }
154} 158}
e2e/web_test.go +12
@@ -186,6 +186,18 @@ func TestWebUI(t *testing.T) {
186 t.Fatalf("txt README not escaped-plaintext:\n%s", body) 186 t.Fatalf("txt README not escaped-plaintext:\n%s", body)
187 } 187 }
188 188
189 // Owner page: lists visible repos only; unknown owners 404.
190 status, body = inst.get(t, "/alice")
191 if status != 200 || !strings.Contains(body, ">site<") || !strings.Contains(body, "user") {
192 t.Fatalf("owner page: %d", status)
193 }
194 if strings.Contains(body, "secret") {
195 t.Fatal("owner page leaks private repo")
196 }
197 if status, _ := inst.get(t, "/nobody"); status != 404 {
198 t.Fatalf("unknown owner: %d, want 404", status)
199 }
200
189 // Private repo pages: 404, indistinguishable from nonexistent. 201 // Private repo pages: 404, indistinguishable from nonexistent.
190 for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { 202 for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} {
191 if status, _ := inst.get(t, p); status != 404 { 203 if status, _ := inst.get(t, p); status != 404 {
internal/httpd/routes.go +1
@@ -28,6 +28,7 @@ func (s *Server) Routes() []Route {
28 routes = append(routes, 28 routes = append(routes,
29 Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, 29 Route{Method: "GET", Pattern: "/{$}", Handler: s.index},
30 Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, 30 Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet},
31 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage},
31 Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, 32 Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome},
32 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, 33 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree},
33 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, 34 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
internal/httpd/web.go +51
@@ -141,6 +141,57 @@ func crumbs(p repoPage, kind, filePath string) []crumb {
141 return cs 141 return cs
142} 142}
143 143
144// ownerPage renders /{owner} for users and orgs: the repositories the
145// viewer may see, org membership either direction. Owner names are not
146// secret (they are on every commit); repository visibility rules hold.
147func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
148 name := r.PathValue("owner")
149 var viewer store.User
150 if s.cfg.Web.Mode == "accounts" {
151 viewer = s.viewer(r)
152 }
153
154 kind := "user"
155 var ownerID int64
156 var members []store.OrgMember
157 var orgs []store.OrgMember
158 if u, err := s.st.UserByUsername(name); err == nil {
159 ownerID = u.ID
160 orgs, _ = s.st.ListOrgsForUser(u.ID)
161 } else if o, err := s.st.OrgByName(name); err == nil {
162 kind, ownerID = "org", o.ID
163 members, _ = s.st.OrgMembers(o.ID)
164 } else {
165 http.NotFound(w, r)
166 return
167 }
168
169 all, err := s.st.ListReposForOwner(kind, ownerID)
170 if err != nil {
171 http.Error(w, "internal error", http.StatusInternalServerError)
172 return
173 }
174 var visible []store.Repo
175 for _, repo := range all {
176 grant := ""
177 if viewer.ID != 0 {
178 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
179 }
180 if policy.CanRead(viewer, repo, grant) {
181 visible = append(visible, repo)
182 }
183 }
184 s.render(w, "owner.html", struct {
185 Site string
186 Viewer string
187 Owner string
188 Kind string
189 Repos []store.Repo
190 Members []store.OrgMember
191 Orgs []store.OrgMember
192 }{s.siteName(), viewer.Username, name, kind, visible, members, orgs})
193}
194
144func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 195func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
145 p, ok := s.repoFor(w, r, "") 196 p, ok := s.repoFor(w, r, "")
146 if !ok { 197 if !ok {
internal/store/repos.go +20
@@ -235,3 +235,23 @@ func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
235 _, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID) 235 _, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
236 return err 236 return err
237} 237}
238
239// ListReposForOwner returns every repo owned by one user or org; the caller
240// filters by viewer visibility.
241func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) {
242 rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name",
243 ownerKind, ownerID)
244 if err != nil {
245 return nil, err
246 }
247 defer rows.Close()
248 var out []Repo
249 for rows.Next() {
250 r, err := scanRepo(rows)
251 if err != nil {
252 return nil, err
253 }
254 out = append(out, r)
255 }
256 return out, rows.Err()
257}
internal/web/templates/index.html +1 −1
@@ -4,7 +4,7 @@
4<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}} 4<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}}
5<h1>repositories</h1> 5<h1>repositories</h1>
6<table> 6<table>
7{{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> 7{{range .Repos}}<tr><td><a href="/{{.OwnerName}}">{{.OwnerName}}</a>/<a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr>
8{{else}}<tr><td>no public repositories</td></tr>{{end}} 8{{else}}<tr><td>no public repositories</td></tr>{{end}}
9</table> 9</table>
10{{if .Mine}}<h2>your private repositories</h2> 10{{if .Mine}}<h2>your private repositories</h2>
internal/web/templates/layout.html +1 −1
@@ -17,7 +17,7 @@
17</html>{{end}} 17</html>{{end}}
18 18
19{{define "repoheader"}} 19{{define "repoheader"}}
20<h1><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.OwnerName}}/{{.Repo.Name}}</a></h1> 20<h1><a href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a>/<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a></h1>
21<nav class="tabs"> 21<nav class="tabs">
22 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> 22 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
23 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> 23 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
internal/web/templates/owner.html added +14
@@ -0,0 +1,14 @@
1{{define "title"}}{{.Owner}} · {{.Site}}{{end}}
2{{define "content"}}
3<h1>{{.Owner}} <span class="badge badge-unsigned">{{.Kind}}</span></h1>
4{{if .Orgs}}<p class="crumbs">member of: {{range .Orgs}}<a href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
5{{if .Members}}<p class="crumbs">members: {{range .Members}}<a href="/{{.Username}}">{{.Username}}</a> ({{.Role}}) {{end}}</p>{{end}}
6<table>
7{{range .Repos}}<tr>
8 <td><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td>
9 <td>{{.Visibility}}</td>
10 <td>{{.DefaultBranch}}</td>
11</tr>
12{{else}}<tr><td>no visible repositories</td></tr>{{end}}
13</table>
14{{end}}