v0.28.0 fca9155

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

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