Commit 98e6424c98
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Parity.org +1
| @@ -163,6 +163,7 @@ always markdown. | ||
| 163 | 163 | | visibility | yes | yes | yes | |
| 164 | 164 | | archive (read-only flag) | yes | yes | yes | |
| 165 | 165 | | release list, show | yes | yes | yes | |
| 166 | | atom feeds | n/a | yes | n/a | | |
| 166 | 167 | | release create, edit | yes | yes | yes | |
| 167 | 168 | | build list | yes | yes | yes | |
| 168 | 169 | | build show (one build) | yes | yes | yes | |
.gitbay/wiki/Users.org +5 −1
| @@ -327,7 +327,11 @@ gitbay release list / show v1.0 / delete v1.0 --yes | ||
| 327 | 327 | #+end_src |
| 328 | 328 | |
| 329 | 329 | The web shows them under the repository's =releases= tab with rendered |
| 330 | notes, sha256 sums, and download links. | |
| 330 | notes, sha256 sums, and download links. Feed readers subscribe at | |
| 331 | =/you/project/releases.atom=; commits are at =/you/project/log.atom= | |
| 332 | (the default branch) or =/you/project/log.atom/<ref>=, and an owner's | |
| 333 | activity on their public repositories at =/you/activity.atom=. The | |
| 334 | pages carry the discovery link. | |
| 331 | 335 | |
| 332 | 336 | Commit messages act on issues when the commits land on the default |
| 333 | 337 | branch (direct push or MR merge): =closes/fixes/resolves #4= closes the |
e2e/feeds_test.go added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | // Atom feeds for releases, commits and an owner's public activity, read | |
| 12 | // with no session; private repositories stay out of them (#192). | |
| 13 | func TestAtomFeeds(t *testing.T) { | |
| 14 | inst := startInstance(t) | |
| 15 | aliceKey := inst.newKey(t, "alice") | |
| 16 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 17 | for _, args := range [][]string{ | |
| 18 | {"repo", "create", "alice/app"}, | |
| 19 | {"repo", "create", "alice/secret", "--private"}, | |
| 20 | } { | |
| 21 | if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 { | |
| 22 | t.Fatalf("%v: %s", args, errOut) | |
| 23 | } | |
| 24 | } | |
| 25 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/feed.atom"); code == 0 || !strings.Contains(errOut, ".atom") { | |
| 26 | t.Fatalf("a repository named like a feed was accepted: %d %s", code, errOut) | |
| 27 | } | |
| 28 | env := inst.gitEnv(aliceKey) | |
| 29 | for _, name := range []string{"app", "secret"} { | |
| 30 | dir := filepath.Join(t.TempDir(), name) | |
| 31 | mustGit(t, t.TempDir(), env, "clone", "-q", inst.sshURL("alice/"+name), dir) | |
| 32 | if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644); err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 36 | mustGit(t, dir, env, "add", ".") | |
| 37 | mustGit(t, dir, env, "commit", "-q", "-m", "first commit on "+name) | |
| 38 | mustGit(t, dir, env, "tag", "v1.0") | |
| 39 | mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0") | |
| 40 | if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/"+name, "v1.0", | |
| 41 | "--title", "'First light'", "--notes", "'notes for "+name+"'"); code != 0 { | |
| 42 | t.Fatalf("release create: %s", errOut) | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | anon := &http.Client{} | |
| 47 | get := func(path string, want int) string { | |
| 48 | t.Helper() | |
| 49 | status, body := browserGet(t, anon, inst.base()+path) | |
| 50 | if status != want { | |
| 51 | t.Fatalf("GET %s: %d, want %d\n%s", path, status, want, body) | |
| 52 | } | |
| 53 | return body | |
| 54 | } | |
| 55 | resp, err := http.Get(inst.base() + "/alice/app/releases.atom") | |
| 56 | if err != nil { | |
| 57 | t.Fatal(err) | |
| 58 | } | |
| 59 | resp.Body.Close() | |
| 60 | if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/atom+xml") { | |
| 61 | t.Fatalf("content type %q", ct) | |
| 62 | } | |
| 63 | body := get("/alice/app/releases.atom", 200) | |
| 64 | for _, want := range []string{`xmlns="http://www.w3.org/2005/Atom"`, "<title>v1.0: First light</title>", "notes for app", "/alice/app/releases#v1.0"} { | |
| 65 | if !strings.Contains(body, want) { | |
| 66 | t.Errorf("releases feed missing %s:\n%s", want, body) | |
| 67 | } | |
| 68 | } | |
| 69 | for _, path := range []string{"/alice/app/log.atom", "/alice/app/log.atom/main"} { | |
| 70 | if body := get(path, 200); !strings.Contains(body, "<title>first commit on app</title>") || !strings.Contains(body, "<name>t</name>") { | |
| 71 | t.Errorf("%s:\n%s", path, body) | |
| 72 | } | |
| 73 | } | |
| 74 | get("/alice/app/log.atom/nope", 404) | |
| 75 | body = get("/alice/activity.atom", 200) | |
| 76 | if !strings.Contains(body, "release.created alice/app v1.0") || strings.Contains(body, "secret") { | |
| 77 | t.Errorf("activity feed:\n%s", body) | |
| 78 | } | |
| 79 | get("/nobody/activity.atom", 404) | |
| 80 | get("/alice/secret/releases.atom", 404) | |
| 81 | get("/alice/secret/log.atom", 404) | |
| 82 | ||
| 83 | // The pages say where their feed is. | |
| 84 | for path, feed := range map[string]string{ | |
| 85 | "/alice/app/releases": "/alice/app/releases.atom", | |
| 86 | "/alice/app/log": "/alice/app/log.atom/main", | |
| 87 | "/alice": "/alice/activity.atom", | |
| 88 | } { | |
| 89 | if body := get(path, 200); !strings.Contains(body, `type="application/atom+xml" href="`+feed+`"`) { | |
| 90 | t.Errorf("%s has no discovery link to %s", path, feed) | |
| 91 | } | |
| 92 | } | |
| 93 | } | |
internal/httpd/atom.go added +195
| @@ -0,0 +1,195 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "encoding/xml" | |
| 6 | "fmt" | |
| 7 | "net/http" | |
| 8 | "strings" | |
| 9 | "time" | |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/gitutil" | |
| 12 | "gitbay.org/gitbay/internal/sig" | |
| 13 | "gitbay.org/gitbay/internal/store" | |
| 14 | ) | |
| 15 | ||
| 16 | // Atom feeds (#192): a repository's releases and commits, and an owner's | |
| 17 | // public activity. Renderers over rows other surfaces already serve; | |
| 18 | // nothing is stored for them. A feed reader carries no session, so the | |
| 19 | // owner feed covers public repositories only, and a private repository | |
| 20 | // answers 404 as its pages do. | |
| 21 | ||
| 22 | type atomFeed struct { | |
| 23 | XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"` | |
| 24 | ID string `xml:"id"` | |
| 25 | Title string `xml:"title"` | |
| 26 | Updated string `xml:"updated"` | |
| 27 | Links []atomLink `xml:"link"` | |
| 28 | Entries []atomEntry `xml:"entry"` | |
| 29 | } | |
| 30 | ||
| 31 | type atomLink struct { | |
| 32 | Rel string `xml:"rel,attr"` | |
| 33 | Href string `xml:"href,attr"` | |
| 34 | Type string `xml:"type,attr,omitempty"` | |
| 35 | } | |
| 36 | ||
| 37 | type atomEntry struct { | |
| 38 | ID string `xml:"id"` | |
| 39 | Title string `xml:"title"` | |
| 40 | Updated string `xml:"updated"` | |
| 41 | Author *atomAuthor `xml:"author,omitempty"` | |
| 42 | Link atomLink `xml:"link"` | |
| 43 | Content *atomContent `xml:"content,omitempty"` | |
| 44 | } | |
| 45 | ||
| 46 | type atomAuthor struct { | |
| 47 | Name string `xml:"name"` | |
| 48 | } | |
| 49 | ||
| 50 | type atomContent struct { | |
| 51 | Type string `xml:"type,attr"` | |
| 52 | Text string `xml:",chardata"` | |
| 53 | } | |
| 54 | ||
| 55 | const atomLimit = 50 | |
| 56 | ||
| 57 | func (s *Server) site() string { return strings.TrimSuffix(s.cfg.Server.SiteURL, "/") } | |
| 58 | ||
| 59 | // writeAtom serialises the feed. Updated falls back to the newest entry, | |
| 60 | // then to now: a feed with no entries is still a feed. | |
| 61 | func (s *Server) writeAtom(w http.ResponseWriter, path, title string, entries []atomEntry) { | |
| 62 | f := atomFeed{ID: s.site() + path, Title: title, Entries: entries, | |
| 63 | Links: []atomLink{ | |
| 64 | {Rel: "self", Href: s.site() + path, Type: "application/atom+xml"}, | |
| 65 | {Rel: "alternate", Href: s.site() + strings.TrimSuffix(strings.TrimSuffix(path, ".atom"), "/activity")}, | |
| 66 | }} | |
| 67 | if len(entries) > 0 { | |
| 68 | f.Updated = entries[0].Updated | |
| 69 | } else { | |
| 70 | f.Updated = time.Now().UTC().Format(time.RFC3339) | |
| 71 | } | |
| 72 | w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") | |
| 73 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 74 | fmt.Fprint(w, xml.Header) | |
| 75 | xml.NewEncoder(w).Encode(f) | |
| 76 | } | |
| 77 | ||
| 78 | func (s *Server) releasesAtom(w http.ResponseWriter, r *http.Request) { | |
| 79 | p, ok := s.repoFor(w, r, "") | |
| 80 | if !ok { | |
| 81 | return | |
| 82 | } | |
| 83 | rels, err := s.st.ListReleases(p.Repo.ID) | |
| 84 | if err != nil { | |
| 85 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 86 | return | |
| 87 | } | |
| 88 | base := "/" + p.Repo.Path() | |
| 89 | var entries []atomEntry | |
| 90 | for _, rel := range rels { | |
| 91 | title := rel.Tag | |
| 92 | if rel.Title != "" { | |
| 93 | title = rel.Tag + ": " + rel.Title | |
| 94 | } | |
| 95 | e := atomEntry{ID: s.site() + base + "/releases#" + rel.Tag, Title: title, Updated: rel.CreatedAt, | |
| 96 | Link: atomLink{Rel: "alternate", Href: s.site() + base + "/releases#" + rel.Tag}} | |
| 97 | if rel.Author != "" { | |
| 98 | e.Author = &atomAuthor{Name: rel.Author} | |
| 99 | } | |
| 100 | if rel.Notes != "" { | |
| 101 | e.Content = &atomContent{Type: "text", Text: rel.Notes} | |
| 102 | } | |
| 103 | entries = append(entries, e) | |
| 104 | } | |
| 105 | s.writeAtom(w, base+"/releases.atom", p.Repo.Path()+" releases", entries) | |
| 106 | } | |
| 107 | ||
| 108 | func (s *Server) logAtom(w http.ResponseWriter, r *http.Request) { | |
| 109 | p, ok := s.repoFor(w, r, r.PathValue("ref")) | |
| 110 | if !ok { | |
| 111 | return | |
| 112 | } | |
| 113 | shas, err := gitutil.RevList(p.Dir, p.Ref, atomLimit) | |
| 114 | if err != nil { | |
| 115 | s.notFound(w, r) | |
| 116 | return | |
| 117 | } | |
| 118 | base := "/" + p.Repo.Path() | |
| 119 | var entries []atomEntry | |
| 120 | for _, sha := range shas { | |
| 121 | e := atomEntry{ID: s.site() + base + "/commit/" + sha, Title: sha[:10], | |
| 122 | Link: atomLink{Rel: "alternate", Href: s.site() + base + "/commit/" + sha}} | |
| 123 | if raw, err := gitutil.ReadCommit(p.Dir, sha); err == nil { | |
| 124 | if c, err := sig.ParseCommit(raw); err == nil { | |
| 125 | e.Title = c.Subject | |
| 126 | e.Updated = time.Unix(c.AuthorUnix, 0).UTC().Format(time.RFC3339) | |
| 127 | e.Author = &atomAuthor{Name: c.AuthorName} | |
| 128 | } | |
| 129 | } | |
| 130 | entries = append(entries, e) | |
| 131 | } | |
| 132 | path := base + "/log.atom" | |
| 133 | if r.PathValue("ref") != "" { | |
| 134 | path += "/" + p.Ref | |
| 135 | } | |
| 136 | s.writeAtom(w, path, p.Repo.Path()+" commits on "+p.Ref, entries) | |
| 137 | } | |
| 138 | ||
| 139 | // ownerAtom is an owner's activity on their public repositories, the | |
| 140 | // feed command's rows without a viewer. | |
| 141 | func (s *Server) ownerAtom(w http.ResponseWriter, r *http.Request) { | |
| 142 | name := r.PathValue("owner") | |
| 143 | kind, id := "", int64(0) | |
| 144 | if u, err := s.st.UserByUsername(name); err == nil { | |
| 145 | kind, id = "user", u.ID | |
| 146 | } else if o, err := s.st.OrgByName(name); err == nil { | |
| 147 | kind, id = "org", o.ID | |
| 148 | } else { | |
| 149 | s.notFound(w, r) | |
| 150 | return | |
| 151 | } | |
| 152 | events, err := s.st.OwnerPublicEvents(kind, id, atomLimit) | |
| 153 | if err != nil { | |
| 154 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 155 | return | |
| 156 | } | |
| 157 | var entries []atomEntry | |
| 158 | for _, ev := range events { | |
| 159 | title, link := eventTitle(ev) | |
| 160 | e := atomEntry{ID: fmt.Sprintf("%s/%s/activity.atom#%d", s.site(), name, ev.ID), Title: title, Updated: ev.CreatedAt, | |
| 161 | Link: atomLink{Rel: "alternate", Href: s.site() + link}} | |
| 162 | if ev.Actor != "" { | |
| 163 | e.Author = &atomAuthor{Name: ev.Actor} | |
| 164 | } | |
| 165 | entries = append(entries, e) | |
| 166 | } | |
| 167 | s.writeAtom(w, "/"+name+"/activity.atom", name+" activity", entries) | |
| 168 | } | |
| 169 | ||
| 170 | // eventTitle names an event and where it points, from the kind and the | |
| 171 | // number or tag its data carries. | |
| 172 | func eventTitle(ev store.FeedEvent) (title, link string) { | |
| 173 | var data struct { | |
| 174 | Number int64 `json:"number"` | |
| 175 | Tag string `json:"tag"` | |
| 176 | } | |
| 177 | json.Unmarshal([]byte(ev.Data), &data) | |
| 178 | link = "/" + ev.RepoPath | |
| 179 | title = ev.Kind + " in " + ev.RepoPath | |
| 180 | switch { | |
| 181 | case strings.HasPrefix(ev.Kind, "issue.") && data.Number > 0: | |
| 182 | link += fmt.Sprintf("/issues/%d", data.Number) | |
| 183 | title = fmt.Sprintf("%s %s#%d", ev.Kind, ev.RepoPath, data.Number) | |
| 184 | case strings.HasPrefix(ev.Kind, "mr.") && data.Number > 0: | |
| 185 | link += fmt.Sprintf("/mrs/%d", data.Number) | |
| 186 | title = fmt.Sprintf("%s %s!%d", ev.Kind, ev.RepoPath, data.Number) | |
| 187 | case strings.HasPrefix(ev.Kind, "release.") && data.Tag != "": | |
| 188 | link += "/releases#" + data.Tag | |
| 189 | title = fmt.Sprintf("%s %s %s", ev.Kind, ev.RepoPath, data.Tag) | |
| 190 | } | |
| 191 | if ev.Actor != "" { | |
| 192 | title = ev.Actor + ": " + title | |
| 193 | } | |
| 194 | return title, link | |
| 195 | } | |
internal/httpd/atom_test.go added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/xml" | |
| 5 | "net/http/httptest" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/config" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | func TestWriteAtom(t *testing.T) { | |
| 14 | s := &Server{cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test/"}}} | |
| 15 | rec := httptest.NewRecorder() | |
| 16 | s.writeAtom(rec, "/alice/app/releases.atom", "alice/app releases", []atomEntry{ | |
| 17 | {ID: "https://forge.test/alice/app/releases#v1", Title: "v1: <first>", Updated: "2026-09-07T10:00:00Z", | |
| 18 | Author: &atomAuthor{Name: "alice"}, Link: atomLink{Rel: "alternate", Href: "https://forge.test/alice/app/releases#v1"}, | |
| 19 | Content: &atomContent{Type: "text", Text: "notes & more"}}, | |
| 20 | }) | |
| 21 | if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/atom+xml") { | |
| 22 | t.Fatalf("content type %q", ct) | |
| 23 | } | |
| 24 | var got atomFeed | |
| 25 | if err := xml.Unmarshal(rec.Body.Bytes(), &got); err != nil { | |
| 26 | t.Fatalf("not XML: %v\n%s", err, rec.Body.String()) | |
| 27 | } | |
| 28 | if got.ID != "https://forge.test/alice/app/releases.atom" || got.Updated != "2026-09-07T10:00:00Z" || len(got.Entries) != 1 { | |
| 29 | t.Fatalf("feed: %+v", got) | |
| 30 | } | |
| 31 | if e := got.Entries[0]; e.Title != "v1: <first>" || e.Content.Text != "notes & more" || e.Author.Name != "alice" { | |
| 32 | t.Fatalf("entry: %+v", e) | |
| 33 | } | |
| 34 | alt := "" | |
| 35 | for _, l := range got.Links { | |
| 36 | if l.Rel == "alternate" { | |
| 37 | alt = l.Href | |
| 38 | } | |
| 39 | } | |
| 40 | if alt != "https://forge.test/alice/app/releases" { | |
| 41 | t.Fatalf("alternate link %q", alt) | |
| 42 | } | |
| 43 | ||
| 44 | // No entries: still a feed, updated now. | |
| 45 | rec = httptest.NewRecorder() | |
| 46 | s.writeAtom(rec, "/alice/activity.atom", "alice activity", nil) | |
| 47 | if err := xml.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Updated == "" { | |
| 48 | t.Fatalf("empty feed: %v %+v", err, got) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | func TestEventTitle(t *testing.T) { | |
| 53 | cases := []struct { | |
| 54 | ev store.FeedEvent | |
| 55 | title, link string | |
| 56 | }{ | |
| 57 | {store.FeedEvent{RepoPath: "alice/app", Actor: "bob", Kind: "issue.created", Data: `{"number":3}`}, | |
| 58 | "bob: issue.created alice/app#3", "/alice/app/issues/3"}, | |
| 59 | {store.FeedEvent{RepoPath: "alice/app", Actor: "bob", Kind: "mr.merged", Data: `{"number":7}`}, | |
| 60 | "bob: mr.merged alice/app!7", "/alice/app/mrs/7"}, | |
| 61 | {store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "release.created", Data: `{"tag":"v1.0"}`}, | |
| 62 | "alice: release.created alice/app v1.0", "/alice/app/releases#v1.0"}, | |
| 63 | {store.FeedEvent{RepoPath: "alice/app", Kind: "repo.imported", Data: `{"from":"x"}`}, | |
| 64 | "repo.imported in alice/app", "/alice/app"}, | |
| 65 | } | |
| 66 | for _, tc := range cases { | |
| 67 | title, link := eventTitle(tc.ev) | |
| 68 | if title != tc.title || link != tc.link { | |
| 69 | t.Errorf("%s: got %q %q, want %q %q", tc.ev.Kind, title, link, tc.title, tc.link) | |
| 70 | } | |
| 71 | } | |
| 72 | } | |
internal/httpd/routes.go +4
| @@ -67,6 +67,10 @@ func (s *Server) Routes() []Route { | ||
| 67 | 67 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw}, |
| 68 | 68 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki}, |
| 69 | 69 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, |
| 70 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases.atom", Handler: s.releasesAtom}, | |
| 71 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, | |
| 72 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom}, | |
| 73 | Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom}, | |
| 70 | 74 | Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, |
| 71 | 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, |
| 72 | 76 | Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG}, |
internal/httpd/web.go +5 −1
| @@ -253,6 +253,7 @@ type repoPage struct { | ||
| 253 | 253 | Host string |
| 254 | 254 | Mirrors []mirrorLine // repo admins only |
| 255 | 255 | CanAdmin bool // gates the settings tab |
| 256 | Feed string // Atom feed for this page, if it has one | |
| 256 | 257 | // OpenIssues and OpenMRs are the counts on the header tabs. |
| 257 | 258 | OpenIssues int |
| 258 | 259 | OpenMRs int |
| @@ -445,11 +446,12 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 445 | 446 | CanAdmin bool |
| 446 | 447 | Self bool |
| 447 | 448 | Notice string |
| 449 | Feed string | |
| 448 | 450 | }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), |
| 449 | 451 | d.Repos, d.Members, d.Orgs, |
| 450 | 452 | weeks, activityTotal, teams, canAdmin, |
| 451 | 453 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), |
| 452 | s.takeFlash(w, r)}) | |
| 454 | s.takeFlash(w, r), "/" + name + "/activity.atom"}) | |
| 453 | 455 | } |
| 454 | 456 | |
| 455 | 457 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { |
| @@ -613,6 +615,7 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) { | ||
| 613 | 615 | return |
| 614 | 616 | } |
| 615 | 617 | p.Tab = "releases" |
| 618 | p.Feed = "/" + p.Repo.Path() + "/releases.atom" | |
| 616 | 619 | rels, err := s.st.ListReleases(p.Repo.ID) |
| 617 | 620 | if err != nil { |
| 618 | 621 | http.Error(w, "internal error", http.StatusInternalServerError) |
| @@ -1406,6 +1409,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { | ||
| 1406 | 1409 | return |
| 1407 | 1410 | } |
| 1408 | 1411 | p.Tab = "log" |
| 1412 | p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref | |
| 1409 | 1413 | const pageSize = 50 |
| 1410 | 1414 | // ?path= filters to commits touching one file or directory. |
| 1411 | 1415 | filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/") |
internal/policy/names.go +6
| @@ -4,6 +4,7 @@ package policy | ||
| 4 | 4 | import ( |
| 5 | 5 | "fmt" |
| 6 | 6 | "regexp" |
| 7 | "strings" | |
| 7 | 8 | ) |
| 8 | 9 | |
| 9 | 10 | // reservedNames are forbidden as usernames and org names because they are, or |
| @@ -60,6 +61,11 @@ func ValidateName(name string) error { | ||
| 60 | 61 | if len(name) > 4 && name[len(name)-4:] == ".git" { |
| 61 | 62 | return fmt.Errorf("invalid name %q: must not end in .git", name) |
| 62 | 63 | } |
| 64 | // /{owner}/activity.atom is the owner's feed; a repository by that | |
| 65 | // name would be unreachable. | |
| 66 | if strings.HasSuffix(name, ".atom") { | |
| 67 | return fmt.Errorf("invalid name %q: must not end in .atom", name) | |
| 68 | } | |
| 63 | 69 | return nil |
| 64 | 70 | } |
| 65 | 71 | |
internal/policy/names_test.go +3
| @@ -39,4 +39,7 @@ func TestRepoNameAllowsReservedWords(t *testing.T) { | ||
| 39 | 39 | if err := ValidateName("repo.git"); err == nil { |
| 40 | 40 | t.Error("ValidateName(\"repo.git\") = nil, want error") |
| 41 | 41 | } |
| 42 | if err := ValidateName("activity.atom"); err == nil { | |
| 43 | t.Error("ValidateName(\"activity.atom\") = nil, want error") | |
| 44 | } | |
| 42 | 45 | } |
internal/store/dashboard.go +29
| @@ -294,6 +294,35 @@ type FeedEvent struct { | ||
| 294 | 294 | CreatedAt string |
| 295 | 295 | } |
| 296 | 296 | |
| 297 | // OwnerPublicEvents returns activity on an owner's public repositories, | |
| 298 | // newest first, for readers carrying no session. Push events are | |
| 299 | // excluded as in RecentEvents. | |
| 300 | func (s *Store) OwnerPublicEvents(ownerKind string, ownerID int64, limit int) ([]FeedEvent, error) { | |
| 301 | rows, err := s.DB.Query(` | |
| 302 | SELECT e.id, COALESCE(u.username, o.name) || '/' || r.name, | |
| 303 | COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at | |
| 304 | FROM events e | |
| 305 | JOIN repos r ON r.id = e.repo_id | |
| 306 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 307 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 308 | LEFT JOIN users ac ON ac.id = e.actor_id | |
| 309 | WHERE e.kind <> 'push' AND r.visibility = 'public' AND r.owner_kind = ? AND r.owner_id = ? | |
| 310 | ORDER BY e.id DESC LIMIT ?`, ownerKind, ownerID, limit) | |
| 311 | if err != nil { | |
| 312 | return nil, err | |
| 313 | } | |
| 314 | defer rows.Close() | |
| 315 | var out []FeedEvent | |
| 316 | for rows.Next() { | |
| 317 | var e FeedEvent | |
| 318 | if err := rows.Scan(&e.ID, &e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil { | |
| 319 | return nil, err | |
| 320 | } | |
| 321 | out = append(out, e) | |
| 322 | } | |
| 323 | return out, rows.Err() | |
| 324 | } | |
| 325 | ||
| 297 | 326 | // RecentEvents returns activity on repositories the user can reach. Push |
| 298 | 327 | // events are excluded: they repeat what the commit lists already show. |
| 299 | 328 | // before (an event id) starts the page strictly below it, matching the |
internal/web/templates/layout.html +2 −1
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | <title>{{template "title" .}}</title> |
| 7 | 7 | <link rel="stylesheet" href="/static/style.css"> |
| 8 | 8 | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> |
| 9 | </head> | |
| 9 | {{with field . "Feed"}}<link rel="alternate" type="application/atom+xml" href="{{.}}"> | |
| 10 | {{end}}</head> | |
| 10 | 11 | <body> |
| 11 | 12 | <a class="skip" href="#content">Skip to content</a> |
| 12 | 13 | <div class="shell"> |