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) {
151151 if status, _ := inst.get(t, "/puborg/site"); status != 200 {
152152 t.Fatalf("org repo page: %d", status)
153153 }
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 }
154158}
e2e/web_test.go +12
@@ -186,6 +186,18 @@ func TestWebUI(t *testing.T) {
186186 t.Fatalf("txt README not escaped-plaintext:\n%s", body)
187187 }
188188
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
189201 // Private repo pages: 404, indistinguishable from nonexistent.
190202 for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} {
191203 if status, _ := inst.get(t, p); status != 404 {
internal/httpd/routes.go +1
@@ -28,6 +28,7 @@ func (s *Server) Routes() []Route {
2828 routes = append(routes,
2929 Route{Method: "GET", Pattern: "/{$}", Handler: s.index},
3030 Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet},
31 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage},
3132 Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome},
3233 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree},
3334 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 {
141141 return cs
142142}
143143
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
144195func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
145196 p, ok := s.repoFor(w, r, "")
146197 if !ok {
internal/store/repos.go +20
@@ -235,3 +235,23 @@ func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
235235 _, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
236236 return err
237237}
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 @@
44<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}}
55<h1>repositories</h1>
66<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>
88{{else}}<tr><td>no public repositories</td></tr>{{end}}
99</table>
1010{{if .Mine}}<h2>your private repositories</h2>
internal/web/templates/layout.html +1 −1
@@ -17,7 +17,7 @@
1717</html>{{end}}
1818
1919{{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>
2121<nav class="tabs">
2222 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
2323 <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}}