Surface mirror status in repo show and the web repo page !36

merged merged by cmc on 2026-08-25 03:50 UTC · krz/gitbay:mirror-status into main

5 files changed, +114 −11

Layout: unified · split

e2e/mirror_test.go +40 −1
@@ -1,6 +1,7 @@
1package e2e 1package e2e
2 2
3import ( 3import (
4 "encoding/json"
4 "net/http/cgi" 5 "net/http/cgi"
5 "net/http/httptest" 6 "net/http/httptest"
6 "os" 7 "os"
@@ -52,7 +53,7 @@ func waitFor(t *testing.T, what string, cond func() bool) {
52 53
53func TestMirrors(t *testing.T) { 54func TestMirrors(t *testing.T) {
54 t.Setenv("GITBAY_MIRROR_TICK", "200ms") 55 t.Setenv("GITBAY_MIRROR_TICK", "200ms")
55 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n") 56 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n[web]\nmode = \"accounts\"\n")
56 aliceKey := inst.newKey(t, "alice") 57 aliceKey := inst.newKey(t, "alice")
57 bobKey := inst.newKey(t, "bob") 58 bobKey := inst.newKey(t, "bob")
58 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") 59 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
@@ -91,6 +92,40 @@ func TestMirrors(t *testing.T) {
91 t.Fatalf("mirror list after sync: %s", out) 92 t.Fatalf("mirror list after sync: %s", out)
92 } 93 }
93 94
95 // ---- status surfacing: repo show carries mirrors for admins only.
96 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
97 if !strings.Contains(out, `"mirrors":[`) || !strings.Contains(out, `"last_sync":"`) ||
98 strings.Contains(out, "token") {
99 t.Fatalf("repo show missing mirror status: %s", out)
100 }
101 out, _, _ = inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json")
102 if strings.Contains(out, `"mirrors"`) {
103 t.Fatalf("repo show leaked mirrors to non-admin: %s", out)
104 }
105
106 // The web repo page shows the mirror line to the admin, not to visitors.
107 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
108 if code != 0 {
109 t.Fatalf("web login: %s", errOut)
110 }
111 var loginEnv struct {
112 Data struct {
113 URL string `json:"url"`
114 } `json:"data"`
115 }
116 json.Unmarshal([]byte(out), &loginEnv)
117 loginPath := loginEnv.Data.URL[strings.Index(loginEnv.Data.URL, "/login"):]
118 browser := newBrowser(t)
119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120 t.Fatalf("login: %d", status)
121 }
122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "mirrors to") {
123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124 }
125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "mirrors to") {
126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127 }
128
94 // ---- pull mirror: local repo follows the remote and refuses pushes. 129 // ---- pull mirror: local repo follows the remote and refuses pushes.
95 srcURL, srcBare := gitHTTPRemote(t) 130 srcURL, srcBare := gitHTTPRemote(t)
96 seed := t.TempDir() 131 seed := t.TempDir()
@@ -144,6 +179,10 @@ func TestMirrors(t *testing.T) {
144 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json") 179 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json")
145 return strings.Contains(out, `"last_error":"git push`) 180 return strings.Contains(out, `"last_error":"git push`)
146 }) 181 })
182 // The failure is visible on the admin's web repo page.
183 if _, body := browserGet(t, browser, inst.base()+"/alice/follow"); !strings.Contains(body, "sync error:") {
184 t.Fatalf("admin repo page missing sync error:\n%s", body)
185 }
147} 186}
148 187
149func TestMirrorSSRFGuard(t *testing.T) { 188func TestMirrorSSRFGuard(t *testing.T) {
internal/control/repo.go +37 −8
@@ -226,14 +226,22 @@ func runRepoShow(c *Ctx, args []string) int {
226 if code >= 0 { 226 if code >= 0 {
227 return code 227 return code
228 } 228 }
229 type mirrorOut struct {
230 Direction string `json:"direction"`
231 URL string `json:"url"`
232 Pending bool `json:"pending"`
233 LastSync string `json:"last_sync,omitempty"`
234 LastError string `json:"last_error,omitempty"`
235 }
229 type out struct { 236 type out struct {
230 Path string `json:"path"` 237 Path string `json:"path"`
231 Description string `json:"description,omitempty"` 238 Description string `json:"description,omitempty"`
232 Visibility string `json:"visibility"` 239 Visibility string `json:"visibility"`
233 DefaultBranch string `json:"default_branch"` 240 DefaultBranch string `json:"default_branch"`
234 ProtectedBranches []string `json:"protected_branches,omitempty"` 241 ProtectedBranches []string `json:"protected_branches,omitempty"`
235 Archived bool `json:"archived,omitempty"` 242 Archived bool `json:"archived,omitempty"`
236 Topics []string `json:"topics,omitempty"` 243 Topics []string `json:"topics,omitempty"`
244 Mirrors []mirrorOut `json:"mirrors,omitempty"`
237 } 245 }
238 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) 246 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
239 topics, err := c.Store.ListTopics(repo.ID) 247 topics, err := c.Store.ListTopics(repo.ID)
@@ -241,7 +249,18 @@ func runRepoShow(c *Ctx, args []string) int {
241 return c.fail(protocol.ExitFailure, "%v", err) 249 return c.fail(protocol.ExitFailure, "%v", err)
242 } 250 }
243 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches, 251 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches,
244 repo.Settings.Archived, topics} 252 repo.Settings.Archived, topics, nil}
253 // Mirror status is admin-only, like repo mirror list. The token never
254 // leaves the server.
255 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
256 ms, err := c.Store.ListMirrors(repo.ID)
257 if err != nil {
258 return c.fail(protocol.ExitFailure, "%v", err)
259 }
260 for _, m := range ms {
261 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
262 }
263 }
245 return c.emit(d, func(w io.Writer) { 264 return c.emit(d, func(w io.Writer) {
246 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch) 265 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
247 if d.Archived { 266 if d.Archived {
@@ -257,6 +276,16 @@ func runRepoShow(c *Ctx, args []string) int {
257 if len(d.ProtectedBranches) > 0 { 276 if len(d.ProtectedBranches) > 0 {
258 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) 277 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
259 } 278 }
279 for _, m := range d.Mirrors {
280 status := "ok"
281 if m.Pending {
282 status = "pending"
283 }
284 if m.LastError != "" {
285 status = "error: " + m.LastError
286 }
287 fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
288 }
260 }) 289 })
261} 290}
262 291
internal/httpd/web.go +33 −1
@@ -218,6 +218,25 @@ type repoPage struct {
218 Pinned bool // by the viewer 218 Pinned bool // by the viewer
219 HasWiki bool 219 HasWiki bool
220 Host string 220 Host string
221 Mirrors []mirrorLine // repo admins only
222}
223
224// mirrorLine is the admin-only mirror status shown in the repo header.
225// It carries no credentials: URL host/path only, sync time, and error.
226type mirrorLine struct {
227 Direction string
228 Target string // URL without the scheme
229 Synced string
230 Error string
231}
232
233// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
234// readable "2026-08-25 03:39 UTC".
235func syncedAt(ts string) string {
236 if len(ts) < 16 {
237 return ts
238 }
239 return ts[:10] + " " + ts[11:16] + " UTC"
221} 240}
222 241
223// repoFor resolves the repo for a web request; false means 404 was sent. 242// repoFor resolves the repo for a web request; false means 404 was sent.
@@ -232,8 +251,8 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
232 } 251 }
233 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) 252 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
234 ok := err == nil 253 ok := err == nil
254 grant := ""
235 if ok { 255 if ok {
236 grant := ""
237 if viewer.ID != 0 { 256 if viewer.ID != 0 {
238 grant, _ = s.st.AccessRole(repo.ID, viewer.ID) 257 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
239 } 258 }
@@ -251,7 +270,20 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
251 if viewer.ID != 0 { 270 if viewer.ID != 0 {
252 pinned = s.st.IsPinned(viewer.ID, repo.ID) 271 pinned = s.st.IsPinned(viewer.ID, repo.ID)
253 } 272 }
273 var mirrors []mirrorLine
274 if viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) {
275 ms, _ := s.st.ListMirrors(repo.ID)
276 for _, m := range ms {
277 mirrors = append(mirrors, mirrorLine{
278 Direction: m.Direction,
279 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
280 Synced: syncedAt(m.LastSync),
281 Error: m.LastError,
282 })
283 }
284 }
254 return repoPage{ 285 return repoPage{
286 Mirrors: mirrors,
255 Site: s.siteName(), 287 Site: s.siteName(),
256 Viewer: viewer.Username, 288 Viewer: viewer.Username,
257 Pinned: pinned, 289 Pinned: pinned,
internal/web/static/style.css +2
@@ -530,6 +530,8 @@ pre.diff .meta { color: var(--muted); }
530.chip.label { font-weight: 500; } 530.chip.label { font-weight: 500; }
531.chip.topic { --chip: var(--accent); } 531.chip.topic { --chip: var(--accent); }
532.repohead .topics { margin: 0 0 var(--sp-2); } 532.repohead .topics { margin: 0 0 var(--sp-2); }
533.repohead .mirrorline { margin: 0 0 var(--sp-2); }
534.mirrorerr { color: var(--bad); }
533.badge-verified { --chip: var(--ok); } 535.badge-verified { --chip: var(--ok); }
534.badge-unsigned { --chip: var(--neutral); } 536.badge-unsigned { --chip: var(--neutral); }
535.badge-signed_unknown_key { --chip: var(--warn); } 537.badge-signed_unknown_key { --chip: var(--warn); }
internal/web/templates/layout.html +2 −1
@@ -32,7 +32,8 @@
32<h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1> 32<h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1>
33{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} 33{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
34{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} 34{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
35<nav class="tabs"> 35{{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} {{.Target}}{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
36{{end}}<nav class="tabs">
36 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> 37 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
37 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> 38 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
38 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> 39 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>