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}