detached fda3c2b

releases.go RawHistoryFolder

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}

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close