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}