1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
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/httprange"
20 "bnema.dev/ermite/internal/ports"
21)
22
23func releasesBase(repo string) string { return repoHref(repo, "releases") }
24func releaseHref(repo, tag string) string { return repoHref(repo, "releases", "tag", tag) }
25func releaseAssetHref(repo string, id int64) string {
26 return releasesBase(repo) + "/assets/" + strconv.FormatInt(id, 10)
27}
28
29// ReleaseRow describes a release without mistaking MakeLatest (a pin) for
30// the latest release selected by the service.
31type ReleaseRow struct {
32 Tag, Title, Href, Published string
33 Draft, Prerelease, Latest bool
34 AssetCount int
35}
36type ReleaseListPage struct {
37 Rows []ReleaseRow
38 Older string
39}
40type ReleaseAssetRow struct{ Name, Href, Size, ContentType string }
41type ReleaseDetailPage struct {
42 ReleaseRow
43 Notes template.HTML
44 SHA, CommitHref, SourceHref string
45 Assets []ReleaseAssetRow
46}
47
48func (s *Server) latestReleaseID(ctx context.Context, repo domain.RepoName) (int64, error) {
49 rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
50 if errors.Is(err, domain.ErrNotFound) {
51 return 0, nil
52 }
53 return rel.ID, err
54}
55func releaseRow(repo string, rel domain.Release, latest int64) ReleaseRow {
56 published := "draft"
57 if !rel.Draft && !rel.PublishedAt.IsZero() {
58 published = rel.PublishedAt.UTC().Format("2006-01-02")
59 }
60 title := rel.Title
61 if title == "" {
62 title = rel.Tag
63 }
64 return ReleaseRow{Tag: rel.Tag, Title: title, Href: releaseHref(repo, rel.Tag), Published: published, Draft: rel.Draft, Prerelease: rel.Prerelease, Latest: rel.ID == latest}
65}
66func (s *Server) releaseList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
67 d, err := s.loadRepo(ctx, r.PathValue("repo"))
68 if err != nil {
69 return nil, 0, err
70 }
71 page, err := s.cfg.Releases.ListReleases(ctx, principal(ctx), d.repo.Name, 30, r.URL.Query().Get("page"))
72 if err != nil {
73 return nil, 0, err
74 }
75 latest, err := s.latestReleaseID(ctx, d.repo.Name)
76 if err != nil {
77 return nil, 0, err
78 }
79 p := ReleaseListPage{}
80 for _, rel := range page.Releases {
81 row := releaseRow(d.name(), rel, latest)
82 assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
83 if err != nil {
84 return nil, 0, err
85 }
86 row.AssetCount = len(assets)
87 p.Rows = append(p.Rows, row)
88 }
89 if page.NextPageToken != "" {
90 p.Older = releasesBase(d.name()) + "?page=" + url.QueryEscape(page.NextPageToken)
91 }
92 l := s.repoLayout(ctx, d, "releases")
93 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=releases")
94 l.Title = "Releases · " + d.name()
95 l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Current: true})
96 return Releases(l, p), http.StatusOK, nil
97}
98func (s *Server) releaseShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
99 d, err := s.loadRepo(ctx, r.PathValue("repo"))
100 if err != nil {
101 return nil, 0, err
102 }
103 rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), d.repo.Name, ports.ReleaseRef{Tag: r.PathValue("tag")})
104 if err != nil {
105 return nil, 0, err
106 }
107 latest, err := s.latestReleaseID(ctx, d.repo.Name)
108 if err != nil {
109 return nil, 0, err
110 }
111 assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
112 if err != nil {
113 return nil, 0, err
114 }
115 notes, _, err := s.md.render([]byte(rel.Notes), d.name(), rel.Tag, "")
116 if err != nil {
117 return nil, 0, err
118 }
119 p := ReleaseDetailPage{ReleaseRow: releaseRow(d.name(), rel, latest), Notes: notes, SourceHref: repoHref(d.name(), "tree", rel.Tag)}
120 if rel.TargetCommitish != "" {
121 p.SHA = short(rel.TargetCommitish)
122 p.CommitHref = repoHref(d.name(), "commit", rel.TargetCommitish)
123 }
124 for _, a := range assets {
125 p.Assets = append(p.Assets, ReleaseAssetRow{Name: a.Name, Href: releaseAssetHref(d.name(), a.ID), Size: humanSize(a.Size), ContentType: a.ContentType})
126 }
127 l := s.repoLayout(ctx, d, "releases")
128 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=release")
129 l.Title = rel.Tag + " · " + d.name()
130 l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Href: releasesBase(d.name())}, Crumb{Label: rel.Tag, Current: true})
131 return ReleaseDetail(l, p), http.StatusOK, nil
132}
133func (s *Server) releaseLatest(w http.ResponseWriter, r *http.Request) {
134 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
135 defer cancel()
136 repo, err := domain.ParseRepoName(r.PathValue("repo"))
137 if err != nil {
138 s.fail(ctx, w, r, err)
139 return
140 }
141 rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
142 if err != nil {
143 s.fail(ctx, w, r, err)
144 return
145 }
146 cacheHeaders(ctx, w.Header(), "no-cache", sessionCache)
147 http.Redirect(w, r, releaseHref(repo.String(), rel.Tag), http.StatusSeeOther)
148}
149
150func releaseHeaders(h http.Header, a domain.ReleaseAsset) {
151 // Reuse the artifact sandbox and attachment policy; never trust an
152 // uploader's media type to execute content in the forge's origin.
153 typ := "application/octet-stream"
154 if a.ContentType == domain.CIArtifactZip {
155 typ = domain.CIArtifactZip
156 }
157 h.Set("Content-Type", typ)
158 h.Set("Content-Disposition", contentDisposition(attachmentName(a.Name)))
159 h.Set("X-Content-Type-Options", "nosniff")
160 h.Set("Content-Security-Policy", rawCSP)
161 h.Set("Content-Length", strconv.FormatInt(a.Size, 10))
162 h.Set("ETag", `"`+strings.Replace(a.Digest, "sha256:", "sha256-", 1)+`"`)
163 h.Set("Accept-Ranges", "bytes")
164}
165func (s *Server) releaseDownload(w http.ResponseWriter, r *http.Request) {
166 ctx, cancel := context.WithTimeout(r.Context(), 60*time.Minute)
167 defer cancel()
168 repo, err := domain.ParseRepoName(r.PathValue("repo"))
169 if err != nil {
170 s.fail(ctx, w, r, errNotFound)
171 return
172 }
173 ref := ports.AssetRef{Tag: r.PathValue("tag"), Name: r.PathValue("asset")}
174 if id := r.PathValue("id"); id != "" {
175 ref.ID, err = strconv.ParseInt(id, 10, 64)
176 if err != nil || ref.ID < 1 {
177 s.fail(ctx, w, r, errNotFound)
178 return
179 }
180 } else if ref.Tag == "" {
181 ref.Latest = true
182 }
183 a, err := s.cfg.Releases.GetAsset(ctx, principal(ctx), repo, ref)
184 if err != nil {
185 s.fail(ctx, w, r, errNotFound)
186 return
187 }
188 // Resolve latest/tag only once: a moving latest cannot select another
189 // asset between reading the headers and streaming the bytes.
190 ref = ports.AssetRef{ID: a.ID}
191 h := w.Header()
192 releaseHeaders(h, a)
193 cacheHeaders(ctx, h, "no-cache", sessionCache)
194 offset, length, partial, err := httprange.Select(r, a.Size, h.Get("ETag"))
195 if err != nil {
196 h.Set("Content-Range", httprange.Unsatisfied(a.Size))
197 h.Set("Content-Length", "0")
198 w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
199 return
200 }
201 status := http.StatusOK
202 if partial {
203 status = http.StatusPartialContent
204 h.Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
205 }
206 h.Set("Content-Length", strconv.FormatInt(length, 10))
207 if r.Method == http.MethodHead {
208 w.WriteHeader(status)
209 return
210 }
211 started := false
212 rc := http.NewResponseController(w)
213 defer func() { _ = rc.SetWriteDeadline(time.Time{}) }()
214 err = s.cfg.Releases.DownloadAsset(ctx, principal(ctx), repo, ref, offset, length, func(c ports.AssetChunk) error {
215 if !started {
216 if c.Asset == nil || c.Asset.ID != a.ID || c.Asset.Digest != a.Digest || c.Asset.Size != a.Size {
217 return errors.New("web: release asset changed before download")
218 }
219 _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
220 w.WriteHeader(status)
221 started = true
222 }
223 if len(c.Data) == 0 {
224 return nil
225 }
226 _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
227 _, err := w.Write(c.Data)
228 return err
229 })
230 if err == nil {
231 return
232 }
233 if !started {
234 // Do not leave binary headers or range metadata on the HTML error page.
235 for _, key := range []string{"Content-Length", "Content-Disposition", "Content-Range", "Content-Security-Policy", "ETag", "Accept-Ranges"} {
236 h.Del(key)
237 }
238 s.fail(ctx, w, r, errNotFound)
239 return
240 }
241 log := zerowrap.FromCtx(ctx)
242 log.Error().Err(err).Str("repo", repo.String()).Int64("asset", a.ID).Msg("release download failed")
243 panic(http.ErrAbortHandler)
244}