Commit 471902dd14
Verified · cmc
e2e/mirror_test.go +40 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "encoding/json" | |
| 4 | 5 | "net/http/cgi" |
| 5 | 6 | "net/http/httptest" |
| 6 | 7 | "os" |
| @@ -52,7 +53,7 @@ func waitFor(t *testing.T, what string, cond func() bool) { | ||
| 52 | 53 | |
| 53 | 54 | func TestMirrors(t *testing.T) { |
| 54 | 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 | 57 | aliceKey := inst.newKey(t, "alice") |
| 57 | 58 | bobKey := inst.newKey(t, "bob") |
| 58 | 59 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| @@ -91,6 +92,40 @@ func TestMirrors(t *testing.T) { | ||
| 91 | 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 | 129 | // ---- pull mirror: local repo follows the remote and refuses pushes. |
| 95 | 130 | srcURL, srcBare := gitHTTPRemote(t) |
| 96 | 131 | seed := t.TempDir() |
| @@ -144,6 +179,10 @@ func TestMirrors(t *testing.T) { | ||
| 144 | 179 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json") |
| 145 | 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 | |
| 149 | 188 | func TestMirrorSSRFGuard(t *testing.T) { |
internal/control/repo.go +37 −8
| @@ -226,14 +226,22 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 226 | 226 | if code >= 0 { |
| 227 | 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 | 236 | type out struct { |
| 230 | Path string `json:"path"` | |
| 231 | Description string `json:"description,omitempty"` | |
| 232 | Visibility string `json:"visibility"` | |
| 233 | DefaultBranch string `json:"default_branch"` | |
| 234 | ProtectedBranches []string `json:"protected_branches,omitempty"` | |
| 235 | Archived bool `json:"archived,omitempty"` | |
| 236 | Topics []string `json:"topics,omitempty"` | |
| 237 | Path string `json:"path"` | |
| 238 | Description string `json:"description,omitempty"` | |
| 239 | Visibility string `json:"visibility"` | |
| 240 | DefaultBranch string `json:"default_branch"` | |
| 241 | ProtectedBranches []string `json:"protected_branches,omitempty"` | |
| 242 | Archived bool `json:"archived,omitempty"` | |
| 243 | Topics []string `json:"topics,omitempty"` | |
| 244 | Mirrors []mirrorOut `json:"mirrors,omitempty"` | |
| 237 | 245 | } |
| 238 | 246 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) |
| 239 | 247 | topics, err := c.Store.ListTopics(repo.ID) |
| @@ -241,7 +249,18 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 241 | 249 | return c.fail(protocol.ExitFailure, "%v", err) |
| 242 | 250 | } |
| 243 | 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 | 264 | return c.emit(d, func(w io.Writer) { |
| 246 | 265 | line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch) |
| 247 | 266 | if d.Archived { |
| @@ -257,6 +276,16 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 257 | 276 | if len(d.ProtectedBranches) > 0 { |
| 258 | 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 | 218 | Pinned bool // by the viewer |
| 219 | 219 | HasWiki bool |
| 220 | 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. | |
| 226 | type 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". | |
| 235 | func 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 | 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 | 252 | repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) |
| 234 | 253 | ok := err == nil |
| 254 | grant := "" | |
| 235 | 255 | if ok { |
| 236 | grant := "" | |
| 237 | 256 | if viewer.ID != 0 { |
| 238 | 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 | 270 | if viewer.ID != 0 { |
| 252 | 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 | 285 | return repoPage{ |
| 286 | Mirrors: mirrors, | |
| 255 | 287 | Site: s.siteName(), |
| 256 | 288 | Viewer: viewer.Username, |
| 257 | 289 | Pinned: pinned, |
internal/web/static/style.css +2
| @@ -530,6 +530,8 @@ pre.diff .meta { color: var(--muted); } | ||
| 530 | 530 | .chip.label { font-weight: 500; } |
| 531 | 531 | .chip.topic { --chip: var(--accent); } |
| 532 | 532 | .repohead .topics { margin: 0 0 var(--sp-2); } |
| 533 | .repohead .mirrorline { margin: 0 0 var(--sp-2); } | |
| 534 | .mirrorerr { color: var(--bad); } | |
| 533 | 535 | .badge-verified { --chip: var(--ok); } |
| 534 | 536 | .badge-unsigned { --chip: var(--neutral); } |
| 535 | 537 | .badge-signed_unknown_key { --chip: var(--warn); } |
internal/web/templates/layout.html +2 −1
| @@ -32,7 +32,8 @@ | ||
| 32 | 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 | 33 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} |
| 34 | 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 | 37 | <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> |
| 37 | 38 | <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> |
| 38 | 39 | <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> |