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. |
| |
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 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { |
195 | func (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. |
| |
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 | <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}} |