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}