krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
388142116c9926c40deb8bbcdc74924036009eec
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T02:24:52Z
e2e/org_test.go | 4 +++ e2e/web_test.go | 12 +++++++++ internal/httpd/routes.go | 1 + internal/httpd/web.go | 51 ++++++++++++++++++++++++++++++++++++++ internal/store/repos.go | 20 +++++++++++++++ internal/web/templates/index.html | 2 +- internal/web/templates/layout.html | 2 +- internal/web/templates/owner.html | 14 +++++++++++ 8 files changed, 104 insertions(+), 2 deletions(-) @@ -151,4 +151,8 @@ func TestOrganizations(t *testing.T) { if status, _ := inst.get(t, "/puborg/site"); status != 200 { t.Fatalf("org repo page: %d", status) } + status, body = inst.get(t, "/puborg") + if status != 200 || !strings.Contains(body, "org") || !strings.Contains(body, "alice") || !strings.Contains(body, ">site<") { + t.Fatalf("org owner page: %d\n%s", status, body) + } } @@ -186,6 +186,18 @@ func TestWebUI(t *testing.T) { t.Fatalf("txt README not escaped-plaintext:\n%s", body) } + // Owner page: lists visible repos only; unknown owners 404. + status, body = inst.get(t, "/alice") + if status != 200 || !strings.Contains(body, ">site<") || !strings.Contains(body, "user") { + t.Fatalf("owner page: %d", status) + } + if strings.Contains(body, "secret") { + t.Fatal("owner page leaks private repo") + } + if status, _ := inst.get(t, "/nobody"); status != 404 { + t.Fatalf("unknown owner: %d, want 404", status) + } + // Private repo pages: 404, indistinguishable from nonexistent. for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { if status, _ := inst.get(t, p); status != 404 { @@ -28,6 +28,7 @@ func (s *Server) Routes() []Route { routes = append(routes, Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, + Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, @@ -141,6 +141,57 @@ func crumbs(p repoPage, kind, filePath string) []crumb { return cs } +// ownerPage renders /{owner} for users and orgs: the repositories the +// viewer may see, org membership either direction. Owner names are not +// secret (they are on every commit); repository visibility rules hold. +func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("owner") + var viewer store.User + if s.cfg.Web.Mode == "accounts" { + viewer = s.viewer(r) + } + + kind := "user" + var ownerID int64 + var members []store.OrgMember + var orgs []store.OrgMember + if u, err := s.st.UserByUsername(name); err == nil { + ownerID = u.ID + orgs, _ = s.st.ListOrgsForUser(u.ID) + } else if o, err := s.st.OrgByName(name); err == nil { + kind, ownerID = "org", o.ID + members, _ = s.st.OrgMembers(o.ID) + } else { + http.NotFound(w, r) + return + } + + all, err := s.st.ListReposForOwner(kind, ownerID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + var visible []store.Repo + for _, repo := range all { + grant := "" + if viewer.ID != 0 { + grant, _ = s.st.AccessRole(repo.ID, viewer.ID) + } + if policy.CanRead(viewer, repo, grant) { + visible = append(visible, repo) + } + } + s.render(w, "owner.html", struct { + Site string + Viewer string + Owner string + Kind string + Repos []store.Repo + Members []store.OrgMember + Orgs []store.OrgMember + }{s.siteName(), viewer.Username, name, kind, visible, members, orgs}) +} + func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { p, ok := s.repoFor(w, r, "") if !ok { @@ -235,3 +235,23 @@ func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error { _, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID) return err } + +// ListReposForOwner returns every repo owned by one user or org; the caller +// filters by viewer visibility. +func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) { + rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name", + ownerKind, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Repo + for rows.Next() { + r, err := scanRepo(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} @@ -4,7 +4,7 @@ <form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}} <h1>repositories</h1> <table> -{{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> +{{range .Repos}}<tr><td><a href="/{{.OwnerName}}">{{.OwnerName}}</a>/<a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> {{else}}<tr><td>no public repositories</td></tr>{{end}} </table> {{if .Mine}}<h2>your private repositories</h2> @@ -17,7 +17,7 @@ </html>{{end}} {{define "repoheader"}} -<h1><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.OwnerName}}/{{.Repo.Name}}</a></h1> +<h1><a href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a>/<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a></h1> <nav class="tabs"> <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> new file mode 100644 @@ -0,0 +1,14 @@ +{{define "title"}}{{.Owner}} · {{.Site}}{{end}} +{{define "content"}} +<h1>{{.Owner}} <span class="badge badge-unsigned">{{.Kind}}</span></h1> +{{if .Orgs}}<p class="crumbs">member of: {{range .Orgs}}<a href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} +{{if .Members}}<p class="crumbs">members: {{range .Members}}<a href="/{{.Username}}">{{.Username}}</a> ({{.Role}}) {{end}}</p>{{end}} +<table> +{{range .Repos}}<tr> + <td><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></td> + <td>{{.Visibility}}</td> + <td>{{.DefaultBranch}}</td> +</tr> +{{else}}<tr><td>no visible repositories</td></tr>{{end}} +</table> +{{end}}