detached eab40ee

feat(web): releases tab, release pages and stable asset download links

commit
eab40eefd5b5ee4f7bffc1d275a1775e2907144e
author
brice · · 2026-10-21 15:07 UTC
committer
brice · · 2026-10-21 17:53 UTC
parent
bfabb0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +1206 -2
M internal/adapters/web/fragments.go +8 -2
@@ -62,7 +62,8 @@ func liveEvent(we ports.WatchEvent) bool {
6262 return true
6363 }
6464 switch we.Event.(type) {
65- case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
65+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished,
66+ domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6667 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6768 domain.RunUpdated, domain.JobUpdated,
6869 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
@@ -164,6 +165,11 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
164165 // repoPage handles the events of the topic of one repository.
165166 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
166167 switch ev := we.Event.(type) {
168+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
169+ if l.view == "releases" || l.view == "release" {
170+ return gap, nil // notes, assets, latest or visibility changed: reload
171+ }
172+ return nil, nil
167173 case domain.RunUpdated:
168174 return l.runEvent(ctx, ev)
169175 case domain.JobUpdated:
@@ -179,7 +185,7 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
179185 return gap, nil // no longer visible
180186 case err != nil:
181187 return nil, err
182- case l.view == "summary", l.view == "refs":
188+ case l.view == "summary", l.view == "refs", l.view == "releases", l.view == "release":
183189 return gap, nil // the description, the default branch
184190 }
185191 return nil, nil
@@ -62,7 +62,8 @@ func liveEvent(we ports.WatchEvent) bool {
62 return true62 return true
63 }63 }
64 switch we.Event.(type) {64 switch we.Event.(type) {
65- case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,65+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished,
66+ domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
66 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,67 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
67 domain.RunUpdated, domain.JobUpdated,68 domain.RunUpdated, domain.JobUpdated,
68 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:69 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
@@ -164,6 +165,11 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
164 // repoPage handles the events of the topic of one repository.165 // repoPage handles the events of the topic of one repository.
165 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {166 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
166 switch ev := we.Event.(type) {167 switch ev := we.Event.(type) {
168+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
169+ if l.view == "releases" || l.view == "release" {
170+ return gap, nil // notes, assets, latest or visibility changed: reload
171+ }
172+ return nil, nil
167 case domain.RunUpdated:173 case domain.RunUpdated:
168 return l.runEvent(ctx, ev)174 return l.runEvent(ctx, ev)
169 case domain.JobUpdated:175 case domain.JobUpdated:
@@ -179,7 +185,7 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
179 return gap, nil // no longer visible185 return gap, nil // no longer visible
180 case err != nil:186 case err != nil:
181 return nil, err187 return nil, err
182- case l.view == "summary", l.view == "refs":188+ case l.view == "summary", l.view == "refs", l.view == "releases", l.view == "release":
183 return gap, nil // the description, the default branch189 return gap, nil // the description, the default branch
184 }190 }
185 return nil, nil191 return nil, nil
M internal/adapters/web/fragments_test.go +20 -0
@@ -8,6 +8,7 @@ import (
88 "testing"
99
1010 "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
1112
1213 "github.com/a-h/templ"
1314 "github.com/stretchr/testify/assert"
@@ -99,3 +100,22 @@ func TestMRPushedHeadSignal(t *testing.T) {
99100 }
100101 }
101102 }
103+
104+// Release lifecycle events invalidate the release pages without putting notes
105+// or asset metadata in SSE payloads. Other views need no release fragments.
106+func TestReleaseLiveUpdates(t *testing.T) {
107+ for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108+ we := ports.WatchEvent{Event: ev}
109+ require.True(t, liveEvent(we), ev.EventKind())
110+ for _, view := range []string{"releases", "release", "summary", "refs", "mr"} {
111+ st := &liveStream{repo: "proj", view: view}
112+ got, err := st.render(t.Context(), we)
113+ require.NoError(t, err)
114+ if view == "release" || view == "releases" {
115+ assert.Equal(t, gap, got)
116+ } else {
117+ assert.Empty(t, got)
118+ }
119+ }
120+ }
121+}
@@ -8,6 +8,7 @@ import (
8 "testing"8 "testing"
9 9
10 "bnema.dev/ermite/internal/domain"10 "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
11 12
12 "github.com/a-h/templ"13 "github.com/a-h/templ"
13 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
@@ -99,3 +100,22 @@ func TestMRPushedHeadSignal(t *testing.T) {
99 }100 }
100 }101 }
101 }102 }
103+
104+// Release lifecycle events invalidate the release pages without putting notes
105+// or asset metadata in SSE payloads. Other views need no release fragments.
106+func TestReleaseLiveUpdates(t *testing.T) {
107+ for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108+ we := ports.WatchEvent{Event: ev}
109+ require.True(t, liveEvent(we), ev.EventKind())
110+ for _, view := range []string{"releases", "release", "summary", "refs", "mr"} {
111+ st := &liveStream{repo: "proj", view: view}
112+ got, err := st.render(t.Context(), we)
113+ require.NoError(t, err)
114+ if view == "release" || view == "releases" {
115+ assert.Equal(t, gap, got)
116+ } else {
117+ assert.Empty(t, got)
118+ }
119+ }
120+ }
121+}
A internal/adapters/web/releases.go +283 -0
@@ -0,0 +1,283 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+func releasesBase(repo string) string { return repoHref(repo, "releases") }
23+func releaseHref(repo, tag string) string { return repoHref(repo, "releases", "tag", tag) }
24+func releaseAssetHref(repo string, id int64) string {
25+ return releasesBase(repo) + "/assets/" + strconv.FormatInt(id, 10)
26+}
27+
28+// ReleaseRow describes a release without mistaking MakeLatest (a pin) for
29+// the latest release selected by the service.
30+type ReleaseRow struct {
31+ Tag, Title, Href, Published string
32+ Draft, Prerelease, Latest bool
33+ AssetCount int
34+}
35+type ReleaseListPage struct {
36+ Rows []ReleaseRow
37+ Older string
38+}
39+type ReleaseAssetRow struct{ Name, Href, Size, ContentType string }
40+type ReleaseDetailPage struct {
41+ ReleaseRow
42+ Notes template.HTML
43+ SHA, CommitHref, SourceHref string
44+ Assets []ReleaseAssetRow
45+}
46+
47+func (s *Server) latestReleaseID(ctx context.Context, repo domain.RepoName) (int64, error) {
48+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
49+ if errors.Is(err, domain.ErrNotFound) {
50+ return 0, nil
51+ }
52+ return rel.ID, err
53+}
54+func releaseRow(repo string, rel domain.Release, latest int64) ReleaseRow {
55+ published := "draft"
56+ if !rel.Draft && !rel.PublishedAt.IsZero() {
57+ published = rel.PublishedAt.UTC().Format("2006-01-02")
58+ }
59+ title := rel.Title
60+ if title == "" {
61+ title = rel.Tag
62+ }
63+ return ReleaseRow{Tag: rel.Tag, Title: title, Href: releaseHref(repo, rel.Tag), Published: published, Draft: rel.Draft, Prerelease: rel.Prerelease, Latest: rel.ID == latest}
64+}
65+func (s *Server) releaseList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
66+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
67+ if err != nil {
68+ return nil, 0, err
69+ }
70+ page, err := s.cfg.Releases.ListReleases(ctx, principal(ctx), d.repo.Name, 30, r.URL.Query().Get("page"))
71+ if err != nil {
72+ return nil, 0, err
73+ }
74+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
75+ if err != nil {
76+ return nil, 0, err
77+ }
78+ p := ReleaseListPage{}
79+ for _, rel := range page.Releases {
80+ row := releaseRow(d.name(), rel, latest)
81+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
82+ if err != nil {
83+ return nil, 0, err
84+ }
85+ row.AssetCount = len(assets)
86+ p.Rows = append(p.Rows, row)
87+ }
88+ if page.NextPageToken != "" {
89+ p.Older = releasesBase(d.name()) + "?page=" + url.QueryEscape(page.NextPageToken)
90+ }
91+ l := s.repoLayout(ctx, d, "releases")
92+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=releases")
93+ l.Title = "Releases · " + d.name()
94+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Current: true})
95+ return Releases(l, p), http.StatusOK, nil
96+}
97+func (s *Server) releaseShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
98+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
99+ if err != nil {
100+ return nil, 0, err
101+ }
102+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), d.repo.Name, ports.ReleaseRef{Tag: r.PathValue("tag")})
103+ if err != nil {
104+ return nil, 0, err
105+ }
106+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
107+ if err != nil {
108+ return nil, 0, err
109+ }
110+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
111+ if err != nil {
112+ return nil, 0, err
113+ }
114+ notes, _, err := s.md.render([]byte(rel.Notes), d.name(), rel.Tag, "")
115+ if err != nil {
116+ return nil, 0, err
117+ }
118+ p := ReleaseDetailPage{ReleaseRow: releaseRow(d.name(), rel, latest), Notes: notes, SourceHref: repoHref(d.name(), "tree", rel.Tag)}
119+ if rel.TargetCommitish != "" {
120+ p.SHA = short(rel.TargetCommitish)
121+ p.CommitHref = repoHref(d.name(), "commit", rel.TargetCommitish)
122+ }
123+ for _, a := range assets {
124+ p.Assets = append(p.Assets, ReleaseAssetRow{Name: a.Name, Href: releaseAssetHref(d.name(), a.ID), Size: humanSize(a.Size), ContentType: a.ContentType})
125+ }
126+ l := s.repoLayout(ctx, d, "releases")
127+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=release")
128+ l.Title = rel.Tag + " · " + d.name()
129+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Href: releasesBase(d.name())}, Crumb{Label: rel.Tag, Current: true})
130+ return ReleaseDetail(l, p), http.StatusOK, nil
131+}
132+func (s *Server) releaseLatest(w http.ResponseWriter, r *http.Request) {
133+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
134+ defer cancel()
135+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
136+ if err != nil {
137+ s.fail(ctx, w, r, err)
138+ return
139+ }
140+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
141+ if err != nil {
142+ s.fail(ctx, w, r, err)
143+ return
144+ }
145+ cacheHeaders(ctx, w.Header(), "no-cache", sessionCache)
146+ http.Redirect(w, r, releaseHref(repo.String(), rel.Tag), http.StatusSeeOther)
147+}
148+
149+var errReleaseRange = errors.New("web: unsatisfiable byte range")
150+
151+// releaseRange accepts a single byte range, including open-ended and suffix
152+// ranges. Multiple ranges are intentionally ignored (ADR 0034).
153+func releaseRange(value string, size int64) (offset, length int64, partial bool, err error) {
154+ if value == "" || strings.Contains(value, ",") || !strings.HasPrefix(value, "bytes=") {
155+ return 0, size, false, nil
156+ }
157+ a, b, ok := strings.Cut(strings.TrimPrefix(value, "bytes="), "-")
158+ if !ok || size == 0 {
159+ return 0, 0, false, errReleaseRange
160+ }
161+ if a == "" {
162+ n, e := strconv.ParseInt(b, 10, 64)
163+ if e != nil || n <= 0 {
164+ return 0, 0, false, errReleaseRange
165+ }
166+ n = min(n, size)
167+ return size - n, n, true, nil
168+ }
169+ start, e := strconv.ParseInt(a, 10, 64)
170+ if e != nil || start < 0 || start >= size {
171+ return 0, 0, false, errReleaseRange
172+ }
173+ end := size - 1
174+ if b != "" {
175+ end, e = strconv.ParseInt(b, 10, 64)
176+ if e != nil || end < start {
177+ return 0, 0, false, errReleaseRange
178+ }
179+ end = min(end, size-1)
180+ }
181+ return start, end - start + 1, true, nil
182+}
183+func releaseHeaders(h http.Header, a domain.ReleaseAsset) {
184+ // Reuse the artifact sandbox and attachment policy; never trust an
185+ // uploader's media type to execute content in the forge's origin.
186+ typ := "application/octet-stream"
187+ if a.ContentType == domain.CIArtifactZip {
188+ typ = domain.CIArtifactZip
189+ }
190+ h.Set("Content-Type", typ)
191+ h.Set("Content-Disposition", contentDisposition(attachmentName(a.Name)))
192+ h.Set("X-Content-Type-Options", "nosniff")
193+ h.Set("Content-Security-Policy", rawCSP)
194+ h.Set("Content-Length", strconv.FormatInt(a.Size, 10))
195+ h.Set("ETag", `"`+strings.Replace(a.Digest, "sha256:", "sha256-", 1)+`"`)
196+ h.Set("Accept-Ranges", "bytes")
197+}
198+func (s *Server) releaseDownload(w http.ResponseWriter, r *http.Request) {
199+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Minute)
200+ defer cancel()
201+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
202+ if err != nil {
203+ s.fail(ctx, w, r, errNotFound)
204+ return
205+ }
206+ ref := ports.AssetRef{Tag: r.PathValue("tag"), Name: r.PathValue("asset")}
207+ if id := r.PathValue("id"); id != "" {
208+ ref.ID, err = strconv.ParseInt(id, 10, 64)
209+ if err != nil || ref.ID < 1 {
210+ s.fail(ctx, w, r, errNotFound)
211+ return
212+ }
213+ } else if ref.Tag == "" {
214+ ref.Latest = true
215+ }
216+ a, err := s.cfg.Releases.GetAsset(ctx, principal(ctx), repo, ref)
217+ if err != nil {
218+ s.fail(ctx, w, r, errNotFound)
219+ return
220+ }
221+ // Resolve latest/tag only once: a moving latest cannot select another
222+ // asset between reading the headers and streaming the bytes.
223+ ref = ports.AssetRef{ID: a.ID}
224+ h := w.Header()
225+ releaseHeaders(h, a)
226+ cacheHeaders(ctx, h, "no-cache", sessionCache)
227+ offset, length, partial, err := releaseRange(strings.Join(r.Header.Values("Range"), ","), a.Size)
228+ if r.Method == http.MethodHead {
229+ offset, length, partial, err = 0, a.Size, false, nil
230+ }
231+ if r.Header.Get("If-Range") != "" && r.Header.Get("If-Range") != h.Get("ETag") {
232+ offset, length, partial, err = 0, a.Size, false, nil
233+ }
234+ if err != nil {
235+ h.Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
236+ h.Set("Content-Length", "0")
237+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
238+ return
239+ }
240+ status := http.StatusOK
241+ if partial {
242+ status = http.StatusPartialContent
243+ h.Set("Content-Range", "bytes "+strconv.FormatInt(offset, 10)+"-"+strconv.FormatInt(offset+length-1, 10)+"/"+strconv.FormatInt(a.Size, 10))
244+ }
245+ h.Set("Content-Length", strconv.FormatInt(length, 10))
246+ if r.Method == http.MethodHead || a.Size == 0 {
247+ w.WriteHeader(status)
248+ return
249+ }
250+ started := false
251+ rc := http.NewResponseController(w)
252+ defer func() { _ = rc.SetWriteDeadline(time.Time{}) }()
253+ err = s.cfg.Releases.DownloadAsset(ctx, principal(ctx), repo, ref, offset, length, func(c ports.AssetChunk) error {
254+ if !started {
255+ if c.Asset == nil || c.Asset.ID != a.ID || c.Asset.Digest != a.Digest || c.Asset.Size != a.Size {
256+ return errors.New("web: release asset changed before download")
257+ }
258+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
259+ w.WriteHeader(status)
260+ started = true
261+ }
262+ if len(c.Data) == 0 {
263+ return nil
264+ }
265+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
266+ _, err := w.Write(c.Data)
267+ return err
268+ })
269+ if err == nil {
270+ return
271+ }
272+ if !started {
273+ // Do not leave binary headers or range metadata on the HTML error page.
274+ for _, key := range []string{"Content-Length", "Content-Disposition", "Content-Range", "Content-Security-Policy", "ETag", "Accept-Ranges"} {
275+ h.Del(key)
276+ }
277+ s.fail(ctx, w, r, errNotFound)
278+ return
279+ }
280+ log := zerowrap.FromCtx(ctx)
281+ log.Error().Err(err).Str("repo", repo.String()).Int64("asset", a.ID).Msg("release download failed")
282+ panic(http.ErrAbortHandler)
283+}
@@ -0,0 +1,283 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+func releasesBase(repo string) string { return repoHref(repo, "releases") }
23+func releaseHref(repo, tag string) string { return repoHref(repo, "releases", "tag", tag) }
24+func releaseAssetHref(repo string, id int64) string {
25+ return releasesBase(repo) + "/assets/" + strconv.FormatInt(id, 10)
26+}
27+
28+// ReleaseRow describes a release without mistaking MakeLatest (a pin) for
29+// the latest release selected by the service.
30+type ReleaseRow struct {
31+ Tag, Title, Href, Published string
32+ Draft, Prerelease, Latest bool
33+ AssetCount int
34+}
35+type ReleaseListPage struct {
36+ Rows []ReleaseRow
37+ Older string
38+}
39+type ReleaseAssetRow struct{ Name, Href, Size, ContentType string }
40+type ReleaseDetailPage struct {
41+ ReleaseRow
42+ Notes template.HTML
43+ SHA, CommitHref, SourceHref string
44+ Assets []ReleaseAssetRow
45+}
46+
47+func (s *Server) latestReleaseID(ctx context.Context, repo domain.RepoName) (int64, error) {
48+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
49+ if errors.Is(err, domain.ErrNotFound) {
50+ return 0, nil
51+ }
52+ return rel.ID, err
53+}
54+func releaseRow(repo string, rel domain.Release, latest int64) ReleaseRow {
55+ published := "draft"
56+ if !rel.Draft && !rel.PublishedAt.IsZero() {
57+ published = rel.PublishedAt.UTC().Format("2006-01-02")
58+ }
59+ title := rel.Title
60+ if title == "" {
61+ title = rel.Tag
62+ }
63+ return ReleaseRow{Tag: rel.Tag, Title: title, Href: releaseHref(repo, rel.Tag), Published: published, Draft: rel.Draft, Prerelease: rel.Prerelease, Latest: rel.ID == latest}
64+}
65+func (s *Server) releaseList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
66+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
67+ if err != nil {
68+ return nil, 0, err
69+ }
70+ page, err := s.cfg.Releases.ListReleases(ctx, principal(ctx), d.repo.Name, 30, r.URL.Query().Get("page"))
71+ if err != nil {
72+ return nil, 0, err
73+ }
74+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
75+ if err != nil {
76+ return nil, 0, err
77+ }
78+ p := ReleaseListPage{}
79+ for _, rel := range page.Releases {
80+ row := releaseRow(d.name(), rel, latest)
81+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
82+ if err != nil {
83+ return nil, 0, err
84+ }
85+ row.AssetCount = len(assets)
86+ p.Rows = append(p.Rows, row)
87+ }
88+ if page.NextPageToken != "" {
89+ p.Older = releasesBase(d.name()) + "?page=" + url.QueryEscape(page.NextPageToken)
90+ }
91+ l := s.repoLayout(ctx, d, "releases")
92+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=releases")
93+ l.Title = "Releases · " + d.name()
94+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Current: true})
95+ return Releases(l, p), http.StatusOK, nil
96+}
97+func (s *Server) releaseShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
98+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
99+ if err != nil {
100+ return nil, 0, err
101+ }
102+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), d.repo.Name, ports.ReleaseRef{Tag: r.PathValue("tag")})
103+ if err != nil {
104+ return nil, 0, err
105+ }
106+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
107+ if err != nil {
108+ return nil, 0, err
109+ }
110+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
111+ if err != nil {
112+ return nil, 0, err
113+ }
114+ notes, _, err := s.md.render([]byte(rel.Notes), d.name(), rel.Tag, "")
115+ if err != nil {
116+ return nil, 0, err
117+ }
118+ p := ReleaseDetailPage{ReleaseRow: releaseRow(d.name(), rel, latest), Notes: notes, SourceHref: repoHref(d.name(), "tree", rel.Tag)}
119+ if rel.TargetCommitish != "" {
120+ p.SHA = short(rel.TargetCommitish)
121+ p.CommitHref = repoHref(d.name(), "commit", rel.TargetCommitish)
122+ }
123+ for _, a := range assets {
124+ p.Assets = append(p.Assets, ReleaseAssetRow{Name: a.Name, Href: releaseAssetHref(d.name(), a.ID), Size: humanSize(a.Size), ContentType: a.ContentType})
125+ }
126+ l := s.repoLayout(ctx, d, "releases")
127+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=release")
128+ l.Title = rel.Tag + " · " + d.name()
129+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Href: releasesBase(d.name())}, Crumb{Label: rel.Tag, Current: true})
130+ return ReleaseDetail(l, p), http.StatusOK, nil
131+}
132+func (s *Server) releaseLatest(w http.ResponseWriter, r *http.Request) {
133+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
134+ defer cancel()
135+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
136+ if err != nil {
137+ s.fail(ctx, w, r, err)
138+ return
139+ }
140+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
141+ if err != nil {
142+ s.fail(ctx, w, r, err)
143+ return
144+ }
145+ cacheHeaders(ctx, w.Header(), "no-cache", sessionCache)
146+ http.Redirect(w, r, releaseHref(repo.String(), rel.Tag), http.StatusSeeOther)
147+}
148+
149+var errReleaseRange = errors.New("web: unsatisfiable byte range")
150+
151+// releaseRange accepts a single byte range, including open-ended and suffix
152+// ranges. Multiple ranges are intentionally ignored (ADR 0034).
153+func releaseRange(value string, size int64) (offset, length int64, partial bool, err error) {
154+ if value == "" || strings.Contains(value, ",") || !strings.HasPrefix(value, "bytes=") {
155+ return 0, size, false, nil
156+ }
157+ a, b, ok := strings.Cut(strings.TrimPrefix(value, "bytes="), "-")
158+ if !ok || size == 0 {
159+ return 0, 0, false, errReleaseRange
160+ }
161+ if a == "" {
162+ n, e := strconv.ParseInt(b, 10, 64)
163+ if e != nil || n <= 0 {
164+ return 0, 0, false, errReleaseRange
165+ }
166+ n = min(n, size)
167+ return size - n, n, true, nil
168+ }
169+ start, e := strconv.ParseInt(a, 10, 64)
170+ if e != nil || start < 0 || start >= size {
171+ return 0, 0, false, errReleaseRange
172+ }
173+ end := size - 1
174+ if b != "" {
175+ end, e = strconv.ParseInt(b, 10, 64)
176+ if e != nil || end < start {
177+ return 0, 0, false, errReleaseRange
178+ }
179+ end = min(end, size-1)
180+ }
181+ return start, end - start + 1, true, nil
182+}
183+func releaseHeaders(h http.Header, a domain.ReleaseAsset) {
184+ // Reuse the artifact sandbox and attachment policy; never trust an
185+ // uploader's media type to execute content in the forge's origin.
186+ typ := "application/octet-stream"
187+ if a.ContentType == domain.CIArtifactZip {
188+ typ = domain.CIArtifactZip
189+ }
190+ h.Set("Content-Type", typ)
191+ h.Set("Content-Disposition", contentDisposition(attachmentName(a.Name)))
192+ h.Set("X-Content-Type-Options", "nosniff")
193+ h.Set("Content-Security-Policy", rawCSP)
194+ h.Set("Content-Length", strconv.FormatInt(a.Size, 10))
195+ h.Set("ETag", `"`+strings.Replace(a.Digest, "sha256:", "sha256-", 1)+`"`)
196+ h.Set("Accept-Ranges", "bytes")
197+}
198+func (s *Server) releaseDownload(w http.ResponseWriter, r *http.Request) {
199+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Minute)
200+ defer cancel()
201+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
202+ if err != nil {
203+ s.fail(ctx, w, r, errNotFound)
204+ return
205+ }
206+ ref := ports.AssetRef{Tag: r.PathValue("tag"), Name: r.PathValue("asset")}
207+ if id := r.PathValue("id"); id != "" {
208+ ref.ID, err = strconv.ParseInt(id, 10, 64)
209+ if err != nil || ref.ID < 1 {
210+ s.fail(ctx, w, r, errNotFound)
211+ return
212+ }
213+ } else if ref.Tag == "" {
214+ ref.Latest = true
215+ }
216+ a, err := s.cfg.Releases.GetAsset(ctx, principal(ctx), repo, ref)
217+ if err != nil {
218+ s.fail(ctx, w, r, errNotFound)
219+ return
220+ }
221+ // Resolve latest/tag only once: a moving latest cannot select another
222+ // asset between reading the headers and streaming the bytes.
223+ ref = ports.AssetRef{ID: a.ID}
224+ h := w.Header()
225+ releaseHeaders(h, a)
226+ cacheHeaders(ctx, h, "no-cache", sessionCache)
227+ offset, length, partial, err := releaseRange(strings.Join(r.Header.Values("Range"), ","), a.Size)
228+ if r.Method == http.MethodHead {
229+ offset, length, partial, err = 0, a.Size, false, nil
230+ }
231+ if r.Header.Get("If-Range") != "" && r.Header.Get("If-Range") != h.Get("ETag") {
232+ offset, length, partial, err = 0, a.Size, false, nil
233+ }
234+ if err != nil {
235+ h.Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
236+ h.Set("Content-Length", "0")
237+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
238+ return
239+ }
240+ status := http.StatusOK
241+ if partial {
242+ status = http.StatusPartialContent
243+ h.Set("Content-Range", "bytes "+strconv.FormatInt(offset, 10)+"-"+strconv.FormatInt(offset+length-1, 10)+"/"+strconv.FormatInt(a.Size, 10))
244+ }
245+ h.Set("Content-Length", strconv.FormatInt(length, 10))
246+ if r.Method == http.MethodHead || a.Size == 0 {
247+ w.WriteHeader(status)
248+ return
249+ }
250+ started := false
251+ rc := http.NewResponseController(w)
252+ defer func() { _ = rc.SetWriteDeadline(time.Time{}) }()
253+ err = s.cfg.Releases.DownloadAsset(ctx, principal(ctx), repo, ref, offset, length, func(c ports.AssetChunk) error {
254+ if !started {
255+ if c.Asset == nil || c.Asset.ID != a.ID || c.Asset.Digest != a.Digest || c.Asset.Size != a.Size {
256+ return errors.New("web: release asset changed before download")
257+ }
258+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
259+ w.WriteHeader(status)
260+ started = true
261+ }
262+ if len(c.Data) == 0 {
263+ return nil
264+ }
265+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
266+ _, err := w.Write(c.Data)
267+ return err
268+ })
269+ if err == nil {
270+ return
271+ }
272+ if !started {
273+ // Do not leave binary headers or range metadata on the HTML error page.
274+ for _, key := range []string{"Content-Length", "Content-Disposition", "Content-Range", "Content-Security-Policy", "ETag", "Accept-Ranges"} {
275+ h.Del(key)
276+ }
277+ s.fail(ctx, w, r, errNotFound)
278+ return
279+ }
280+ log := zerowrap.FromCtx(ctx)
281+ log.Error().Err(err).Str("repo", repo.String()).Int64("asset", a.ID).Msg("release download failed")
282+ panic(http.ErrAbortHandler)
283+}
A internal/adapters/web/releases.templ +67 -0
@@ -0,0 +1,67 @@
1+package web
2+
3+// ReleaseBadges are textual as well as visual, including the computed latest.
4+templ ReleaseBadges(r ReleaseRow) {
5+ if r.Draft {
6+ <span class="pill">Draft</span>
7+ }
8+ if r.Prerelease {
9+ <span class="pill">Pre-release</span>
10+ }
11+ if r.Latest {
12+ <span class="pill">Latest</span>
13+ }
14+}
15+
16+templ Releases(l Layout, p ReleaseListPage) {
17+ @Shell(l, nil) {
18+ <h1>Releases</h1>
19+ if len(p.Rows) == 0 {
20+ <p class="dim">no releases yet.</p>
21+ } else {
22+ <table class="list" aria-label="Releases">
23+ <thead><tr><th>tag</th><th>title</th><th>published</th><th>assets</th></tr></thead>
24+ <tbody>
25+ for _, r := range p.Rows {
26+ <tr>
27+ <td><a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a></td>
28+ <td>{ r.Title } @ReleaseBadges(r)</td>
29+ <td>{ r.Published }</td><td>{ itoa(r.AssetCount) }</td>
30+ </tr>
31+ }
32+ </tbody>
33+ </table>
34+ }
35+ if p.Older != "" {
36+ <p class="more"><a href={ templ.SafeURL(p.Older) } rel="next">older releases</a></p>
37+ }
38+ }
39+}
40+
41+templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
42+ @Shell(l, nil) {
43+ <h1>{ p.Title }</h1>
44+ <p>
45+ <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46+ if p.SHA != "" {
47+ <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48+ }
49+ <span class="dim">{ p.Published }</span>
50+ </p>
51+ <div class="md">@templ.Raw(string(p.Notes))</div>
52+ <h2>Assets</h2>
53+ if len(p.Assets) == 0 {
54+ <p class="dim">no assets.</p>
55+ } else {
56+ <table class="list" aria-label="Release assets">
57+ <thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead>
58+ <tbody>
59+ for _, a := range p.Assets {
60+ <tr><td><a href={ templ.SafeURL(a.Href) } download>{ a.Name }</a></td><td>{ a.Size }</td><td>{ a.ContentType }</td></tr>
61+ }
62+ </tbody>
63+ </table>
64+ }
65+ <p><a href={ templ.SafeURL(p.SourceHref) }>Source</a></p>
66+ }
67+}
@@ -0,0 +1,67 @@
1+package web
2+
3+// ReleaseBadges are textual as well as visual, including the computed latest.
4+templ ReleaseBadges(r ReleaseRow) {
5+ if r.Draft {
6+ <span class="pill">Draft</span>
7+ }
8+ if r.Prerelease {
9+ <span class="pill">Pre-release</span>
10+ }
11+ if r.Latest {
12+ <span class="pill">Latest</span>
13+ }
14+}
15+
16+templ Releases(l Layout, p ReleaseListPage) {
17+ @Shell(l, nil) {
18+ <h1>Releases</h1>
19+ if len(p.Rows) == 0 {
20+ <p class="dim">no releases yet.</p>
21+ } else {
22+ <table class="list" aria-label="Releases">
23+ <thead><tr><th>tag</th><th>title</th><th>published</th><th>assets</th></tr></thead>
24+ <tbody>
25+ for _, r := range p.Rows {
26+ <tr>
27+ <td><a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a></td>
28+ <td>{ r.Title } @ReleaseBadges(r)</td>
29+ <td>{ r.Published }</td><td>{ itoa(r.AssetCount) }</td>
30+ </tr>
31+ }
32+ </tbody>
33+ </table>
34+ }
35+ if p.Older != "" {
36+ <p class="more"><a href={ templ.SafeURL(p.Older) } rel="next">older releases</a></p>
37+ }
38+ }
39+}
40+
41+templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
42+ @Shell(l, nil) {
43+ <h1>{ p.Title }</h1>
44+ <p>
45+ <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46+ if p.SHA != "" {
47+ <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48+ }
49+ <span class="dim">{ p.Published }</span>
50+ </p>
51+ <div class="md">@templ.Raw(string(p.Notes))</div>
52+ <h2>Assets</h2>
53+ if len(p.Assets) == 0 {
54+ <p class="dim">no assets.</p>
55+ } else {
56+ <table class="list" aria-label="Release assets">
57+ <thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead>
58+ <tbody>
59+ for _, a := range p.Assets {
60+ <tr><td><a href={ templ.SafeURL(a.Href) } download>{ a.Name }</a></td><td>{ a.Size }</td><td>{ a.ContentType }</td></tr>
61+ }
62+ </tbody>
63+ </table>
64+ }
65+ <p><a href={ templ.SafeURL(p.SourceHref) }>Source</a></p>
66+ }
67+}
A internal/adapters/web/releases_templ.go +439 -0 generated file: collapsed
A internal/adapters/web/releases_test.go +272 -0
@@ -0,0 +1,272 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "mime"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+type releaseFixture struct {
25+ srv *Server
26+ svc *mocks.ReleaseService
27+ published, draft domain.Release
28+ asset domain.ReleaseAsset
29+ data string
30+ downloadErr error
31+ failAfterMetadata bool
32+ downloads int
33+ tokens []string
34+}
35+
36+func newReleaseFixture(t *testing.T) *releaseFixture {
37+ t.Helper()
38+ repo := repoName(t, "proj")
39+ f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
40+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
41+ f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
42+ f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
43+ f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
44+ if ref.Latest || ref.Tag == f.published.Tag {
45+ return f.published, nil
46+ }
47+ if ref.Tag == f.draft.Tag && p.IsOwner() {
48+ return f.draft, nil
49+ }
50+ return domain.Release{}, domain.ErrNotFound
51+ }).Maybe()
52+ f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) {
53+ f.tokens = append(f.tokens, token)
54+ rows := []domain.Release{f.published}
55+ if p.IsOwner() {
56+ rows = append([]domain.Release{f.draft}, rows...)
57+ }
58+ return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil
59+ }).Maybe()
60+ f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
61+ if id == f.published.ID {
62+ return []domain.ReleaseAsset{f.asset}, nil
63+ }
64+ if id == f.draft.ID && p.IsOwner() {
65+ return nil, nil
66+ }
67+ return nil, domain.ErrNotFound
68+ }).Maybe()
69+ f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
70+ if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) {
71+ return f.asset, nil
72+ }
73+ return domain.ReleaseAsset{}, domain.ErrNotFound
74+ }).Maybe()
75+ f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
76+ f.downloads++
77+ deadline, ok := ctx.Deadline()
78+ require.True(t, ok)
79+ assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2)
80+ if f.downloadErr != nil && !f.failAfterMetadata {
81+ return f.downloadErr
82+ }
83+ if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil {
84+ return err
85+ }
86+ if f.downloadErr != nil {
87+ return f.downloadErr
88+ }
89+ return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])})
90+ }).Maybe()
91+ browse := mocks.NewBrowseService(t)
92+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
93+ f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }})
94+ return f
95+}
96+func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder {
97+ r := httptest.NewRequest(method, path, nil)
98+ if cookie != "" {
99+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
100+ }
101+ if byteRange != "" {
102+ r.Header.Set("Range", byteRange)
103+ }
104+ w := httptest.NewRecorder()
105+ f.srv.ServeHTTP(w, r)
106+ return w
107+}
108+func TestReleasePages(t *testing.T) {
109+ f := newReleaseFixture(t)
110+ for _, path := range []string{"/proj/releases", "/proj/releases/"} {
111+ w := f.request("GET", path, "", "")
112+ require.Equal(t, 200, w.Code)
113+ body := w.Body.String()
114+ assert.Contains(t, body, "First release")
115+ assert.NotContains(t, body, "Secret draft")
116+ assert.Contains(t, body, ">Latest</span>")
117+ assert.Contains(t, body, ">Pre-release</span>")
118+ assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`)
119+ assert.Contains(t, body, "2026-10-12")
120+ assert.Contains(t, body, `<td>1</td>`)
121+ assert.Contains(t, body, `?page=next%2B%2F%3D`)
122+ assert.Contains(t, body, `aria-keyshortcuts="l"`)
123+ assert.Contains(t, body, "view=releases")
124+ }
125+ owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
126+ require.Equal(t, 200, owner.Code)
127+ assert.Contains(t, owner.Body.String(), "Secret draft")
128+ assert.Contains(t, owner.Body.String(), ">Draft</span>")
129+ assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store")
130+ assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens)
131+ // The pin on the draft must not become a computed latest badge.
132+ assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>"))
133+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
134+ require.Equal(t, 200, w.Code)
135+ body := w.Body.String()
136+ for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
137+ assert.Contains(t, body, text)
138+ }
139+ assert.NotContains(t, body, "<script>alert")
140+ assert.NotContains(t, body, `href="javascript:`)
141+ assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
142+ for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} {
143+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
144+ }
145+ assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code)
146+ latest := f.request("GET", "/proj/releases/latest", "", "")
147+ assert.Equal(t, 303, latest.Code)
148+ assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location"))
149+}
150+func TestReleaseDownloads(t *testing.T) {
151+ f := newReleaseFixture(t)
152+ // A simple tag uses the stable URL; slash tags use the page's asset-id link.
153+ f.published.Tag = "v1"
154+ for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} {
155+ for _, tc := range []struct {
156+ rangeValue, body, cr string
157+ status int
158+ }{
159+ {"", "0123456789", "", 200}, {"bytes=2-5", "2345", "bytes 2-5/10", 206}, {"bytes=7-", "789", "bytes 7-9/10", 206}, {"bytes=-3", "789", "bytes 7-9/10", 206}, {"bytes=8-99", "89", "bytes 8-9/10", 206}, {"bytes=0-0,2-3", "0123456789", "", 200}, {"bytes=10-", "", "bytes */10", 416},
160+ } {
161+ w := f.request("GET", path, "", tc.rangeValue)
162+ require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue)
163+ assert.Equal(t, tc.body, w.Body.String())
164+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"))
165+ assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
166+ assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag"))
167+ assert.Equal(t, "application/zip", w.Header().Get("Content-Type"))
168+ assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
169+ assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy"))
170+ _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
171+ require.NoError(t, err)
172+ assert.Equal(t, "build.zip", params["filename"])
173+ assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length"))
174+ }
175+ before := f.downloads
176+ w := f.request("HEAD", path, "", "bytes=1-2")
177+ assert.Equal(t, 200, w.Code)
178+ assert.Empty(t, w.Body.String())
179+ assert.Equal(t, "10", w.Header().Get("Content-Length"))
180+ assert.Equal(t, before, f.downloads)
181+ }
182+ for _, path := range []string{"/proj/releases/assets/0", "/proj/releases/assets/nope", "/proj/releases/assets/99", "/proj/releases/download/missing/build.zip", "/proj/releases/latest/download/nope"} {
183+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
184+ }
185+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
186+ r.Header.Set("Range", "bytes=1-2")
187+ r.Header.Set("If-Range", `"old"`)
188+ w := httptest.NewRecorder()
189+ f.srv.ServeHTTP(w, r)
190+ assert.Equal(t, 200, w.Code)
191+ assert.Equal(t, f.data, w.Body.String())
192+}
193+func TestReleaseDownloadFailures(t *testing.T) {
194+ f := newReleaseFixture(t)
195+ f.downloadErr = errors.New("blob unavailable")
196+ w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2")
197+ assert.Equal(t, 404, w.Code)
198+ assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
199+ for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} {
200+ assert.Empty(t, w.Header().Get(key))
201+ }
202+ f.failAfterMetadata = true
203+ assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") })
204+}
205+func TestReleaseAttachmentHeaders(t *testing.T) {
206+ for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} {
207+ h := http.Header{}
208+ releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12})
209+ assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
210+ assert.NotContains(t, h.Get("Content-Disposition"), "\r")
211+ assert.NotContains(t, h.Get("Content-Disposition"), "\n")
212+ media, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
213+ require.NoError(t, err)
214+ assert.Equal(t, "attachment", media)
215+ assert.Equal(t, attachmentName(name), params["filename"])
216+ }
217+}
218+func TestReleaseRangeInvalid(t *testing.T) {
219+ for _, value := range []string{"bytes=", "bytes=-0", "bytes=-1-2", "bytes=5-2", "bytes=999999999999999999999999-", "bytes=0-nope"} {
220+ _, _, _, err := releaseRange(value, 10)
221+ assert.ErrorIs(t, err, errReleaseRange, value)
222+ }
223+}
224+func TestReleasesDisabled(t *testing.T) {
225+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
226+ for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} {
227+ w := httptest.NewRecorder()
228+ s.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
229+ assert.Equal(t, 404, w.Code)
230+ }
231+}
232+
233+func TestReleaseEmptyDownloads(t *testing.T) {
234+ f := newReleaseFixture(t)
235+ f.asset.Size = 0
236+ f.data = ""
237+ w := f.request("GET", "/proj/releases/assets/7", "", "")
238+ assert.Equal(t, 200, w.Code)
239+ assert.Equal(t, "0", w.Header().Get("Content-Length"))
240+ assert.Empty(t, w.Body.String())
241+ assert.Zero(t, f.downloads)
242+ w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-")
243+ assert.Equal(t, 416, w.Code)
244+ assert.Equal(t, "bytes */0", w.Header().Get("Content-Range"))
245+}
246+
247+func TestReleaseLatestMissing(t *testing.T) {
248+ svc := mocks.NewReleaseService(t)
249+ repo := repoName(t, "proj")
250+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound)
251+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
252+ w := httptest.NewRecorder()
253+ srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil))
254+ assert.Equal(t, 404, w.Code)
255+ assert.Empty(t, w.Header().Get("Location"))
256+}
257+
258+func TestReleaseAssetNameEscapedInPage(t *testing.T) {
259+ f := newReleaseFixture(t)
260+ f.asset.Name = `<img src=x onerror=alert(1)>`
261+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
262+ require.Equal(t, 200, w.Code)
263+ assert.NotContains(t, w.Body.String(), f.asset.Name)
264+ assert.Contains(t, w.Body.String(), "&lt;img src=x onerror=alert(1)&gt;")
265+}
266+
267+func TestReleaseRowFallbacks(t *testing.T) {
268+ row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0)
269+ assert.Equal(t, "v1", row.Title)
270+ assert.Equal(t, "draft", row.Published)
271+ assert.False(t, row.Latest)
272+}
@@ -0,0 +1,272 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "mime"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+type releaseFixture struct {
25+ srv *Server
26+ svc *mocks.ReleaseService
27+ published, draft domain.Release
28+ asset domain.ReleaseAsset
29+ data string
30+ downloadErr error
31+ failAfterMetadata bool
32+ downloads int
33+ tokens []string
34+}
35+
36+func newReleaseFixture(t *testing.T) *releaseFixture {
37+ t.Helper()
38+ repo := repoName(t, "proj")
39+ f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
40+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
41+ f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
42+ f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
43+ f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
44+ if ref.Latest || ref.Tag == f.published.Tag {
45+ return f.published, nil
46+ }
47+ if ref.Tag == f.draft.Tag && p.IsOwner() {
48+ return f.draft, nil
49+ }
50+ return domain.Release{}, domain.ErrNotFound
51+ }).Maybe()
52+ f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) {
53+ f.tokens = append(f.tokens, token)
54+ rows := []domain.Release{f.published}
55+ if p.IsOwner() {
56+ rows = append([]domain.Release{f.draft}, rows...)
57+ }
58+ return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil
59+ }).Maybe()
60+ f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
61+ if id == f.published.ID {
62+ return []domain.ReleaseAsset{f.asset}, nil
63+ }
64+ if id == f.draft.ID && p.IsOwner() {
65+ return nil, nil
66+ }
67+ return nil, domain.ErrNotFound
68+ }).Maybe()
69+ f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
70+ if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) {
71+ return f.asset, nil
72+ }
73+ return domain.ReleaseAsset{}, domain.ErrNotFound
74+ }).Maybe()
75+ f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
76+ f.downloads++
77+ deadline, ok := ctx.Deadline()
78+ require.True(t, ok)
79+ assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2)
80+ if f.downloadErr != nil && !f.failAfterMetadata {
81+ return f.downloadErr
82+ }
83+ if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil {
84+ return err
85+ }
86+ if f.downloadErr != nil {
87+ return f.downloadErr
88+ }
89+ return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])})
90+ }).Maybe()
91+ browse := mocks.NewBrowseService(t)
92+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
93+ f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }})
94+ return f
95+}
96+func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder {
97+ r := httptest.NewRequest(method, path, nil)
98+ if cookie != "" {
99+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
100+ }
101+ if byteRange != "" {
102+ r.Header.Set("Range", byteRange)
103+ }
104+ w := httptest.NewRecorder()
105+ f.srv.ServeHTTP(w, r)
106+ return w
107+}
108+func TestReleasePages(t *testing.T) {
109+ f := newReleaseFixture(t)
110+ for _, path := range []string{"/proj/releases", "/proj/releases/"} {
111+ w := f.request("GET", path, "", "")
112+ require.Equal(t, 200, w.Code)
113+ body := w.Body.String()
114+ assert.Contains(t, body, "First release")
115+ assert.NotContains(t, body, "Secret draft")
116+ assert.Contains(t, body, ">Latest</span>")
117+ assert.Contains(t, body, ">Pre-release</span>")
118+ assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`)
119+ assert.Contains(t, body, "2026-10-12")
120+ assert.Contains(t, body, `<td>1</td>`)
121+ assert.Contains(t, body, `?page=next%2B%2F%3D`)
122+ assert.Contains(t, body, `aria-keyshortcuts="l"`)
123+ assert.Contains(t, body, "view=releases")
124+ }
125+ owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
126+ require.Equal(t, 200, owner.Code)
127+ assert.Contains(t, owner.Body.String(), "Secret draft")
128+ assert.Contains(t, owner.Body.String(), ">Draft</span>")
129+ assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store")
130+ assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens)
131+ // The pin on the draft must not become a computed latest badge.
132+ assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>"))
133+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
134+ require.Equal(t, 200, w.Code)
135+ body := w.Body.String()
136+ for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
137+ assert.Contains(t, body, text)
138+ }
139+ assert.NotContains(t, body, "<script>alert")
140+ assert.NotContains(t, body, `href="javascript:`)
141+ assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
142+ for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} {
143+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
144+ }
145+ assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code)
146+ latest := f.request("GET", "/proj/releases/latest", "", "")
147+ assert.Equal(t, 303, latest.Code)
148+ assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location"))
149+}
150+func TestReleaseDownloads(t *testing.T) {
151+ f := newReleaseFixture(t)
152+ // A simple tag uses the stable URL; slash tags use the page's asset-id link.
153+ f.published.Tag = "v1"
154+ for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} {
155+ for _, tc := range []struct {
156+ rangeValue, body, cr string
157+ status int
158+ }{
159+ {"", "0123456789", "", 200}, {"bytes=2-5", "2345", "bytes 2-5/10", 206}, {"bytes=7-", "789", "bytes 7-9/10", 206}, {"bytes=-3", "789", "bytes 7-9/10", 206}, {"bytes=8-99", "89", "bytes 8-9/10", 206}, {"bytes=0-0,2-3", "0123456789", "", 200}, {"bytes=10-", "", "bytes */10", 416},
160+ } {
161+ w := f.request("GET", path, "", tc.rangeValue)
162+ require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue)
163+ assert.Equal(t, tc.body, w.Body.String())
164+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"))
165+ assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
166+ assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag"))
167+ assert.Equal(t, "application/zip", w.Header().Get("Content-Type"))
168+ assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
169+ assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy"))
170+ _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
171+ require.NoError(t, err)
172+ assert.Equal(t, "build.zip", params["filename"])
173+ assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length"))
174+ }
175+ before := f.downloads
176+ w := f.request("HEAD", path, "", "bytes=1-2")
177+ assert.Equal(t, 200, w.Code)
178+ assert.Empty(t, w.Body.String())
179+ assert.Equal(t, "10", w.Header().Get("Content-Length"))
180+ assert.Equal(t, before, f.downloads)
181+ }
182+ for _, path := range []string{"/proj/releases/assets/0", "/proj/releases/assets/nope", "/proj/releases/assets/99", "/proj/releases/download/missing/build.zip", "/proj/releases/latest/download/nope"} {
183+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
184+ }
185+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
186+ r.Header.Set("Range", "bytes=1-2")
187+ r.Header.Set("If-Range", `"old"`)
188+ w := httptest.NewRecorder()
189+ f.srv.ServeHTTP(w, r)
190+ assert.Equal(t, 200, w.Code)
191+ assert.Equal(t, f.data, w.Body.String())
192+}
193+func TestReleaseDownloadFailures(t *testing.T) {
194+ f := newReleaseFixture(t)
195+ f.downloadErr = errors.New("blob unavailable")
196+ w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2")
197+ assert.Equal(t, 404, w.Code)
198+ assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
199+ for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} {
200+ assert.Empty(t, w.Header().Get(key))
201+ }
202+ f.failAfterMetadata = true
203+ assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") })
204+}
205+func TestReleaseAttachmentHeaders(t *testing.T) {
206+ for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} {
207+ h := http.Header{}
208+ releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12})
209+ assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
210+ assert.NotContains(t, h.Get("Content-Disposition"), "\r")
211+ assert.NotContains(t, h.Get("Content-Disposition"), "\n")
212+ media, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
213+ require.NoError(t, err)
214+ assert.Equal(t, "attachment", media)
215+ assert.Equal(t, attachmentName(name), params["filename"])
216+ }
217+}
218+func TestReleaseRangeInvalid(t *testing.T) {
219+ for _, value := range []string{"bytes=", "bytes=-0", "bytes=-1-2", "bytes=5-2", "bytes=999999999999999999999999-", "bytes=0-nope"} {
220+ _, _, _, err := releaseRange(value, 10)
221+ assert.ErrorIs(t, err, errReleaseRange, value)
222+ }
223+}
224+func TestReleasesDisabled(t *testing.T) {
225+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
226+ for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} {
227+ w := httptest.NewRecorder()
228+ s.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
229+ assert.Equal(t, 404, w.Code)
230+ }
231+}
232+
233+func TestReleaseEmptyDownloads(t *testing.T) {
234+ f := newReleaseFixture(t)
235+ f.asset.Size = 0
236+ f.data = ""
237+ w := f.request("GET", "/proj/releases/assets/7", "", "")
238+ assert.Equal(t, 200, w.Code)
239+ assert.Equal(t, "0", w.Header().Get("Content-Length"))
240+ assert.Empty(t, w.Body.String())
241+ assert.Zero(t, f.downloads)
242+ w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-")
243+ assert.Equal(t, 416, w.Code)
244+ assert.Equal(t, "bytes */0", w.Header().Get("Content-Range"))
245+}
246+
247+func TestReleaseLatestMissing(t *testing.T) {
248+ svc := mocks.NewReleaseService(t)
249+ repo := repoName(t, "proj")
250+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound)
251+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
252+ w := httptest.NewRecorder()
253+ srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil))
254+ assert.Equal(t, 404, w.Code)
255+ assert.Empty(t, w.Header().Get("Location"))
256+}
257+
258+func TestReleaseAssetNameEscapedInPage(t *testing.T) {
259+ f := newReleaseFixture(t)
260+ f.asset.Name = `<img src=x onerror=alert(1)>`
261+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
262+ require.Equal(t, 200, w.Code)
263+ assert.NotContains(t, w.Body.String(), f.asset.Name)
264+ assert.Contains(t, w.Body.String(), "&lt;img src=x onerror=alert(1)&gt;")
265+}
266+
267+func TestReleaseRowFallbacks(t *testing.T) {
268+ row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0)
269+ assert.Equal(t, "v1", row.Title)
270+ assert.Equal(t, "draft", row.Published)
271+ assert.False(t, row.Latest)
272+}
M internal/adapters/web/repo.go +3 -0
@@ -156,6 +156,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
156156 if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
157157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
158158 }
159+ if s.cfg.Releases != nil {
160+ l.Sections = append(l.Sections, mk("l", "Releases", releasesBase(d.name()), "releases"))
161+ }
159162 l.Sections = s.withActions(l.Sections, d.name(), section)
160163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
161164
@@ -156,6 +156,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
156 if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {156 if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
158 }158 }
159+ if s.cfg.Releases != nil {
160+ l.Sections = append(l.Sections, mk("l", "Releases", releasesBase(d.name()), "releases"))
161+ }
159 l.Sections = s.withActions(l.Sections, d.name(), section)162 l.Sections = s.withActions(l.Sections, d.name(), section)
160 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
161 164
M internal/adapters/web/web.go +11 -0
@@ -91,6 +91,8 @@ type Config struct {
9191 // their download and their deletion. It needs Runs. Without it there is
9292 // neither. Leave it unset rather than assigning a typed nil pointer.
9393 Artifacts ports.ArtifactService
94+ // Releases serves the release pages and streamed assets. Nil disables them.
95+ Releases ports.ReleaseService
9496 // Events is the source of the event stream behind /-/events. Without
9597 // it the pages are not live and the route answers 404. Leave it unset
9698 // rather than assigning a typed nil pointer.
@@ -252,6 +254,15 @@ func (s *Server) routes() *http.ServeMux {
252254 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
253255 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
254256 }
257+ if s.cfg.Releases != nil {
258+ mux.HandleFunc("GET /{repo}/releases", s.page(s.releaseList))
259+ mux.HandleFunc("GET /{repo}/releases/{$}", s.page(s.releaseList))
260+ mux.HandleFunc("GET /{repo}/releases/tag/{tag...}", s.page(s.releaseShow))
261+ mux.HandleFunc("GET /{repo}/releases/latest", s.releaseLatest)
262+ mux.HandleFunc("GET /{repo}/releases/download/{tag}/{asset}", s.releaseDownload)
263+ mux.HandleFunc("GET /{repo}/releases/latest/download/{asset}", s.releaseDownload)
264+ mux.HandleFunc("GET /{repo}/releases/assets/{id}", s.releaseDownload)
265+ }
255266 if s.cfg.Runs != nil {
256267 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
257268 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
@@ -91,6 +91,8 @@ type Config struct {
91 // their download and their deletion. It needs Runs. Without it there is91 // their download and their deletion. It needs Runs. Without it there is
92 // neither. Leave it unset rather than assigning a typed nil pointer.92 // neither. Leave it unset rather than assigning a typed nil pointer.
93 Artifacts ports.ArtifactService93 Artifacts ports.ArtifactService
94+ // Releases serves the release pages and streamed assets. Nil disables them.
95+ Releases ports.ReleaseService
94 // Events is the source of the event stream behind /-/events. Without96 // Events is the source of the event stream behind /-/events. Without
95 // it the pages are not live and the route answers 404. Leave it unset97 // it the pages are not live and the route answers 404. Leave it unset
96 // rather than assigning a typed nil pointer.98 // rather than assigning a typed nil pointer.
@@ -252,6 +254,15 @@ func (s *Server) routes() *http.ServeMux {
252 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)254 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
253 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)255 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
254 }256 }
257+ if s.cfg.Releases != nil {
258+ mux.HandleFunc("GET /{repo}/releases", s.page(s.releaseList))
259+ mux.HandleFunc("GET /{repo}/releases/{$}", s.page(s.releaseList))
260+ mux.HandleFunc("GET /{repo}/releases/tag/{tag...}", s.page(s.releaseShow))
261+ mux.HandleFunc("GET /{repo}/releases/latest", s.releaseLatest)
262+ mux.HandleFunc("GET /{repo}/releases/download/{tag}/{asset}", s.releaseDownload)
263+ mux.HandleFunc("GET /{repo}/releases/latest/download/{asset}", s.releaseDownload)
264+ mux.HandleFunc("GET /{repo}/releases/assets/{id}", s.releaseDownload)
265+ }
255 if s.cfg.Runs != nil {266 if s.cfg.Runs != nil {
256 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))267 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
257 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))268 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
A internal/app/web_release_test.go +103 -0
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strconv"
11+ "strings"
12+ "testing"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/blobdisk"
18+ "bnema.dev/ermite/internal/adapters/web"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/usecase"
22+)
23+
24+// P8 owns production wiring. Until then, this test wires the real release
25+// use case, forge metadata/Git, disk blobs and web in a test-local server.
26+func TestWebRelease(t *testing.T) {
27+ h := newHarness(t, harnessOpts{})
28+ h.webRepo("demo")
29+ blobs := blobdisk.New(t.TempDir())
30+ releases := usecase.NewReleases(h.forge.store.Releases(), blobs, h.forge.store, h.forge.git, h.forge.git, h.forge.Pushes, h.forge.Bus, systemClock{}, ports.ReleaseLimits{})
31+ repo := h.name("demo")
32+ rel, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v0.2.0", Title: "Demo release", Notes: "## Install\n\n**Download** the binary. <script>alert(1)</script>"})
33+ require.NoError(t, err)
34+ payload := "binary release data\x00\xff\n"
35+ asset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", ContentType: "application/octet-stream", Size: int64(len(payload))}, strings.NewReader(payload))
36+ require.NoError(t, err)
37+ draft, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "draft", Title: "Hidden draft", Draft: true})
38+ require.NoError(t, err)
39+ draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x"))
40+ require.NoError(t, err)
41+ srv, err := web.New(web.Config{Repos: h.forge.Repos, Browse: h.forge.Browse, Releases: releases, PublicURL: "http://example.test"})
42+ require.NoError(t, err)
43+ ts := httptest.NewServer(srv)
44+ t.Cleanup(ts.Close)
45+ get := func(method, path, byteRange string) (int, http.Header, string) {
46+ t.Helper()
47+ req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil)
48+ require.NoError(t, err)
49+ if byteRange != "" {
50+ req.Header.Set("Range", byteRange)
51+ }
52+ res, err := ts.Client().Do(req)
53+ require.NoError(t, err)
54+ defer res.Body.Close()
55+ body, err := io.ReadAll(res.Body)
56+ require.NoError(t, err)
57+ return res.StatusCode, res.Header, string(body)
58+ }
59+ status, _, body := get("GET", "/demo/releases", "")
60+ require.Equal(t, 200, status)
61+ assert.Contains(t, body, "Demo release")
62+ assert.NotContains(t, body, "Hidden draft")
63+ status, _, body = get("GET", "/demo/releases/tag/v0.2.0", "")
64+ require.Equal(t, 200, status)
65+ assert.Contains(t, body, "<strong>Download</strong>")
66+ assert.NotContains(t, body, "<script>alert")
67+ assert.Contains(t, body, "demo.bin")
68+ assert.Contains(t, body, "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10))
69+ for _, path := range []string{"/demo/releases/download/v0.2.0/demo.bin", "/demo/releases/latest/download/demo.bin", "/demo/releases/assets/" + strconv.FormatInt(asset.ID, 10)} {
70+ status, headers, body := get("GET", path, "")
71+ require.Equal(t, 200, status)
72+ assert.Equal(t, payload, body)
73+ assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
74+ assert.Equal(t, "bytes", headers.Get("Accept-Ranges"))
75+ assert.Contains(t, headers.Get("Content-Disposition"), "attachment")
76+ status, headers, body = get("GET", path, "bytes=2-6")
77+ require.Equal(t, 206, status)
78+ assert.Equal(t, payload[2:7], body)
79+ assert.Equal(t, "bytes 2-6/"+strconv.Itoa(len(payload)), headers.Get("Content-Range"))
80+ assert.Equal(t, "5", headers.Get("Content-Length"))
81+ status, headers, body = get("HEAD", path, "")
82+ require.Equal(t, 200, status)
83+ assert.Empty(t, body)
84+ assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
85+ }
86+ for _, path := range []string{"/demo/releases/tag/draft", "/demo/releases/download/draft/hidden.bin", "/demo/releases/assets/" + strconv.FormatInt(draftAsset.ID, 10)} {
87+ status, _, _ := get("GET", path, "")
88+ assert.Equal(t, 404, status)
89+ }
90+ // Slash-tag page and asset-id download remain unambiguous.
91+ slash, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "series/v1", TargetCommitish: "main", Title: "Slash tag"})
92+ require.NoError(t, err)
93+ status, _, body = get("GET", "/demo/releases/tag/series/v1", "")
94+ assert.Equal(t, 200, status)
95+ assert.Contains(t, body, slash.Title)
96+ // A missing blob is a 404 page before any binary headers are written.
97+ require.NoError(t, blobs.Delete(context.Background(), asset.BlobKey()))
98+ status, headers, body := get("GET", "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10), "")
99+ assert.Equal(t, 404, status)
100+ assert.Contains(t, headers.Get("Content-Type"), "text/html")
101+ assert.Contains(t, body, "no such page")
102+ assert.Empty(t, headers.Get("Content-Disposition"))
103+}
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strconv"
11+ "strings"
12+ "testing"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/blobdisk"
18+ "bnema.dev/ermite/internal/adapters/web"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/usecase"
22+)
23+
24+// P8 owns production wiring. Until then, this test wires the real release
25+// use case, forge metadata/Git, disk blobs and web in a test-local server.
26+func TestWebRelease(t *testing.T) {
27+ h := newHarness(t, harnessOpts{})
28+ h.webRepo("demo")
29+ blobs := blobdisk.New(t.TempDir())
30+ releases := usecase.NewReleases(h.forge.store.Releases(), blobs, h.forge.store, h.forge.git, h.forge.git, h.forge.Pushes, h.forge.Bus, systemClock{}, ports.ReleaseLimits{})
31+ repo := h.name("demo")
32+ rel, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v0.2.0", Title: "Demo release", Notes: "## Install\n\n**Download** the binary. <script>alert(1)</script>"})
33+ require.NoError(t, err)
34+ payload := "binary release data\x00\xff\n"
35+ asset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", ContentType: "application/octet-stream", Size: int64(len(payload))}, strings.NewReader(payload))
36+ require.NoError(t, err)
37+ draft, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "draft", Title: "Hidden draft", Draft: true})
38+ require.NoError(t, err)
39+ draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x"))
40+ require.NoError(t, err)
41+ srv, err := web.New(web.Config{Repos: h.forge.Repos, Browse: h.forge.Browse, Releases: releases, PublicURL: "http://example.test"})
42+ require.NoError(t, err)
43+ ts := httptest.NewServer(srv)
44+ t.Cleanup(ts.Close)
45+ get := func(method, path, byteRange string) (int, http.Header, string) {
46+ t.Helper()
47+ req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil)
48+ require.NoError(t, err)
49+ if byteRange != "" {
50+ req.Header.Set("Range", byteRange)
51+ }
52+ res, err := ts.Client().Do(req)
53+ require.NoError(t, err)
54+ defer res.Body.Close()
55+ body, err := io.ReadAll(res.Body)
56+ require.NoError(t, err)
57+ return res.StatusCode, res.Header, string(body)
58+ }
59+ status, _, body := get("GET", "/demo/releases", "")
60+ require.Equal(t, 200, status)
61+ assert.Contains(t, body, "Demo release")
62+ assert.NotContains(t, body, "Hidden draft")
63+ status, _, body = get("GET", "/demo/releases/tag/v0.2.0", "")
64+ require.Equal(t, 200, status)
65+ assert.Contains(t, body, "<strong>Download</strong>")
66+ assert.NotContains(t, body, "<script>alert")
67+ assert.Contains(t, body, "demo.bin")
68+ assert.Contains(t, body, "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10))
69+ for _, path := range []string{"/demo/releases/download/v0.2.0/demo.bin", "/demo/releases/latest/download/demo.bin", "/demo/releases/assets/" + strconv.FormatInt(asset.ID, 10)} {
70+ status, headers, body := get("GET", path, "")
71+ require.Equal(t, 200, status)
72+ assert.Equal(t, payload, body)
73+ assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
74+ assert.Equal(t, "bytes", headers.Get("Accept-Ranges"))
75+ assert.Contains(t, headers.Get("Content-Disposition"), "attachment")
76+ status, headers, body = get("GET", path, "bytes=2-6")
77+ require.Equal(t, 206, status)
78+ assert.Equal(t, payload[2:7], body)
79+ assert.Equal(t, "bytes 2-6/"+strconv.Itoa(len(payload)), headers.Get("Content-Range"))
80+ assert.Equal(t, "5", headers.Get("Content-Length"))
81+ status, headers, body = get("HEAD", path, "")
82+ require.Equal(t, 200, status)
83+ assert.Empty(t, body)
84+ assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
85+ }
86+ for _, path := range []string{"/demo/releases/tag/draft", "/demo/releases/download/draft/hidden.bin", "/demo/releases/assets/" + strconv.FormatInt(draftAsset.ID, 10)} {
87+ status, _, _ := get("GET", path, "")
88+ assert.Equal(t, 404, status)
89+ }
90+ // Slash-tag page and asset-id download remain unambiguous.
91+ slash, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "series/v1", TargetCommitish: "main", Title: "Slash tag"})
92+ require.NoError(t, err)
93+ status, _, body = get("GET", "/demo/releases/tag/series/v1", "")
94+ assert.Equal(t, 200, status)
95+ assert.Contains(t, body, slash.Title)
96+ // A missing blob is a 404 page before any binary headers are written.
97+ require.NoError(t, blobs.Delete(context.Background(), asset.BlobKey()))
98+ status, headers, body := get("GET", "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10), "")
99+ assert.Equal(t, 404, status)
100+ assert.Contains(t, headers.Get("Content-Type"), "text/html")
101+ assert.Contains(t, body, "no such page")
102+ assert.Empty(t, headers.Get("Content-Disposition"))
103+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close