v0.18.0 0cea275

actions_artifacts.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"mime"
9	"net/http"
10	"strconv"
11	"strings"
12	"time"
13	"unicode"
14
15	"github.com/bnema/zerowrap"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19)
20
21// The artifacts of a run (CI-4 D3), read through ports.ArtifactService. Only a
22// signed-in caller may list or download them, even on a public repository.
23//
24//	GET  /<repo>/actions/runs/<id>/artifacts/<artifact id>          the content
25//	POST /<repo>/actions/runs/<id>/artifacts/<artifact id>/delete   (the owner)
26
27// artifactTimeout bounds the download of one artifact.
28const artifactTimeout = 30 * time.Minute
29
30func artifactHref(repo string, runID, id int64) string {
31	return runHref(repo, runID) + "/artifacts/" + strconv.FormatInt(id, 10)
32}
33
34// ArtifactRow is an artifact of the run page.
35type ArtifactRow struct {
36	Name, Href, Size string
37	// Expires is the expiry for the eye, ExpiresAt as RFC 3339.
38	Expires, ExpiresAt string
39	// Job is the job that uploaded it, JobHref its page ("" when it is not a
40	// job of the attempt shown).
41	Job, JobHref string
42	// DeleteAction is the form that deletes it; "" when the visitor cannot.
43	DeleteAction string
44}
45
46// ArtifactsView is the artifacts section of the run page.
47type ArtifactsView struct {
48	Rows []ArtifactRow
49	// Usage is the space the artifacts of the repository use.
50	Usage string
51}
52
53// runArtifacts is the artifacts section of a run, nil when there is nothing to
54// show: no artifacts on this server, a visitor who cannot list them, no
55// artifact, or a failed read, which is logged; the run page never fails for it.
56//
57//hexcheck:ignore suspicious-business-logic-in-adapter builds the artifacts section from the use case results; which errors hide it is presentation, no rule is decided here
58func (s *Server) runArtifacts(ctx context.Context, repo domain.RepoName, p RunPage) *ArtifactsView {
59	if s.cfg.Artifacts == nil || !SignedIn(ctx) {
60		return nil
61	}
62	runID := p.detail.Run.ID
63	list, err := s.cfg.Artifacts.ListArtifacts(ctx, principal(ctx), repo, runID)
64	if err != nil {
65		if !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, domain.ErrUnauthenticated) &&
66			!errors.Is(err, domain.ErrForbidden) && !errors.Is(err, context.Canceled) {
67			log := zerowrap.FromCtx(ctx)
68			log.Warn().Err(err).Str("repo", repo.String()).Int64("run", runID).Msg("list the artifacts of the run")
69		}
70		return nil
71	}
72	if len(list.Artifacts) == 0 {
73		return nil
74	}
75	jobs := map[string]string{}
76	for _, j := range p.detail.Jobs {
77		jobs[j.ID] = j.Name
78	}
79	name := repo.String()
80	v := &ArtifactsView{Usage: humanSize(list.Used) + " used"}
81	if list.Quota > 0 {
82		v.Usage = humanSize(list.Used) + " of " + humanSize(list.Quota) + " used"
83	}
84	for _, a := range list.Artifacts {
85		row := ArtifactRow{
86			Name: visibleText(a.Name), Href: artifactHref(name, runID, a.ID), Size: humanSize(a.Size),
87			Expires: a.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC"), ExpiresAt: rfc3339(a.ExpiresAt),
88			Job: visibleText(a.JobID),
89		}
90		if a.ExpiresAt.IsZero() {
91			row.Expires = "never"
92		}
93		if n, ok := jobs[a.JobID]; ok {
94			row.Job, row.JobHref = visibleText(n), jobHref(name, runID, a.JobID)
95		}
96		row.DeleteAction = artifactHref(name, runID, a.ID) + "/delete"
97		v.Rows = append(v.Rows, row)
98	}
99	return v
100}
101
102func parseArtifactPath(r *http.Request) (int64, error) {
103	id, err := strconv.ParseInt(r.PathValue("artifact"), 10, 64)
104	if err != nil || id < 1 {
105		return 0, errNotFound
106	}
107	return id, nil
108}
109
110// artifactDownload is GET /<repo>/actions/runs/<id>/artifacts/<artifact id>:
111// it streams the content as an attachment. The media type the uploader chose
112// is never served: a zip is application/zip, anything else
113// application/octet-stream, and the answer is sandboxed and not sniffed, so
114// that nothing uploaded by a job can run in the origin of the forge.
115//
116//hexcheck:ignore suspicious-business-logic-in-adapter streams what the use case sends; the headers are about the response, no rule is decided here
117func (s *Server) artifactDownload(w http.ResponseWriter, r *http.Request) {
118	ctx, cancel := context.WithTimeout(r.Context(), artifactTimeout)
119	defer cancel()
120	repo, runID, err := s.runTarget(r)
121	if err != nil {
122		s.fail(ctx, w, r, err)
123		return
124	}
125	id, err := parseArtifactPath(r)
126	if err != nil {
127		s.fail(ctx, w, r, err)
128		return
129	}
130	var started, headOnly bool
131	var writeErr error
132	err = s.cfg.Artifacts.DownloadArtifact(ctx, principal(ctx), repo, id, 0, func(c ports.ArtifactChunk) error {
133		if !started {
134			if c.Artifact == nil {
135				return errors.New("web: the download did not start with the artifact")
136			}
137			if c.Artifact.RunID != runID {
138				return errNotFound // an artifact of another run of the repository
139			}
140			h := w.Header()
141			artifactHeaders(h, *c.Artifact)
142			cacheHeaders(ctx, h, noStore, sessionCache)
143			w.WriteHeader(http.StatusOK)
144			started = true
145			if r.Method == http.MethodHead {
146				headOnly = true
147				return errStopDownload
148			}
149		}
150		if len(c.Data) == 0 {
151			return nil
152		}
153		if _, werr := w.Write(c.Data); werr != nil {
154			writeErr = werr
155			return werr
156		}
157		return nil
158	})
159	switch {
160	case err == nil, headOnly, writeErr != nil:
161		// Done, or the visitor is gone.
162	case !started:
163		s.artifactFailed(ctx, w, r, err, runHref(repo.String(), runID))
164	default:
165		// The headers are gone: cut the answer short, so that the client
166		// sees it is incomplete.
167		log := zerowrap.FromCtx(ctx)
168		log.Error().Err(err).Str("repo", repo.String()).Int64("artifact", id).Msg("artifact download failed")
169		panic(http.ErrAbortHandler)
170	}
171}
172
173// errStopDownload ends the download of a HEAD request after the headers.
174var errStopDownload = errors.New("web: download stopped")
175
176// artifactFailed answers a download refused before any byte was sent.
177func (s *Server) artifactFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
178	if errors.Is(err, domain.ErrUnauthenticated) || errors.Is(err, domain.ErrForbidden) {
179		s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "sign in to download the artifacts of a run.", back)
180		return
181	}
182	s.fail(ctx, w, r, err)
183}
184
185// artifactHeaders are the headers of the content of a.
186func artifactHeaders(h http.Header, a domain.CIArtifact) {
187	name := attachmentName(a.Name)
188	typ := "application/octet-stream"
189	if a.MediaType == domain.CIArtifactZip {
190		typ = domain.CIArtifactZip
191		name += ".zip"
192	}
193	h.Set("Content-Type", typ)
194	h.Set("Content-Disposition", contentDisposition(name))
195	h.Set("X-Content-Type-Options", "nosniff")
196	h.Set("Content-Security-Policy", rawCSP)
197	h.Set("Content-Length", strconv.FormatInt(a.Size, 10))
198}
199
200// attachmentName is an artifact name fit for a file name: without control
201// characters, quotes, backslashes or slashes (which the domain already
202// refuses), "artifact" when nothing is left.
203func attachmentName(name string) string {
204	name = strings.Map(func(r rune) rune {
205		if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar {
206			return -1
207		}
208		return r
209	}, name)
210	name = strings.TrimSpace(name)
211	if name == "" || name == "." || name == ".." {
212		return "artifact"
213	}
214	return name
215}
216
217// contentDisposition is an attachment named name: a quoted ASCII filename,
218// and for a name that is not ASCII its RFC 5987 form as well, with the ASCII
219// one as the fallback (non-ASCII characters replaced by '_').
220func contentDisposition(name string) string {
221	ascii := true
222	fallback := strings.Map(func(r rune) rune {
223		if r > unicode.MaxASCII {
224			ascii = false
225			return '_'
226		}
227		return r
228	}, name)
229	cd := mime.FormatMediaType("attachment", map[string]string{"filename": fallback})
230	if cd == "" {
231		cd = "attachment"
232	}
233	if !ascii {
234		cd += "; filename*=UTF-8''" + rfc5987(name)
235	}
236	return cd
237}
238
239// rfc5987 percent-encodes s but for the attr-char of RFC 5987.
240func rfc5987(s string) string {
241	const hexDigits = "0123456789ABCDEF"
242	var b strings.Builder
243	for i := 0; i < len(s); i++ {
244		c := s[i]
245		if ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
246			b.WriteByte(c)
247			continue
248		}
249		b.WriteByte('%')
250		b.WriteByte(hexDigits[c>>4])
251		b.WriteByte(hexDigits[c&15])
252	}
253	return b.String()
254}
255
256// artifactDelete is POST /<repo>/actions/runs/<id>/artifacts/<artifact id>/delete.
257func (s *Server) artifactDelete(ctx context.Context, r *http.Request) (string, error) {
258	repo, runID, err := s.runTarget(r)
259	if err != nil {
260		return "", err
261	}
262	id, err := parseArtifactPath(r)
263	if err != nil {
264		return "", err
265	}
266	if err := s.cfg.Artifacts.DeleteArtifact(ctx, principal(ctx), repo, id); err != nil {
267		return "", err
268	}
269	return runHref(repo.String(), runID), nil
270}

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