v0.32.0 53e25a3

releases.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githubapi
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"io"
10	"mime"
11	"net/http"
12	"strconv"
13	"strings"
14	"unicode"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/httprange"
18	"bnema.dev/ermite/internal/ports"
19	"github.com/bnema/zerowrap"
20)
21
22func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) {
23	parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
24	upload := len(parts) > 0 && parts[0] == "uploads"
25	if upload {
26		parts = parts[1:]
27	}
28	if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" {
29		return
30	}
31	owner, repo = parts[1], parts[2]
32	if upload {
33		if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" {
34			return "release-upload", owner, repo, parts[4], true
35		}
36		return
37	}
38	switch {
39	case len(parts) == 4:
40		return "release-list", owner, repo, "", true
41	case len(parts) == 5 && parts[4] == "latest":
42		return "release-latest", owner, repo, "", true
43	case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "":
44		return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true
45	case len(parts) == 5 && isNumber(parts[4]):
46		return "release-id", owner, repo, parts[4], true
47	case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets":
48		return "release-assets", owner, repo, parts[4], true
49	case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]):
50		return "release-asset", owner, repo, parts[5], true
51	}
52	return
53}
54func releaseMethods(kind string) string {
55	switch kind {
56	case "release-list":
57		return "GET, HEAD, POST"
58	case "release-id", "release-asset":
59		return "GET, HEAD, PATCH, DELETE"
60	case "release-upload":
61		return "POST"
62	default:
63		return "GET, HEAD"
64	}
65}
66func methodAllowed(method, allow string) bool {
67	for _, m := range strings.Split(allow, ", ") {
68		if method == m {
69			return true
70		}
71	}
72	return false
73}
74func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) {
75	w.Header().Set("Content-Type", "application/json; charset=utf-8")
76	w.Header().Set("Content-Length", strconv.Itoa(len(body)))
77	w.WriteHeader(status)
78	if r.Method != http.MethodHead {
79		_, _ = w.Write(body)
80	}
81}
82func releaseMessage(w http.ResponseWriter, status int, message string) {
83	b, _ := json.Marshal(struct {
84		Message string `json:"message"`
85	}{message})
86	w.Header().Set("Content-Type", "application/json; charset=utf-8")
87	w.WriteHeader(status)
88	_, _ = w.Write(b)
89}
90func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
91	b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
92	if err != nil {
93		var tooLarge *http.MaxBytesError
94		if errors.As(err, &tooLarge) {
95			releaseMessage(w, 422, "Body too large")
96		} else {
97			releaseMessage(w, 422, "Validation Failed")
98		}
99		return nil, false
100	}
101	return b, true
102}
103func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) {
104	ctx := r.Context()
105	api := h.cfg.Releases
106	id, _ := strconv.ParseInt(ref, 10, 64)
107	q := r.URL.Query()
108	page, _ := strconv.Atoi(q.Get("page"))
109	perPage, _ := strconv.Atoi(q.Get("per_page"))
110	var body []byte
111	var err error
112	var link string
113	status := http.StatusOK
114	if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") {
115		if h.cfg.ReleaseDownload == nil {
116			notFound(w)
117			return
118		}
119		h.downloadRelease(w, r, p, owner, repo, id, client)
120		return
121	}
122	switch kind {
123	case "release-list":
124		if r.Method == http.MethodPost {
125			b, ok := releaseBody(w, r)
126			if !ok {
127				return
128			}
129			body, err = api.CreateRelease(ctx, p, owner, repo, b)
130			status = 201
131		} else {
132			body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage)
133		}
134	case "release-latest":
135		body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true})
136	case "release-tag":
137		body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref})
138	case "release-id":
139		switch r.Method {
140		case http.MethodPatch:
141			b, ok := releaseBody(w, r)
142			if !ok {
143				return
144			}
145			body, err = api.UpdateRelease(ctx, p, owner, repo, id, b)
146		case http.MethodDelete:
147			err = api.DeleteRelease(ctx, p, owner, repo, id)
148			status = 204
149		default:
150			body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id})
151		}
152	case "release-assets":
153		body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage)
154	case "release-upload":
155		if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") {
156			releaseMessage(w, 411, "Length Required")
157			return
158		}
159		if q.Get("name") == "" {
160			h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name")
161			return
162		}
163		ct := r.Header.Get("Content-Type")
164		if ct == "" {
165			ct = "application/octet-stream"
166		}
167		body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength))
168		status = 201
169	case "release-asset":
170		switch r.Method {
171		case http.MethodPatch:
172			b, ok := releaseBody(w, r)
173			if !ok {
174				return
175			}
176			body, err = api.UpdateAsset(ctx, p, owner, repo, id, b)
177		case http.MethodDelete:
178			err = api.DeleteAsset(ctx, p, owner, repo, id)
179			status = 204
180		default:
181			body, err = api.GetAsset(ctx, p, owner, repo, id)
182		}
183	}
184	if err != nil {
185		h.failRelease(ctx, w, p, err, kind, "")
186		return
187	}
188	if link != "" {
189		w.Header().Set("Link", link)
190	}
191	if status == 204 {
192		w.WriteHeader(status)
193		return
194	}
195	releaseJSON(w, r, status, body)
196}
197
198// failRelease translates use-case failures to GitHub's status and error JSON;
199// authorization and limits have already been decided by ReleaseService.
200//
201//hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here
202func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) {
203	switch {
204	case errors.Is(err, domain.ErrNotFound):
205		notFound(w)
206	case errors.Is(err, domain.ErrUnauthenticated):
207		releaseMessage(w, 401, "Bad credentials")
208	case errors.Is(err, domain.ErrForbidden):
209		msg := "Forbidden"
210		if p.ViaJobToken() {
211			msg = "Resource not accessible by integration"
212		}
213		releaseMessage(w, 403, msg)
214	case errors.Is(err, domain.ErrResourceExhausted):
215		if strings.Contains(err.Error(), "too many uploads in progress") {
216			w.Header().Set("Retry-After", retryAfter)
217			releaseMessage(w, 429, err.Error())
218		} else {
219			releaseMessage(w, 422, err.Error())
220		}
221	case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed):
222		writeReleaseValidation(w, err, kind, field)
223	case errors.Is(err, context.Canceled):
224	default:
225		log := zerowrap.FromCtx(ctx)
226		log.Error().Err(err).Msg("github release request failed")
227		releaseMessage(w, 500, "Server Error")
228	}
229}
230
231type releaseValidationError struct {
232	Resource string `json:"resource"`
233	Code     string `json:"code"`
234	Field    string `json:"field,omitempty"`
235}
236
237func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) {
238	resource, code, doc := "Release", "invalid", ""
239	asset := kind == "release-upload" || kind == "release-asset"
240	if asset {
241		resource = "ReleaseAsset"
242	}
243	if errors.Is(err, domain.ErrAlreadyExists) {
244		code = "already_exists"
245		field = "tag_name"
246		if asset {
247			field = "name"
248		}
249		if kind == "release-upload" {
250			doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset"
251		}
252	} else if errors.Is(err, domain.ErrPreconditionFailed) {
253		field = "target_commitish"
254	} else if field == "" {
255		field = releaseErrorField(err.Error())
256	}
257	b, _ := json.Marshal(struct {
258		Message          string                   `json:"message"`
259		Errors           []releaseValidationError `json:"errors"`
260		DocumentationURL string                   `json:"documentation_url,omitempty"`
261	}{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc})
262	w.Header().Set("Content-Type", "application/json; charset=utf-8")
263	w.WriteHeader(422)
264	_, _ = w.Write(b)
265}
266
267// releaseErrorField recognises the wire fields and domain validation vocabulary.
268// Errors without a named field omit it rather than guessing.
269func releaseErrorField(text string) string {
270	for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} {
271		if strings.Contains(text, f) {
272			return f
273		}
274	}
275	if strings.Contains(text, "tag name") {
276		return "tag_name"
277	}
278	if strings.Contains(text, "media type") {
279		return "content_type"
280	}
281	if strings.Contains(text, "name") {
282		return "name"
283	}
284	return ""
285}
286
287func releaseContentDisposition(name string) string {
288	name = strings.Map(func(r rune) rune {
289		if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar {
290			return -1
291		}
292		return r
293	}, name)
294	if name == "" || name == "." || name == ".." {
295		name = "asset"
296	}
297	ascii := true
298	fallback := strings.Map(func(r rune) rune {
299		if r > unicode.MaxASCII {
300			ascii = false
301			return '_'
302		}
303		return r
304	}, name)
305	// Quote even token-safe names, keeping a consistent RFC 6266 representation.
306	cd := "attachment; filename=" + strconv.Quote(fallback)
307	if !ascii {
308		const digits = "0123456789ABCDEF"
309		var b strings.Builder
310		for i := 0; i < len(name); i++ {
311			c := name[i]
312			if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
313				b.WriteByte(c)
314			} else {
315				b.WriteByte('%')
316				b.WriteByte(digits[c>>4])
317				b.WriteByte(digits[c&15])
318			}
319		}
320		cd += "; filename*=UTF-8''" + b.String()
321	}
322	return cd
323}
324func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string {
325	ct := a.ContentType
326	media, _, err := mime.ParseMediaType(ct)
327	media = strings.ToLower(media)
328	if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" {
329		ct = "application/octet-stream"
330	}
331	w.Header().Set("Content-Type", ct)
332	w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name))
333	w.Header().Set("X-Content-Type-Options", "nosniff")
334	w.Header().Set("Content-Security-Policy", "sandbox")
335	w.Header().Set("Accept-Ranges", "bytes")
336	etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"`
337	w.Header().Set("ETag", etag)
338	w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat))
339	return etag
340}
341func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) {
342	ctx := r.Context()
343	a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id)
344	if err != nil {
345		h.failRelease(ctx, w, p, err, "release-asset", "")
346		return
347	}
348	if r.Method != http.MethodHead {
349		release, ok := h.acquire(w, client)
350		if !ok {
351			return
352		}
353		defer release()
354	}
355	etag := setReleaseDownloadHeaders(w, a)
356	if r.Header.Get("If-None-Match") == etag {
357		w.WriteHeader(304)
358		return
359	}
360	offset, length, partial, err := httprange.Select(r, a.Size, etag)
361	if err != nil {
362		w.Header().Set("Content-Range", httprange.Unsatisfied(a.Size))
363		w.WriteHeader(416)
364		return
365	}
366	status := 200
367	if partial {
368		status = 206
369		w.Header().Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
370	}
371	w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
372	if r.Method == http.MethodHead {
373		w.WriteHeader(status)
374		return
375	}
376	// Slot failures and pre-stream failures are JSON, not a binary response.
377	clearHeaders := func() {
378		for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} {
379			w.Header().Del(k)
380		}
381	}
382	writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle}
383	started := false
384	err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error {
385		if len(chunk.Data) == 0 {
386			return nil
387		}
388		if !started {
389			w.WriteHeader(status)
390			started = true
391		}
392		_, e := writer.Write(chunk.Data)
393		return e
394	})
395	if err == nil {
396		if !started {
397			w.WriteHeader(status)
398		}
399		return
400	}
401	if started {
402		log := zerowrap.FromCtx(ctx)
403		log.Warn().Err(err).Msg("github release download cut short")
404		panic(http.ErrAbortHandler)
405	}
406	clearHeaders()
407	if errors.Is(err, domain.ErrOutOfRange) {
408		w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
409		w.WriteHeader(416)
410		return
411	}
412	h.failRelease(ctx, w, p, err, "release-asset", "")
413}

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