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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 62 | 62 | return true | |
| 63 | 63 | } | |
| 64 | 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 | 67 | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 67 | 68 | domain.RunUpdated, domain.JobUpdated, | |
| 68 | 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 | 165 | // repoPage handles the events of the topic of one repository. | |
| 165 | 166 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 166 | 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 | 173 | case domain.RunUpdated: | |
| 168 | 174 | return l.runEvent(ctx, ev) | |
| 169 | 175 | case domain.JobUpdated: | |
| @@ -179,7 +185,7 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 179 | 185 | return gap, nil // no longer visible | |
| 180 | 186 | case err != nil: | |
| 181 | 187 | 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 | 189 | return gap, nil // the description, the default branch | |
| 184 | 190 | } | |
| 185 | 191 | return nil, nil | |
| @@ -62,7 +62,8 @@ func liveEvent(we ports.WatchEvent) bool { | |||||
| 62 | return true | 62 | 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 visible | 185 | return gap, nil // no longer visible | ||
| 180 | case err != nil: | 186 | case err != nil: | ||
| 181 | return nil, err | 187 | 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 branch | 189 | return gap, nil // the description, the default branch | ||
| 184 | } | 190 | } | ||
| 185 | return nil, nil | 191 | return nil, nil | ||
M internal/adapters/web/fragments_test.go +20 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | 10 | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/a-h/templ" | |
| 13 | 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 | + | } | |
| @@ -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(), "<img src=x onerror=alert(1)>") | |
| 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(), "<img src=x onerror=alert(1)>") | |||
| 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 | |||
| 156 | 156 | if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() { | |
| 157 | 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 | 162 | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| 160 | 163 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |
| 161 | 164 | ||
| @@ -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 { | |||
| 91 | 91 | // their download and their deletion. It needs Runs. Without it there is | |
| 92 | 92 | // neither. Leave it unset rather than assigning a typed nil pointer. | |
| 93 | 93 | Artifacts ports.ArtifactService | |
| 94 | + | // Releases serves the release pages and streamed assets. Nil disables them. | |
| 95 | + | Releases ports.ReleaseService | |
| 94 | 96 | // Events is the source of the event stream behind /-/events. Without | |
| 95 | 97 | // it the pages are not live and the route answers 404. Leave it unset | |
| 96 | 98 | // rather than assigning a typed nil pointer. | |
| @@ -252,6 +254,15 @@ func (s *Server) routes() *http.ServeMux { | |||
| 252 | 254 | mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage) | |
| 253 | 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 | 266 | if s.cfg.Runs != nil { | |
| 256 | 267 | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 257 | 268 | 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 is | 91 | // 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.ArtifactService | 93 | 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. Without | 96 | // 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 unset | 97 | // 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 | + | } | |||