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

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.
 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(-)

diff --git a/e2e/org_test.go b/e2e/org_test.go
index 852664c..bddf8c1 100644
--- a/e2e/org_test.go
+++ b/e2e/org_test.go
@@ -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)
+	}
 }
diff --git a/e2e/web_test.go b/e2e/web_test.go
index e239a0f..36f0f66 100644
--- a/e2e/web_test.go
+++ b/e2e/web_test.go
@@ -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 {
diff --git a/internal/httpd/routes.go b/internal/httpd/routes.go
index d7da92f..2d3f317 100644
--- a/internal/httpd/routes.go
+++ b/internal/httpd/routes.go
@@ -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},
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index 4427612..70b48ff 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -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 {
diff --git a/internal/store/repos.go b/internal/store/repos.go
index 95ce9dd..aa63b23 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -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()
+}
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index b04d8b8..30ace6c 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -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>
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html
index 40a2fec..fb77ffd 100644
--- a/internal/web/templates/layout.html
+++ b/internal/web/templates/layout.html
@@ -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>
diff --git a/internal/web/templates/owner.html b/internal/web/templates/owner.html
new file mode 100644
index 0000000..5a0519c
--- /dev/null
+++ b/internal/web/templates/owner.html
@@ -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}}