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 | 151 | if status, _ := inst.get(t, "/puborg/site"); status != 200 { |
| 152 | 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 | 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 | 201 | // Private repo pages: 404, indistinguishable from nonexistent. |
| 190 | 202 | for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { |
| 191 | 203 | if status, _ := inst.get(t, p); status != 404 { |
internal/httpd/routes.go
+1
| @@ -28,6 +28,7 @@ func (s *Server) Routes() []Route { |
| 28 | 28 | routes = append(routes, |
| 29 | 29 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, |
| 30 | 30 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, |
| 31 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, |
| 31 | 32 | Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, |
| 32 | 33 | Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, |
| 33 | 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 | 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. |
| 147 | func (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 | |
| 144 | 195 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { |
| 145 | 196 | p, ok := s.repoFor(w, r, "") |
| 146 | 197 | if !ok { |
internal/store/repos.go
+20
| @@ -235,3 +235,23 @@ func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error { |
| 235 | 235 | _, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID) |
| 236 | 236 | return err |
| 237 | 237 | } |
| 238 | |
| 239 | // ListReposForOwner returns every repo owned by one user or org; the caller |
| 240 | // filters by viewer visibility. |
| 241 | func (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 | 4 | <form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}} |
| 5 | 5 | <h1>repositories</h1> |
| 6 | 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 | 8 | {{else}}<tr><td>no public repositories</td></tr>{{end}} |
| 9 | 9 | </table> |
| 10 | 10 | {{if .Mine}}<h2>your private repositories</h2> |
internal/web/templates/layout.html
+1 −1
| @@ -17,7 +17,7 @@ |
| 17 | 17 | </html>{{end}} |
| 18 | 18 | |
| 19 | 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 | 21 | <nav class="tabs"> |
| 22 | 22 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> |
| 23 | 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}} |