1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package githubraw downloads a reusable workflow file from
4// raw.githubusercontent.com at a commit (ports.WorkflowFetcher). It sends no
5// credentials, bounds the time and the size of the download, and follows no
6// redirect to another host.
7package githubraw
8
9import (
10 "context"
11 "fmt"
12 "io"
13 "net/http"
14 "net/url"
15 "strings"
16 "time"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// DefaultBaseURL is where the files are.
23const DefaultBaseURL = "https://raw.githubusercontent.com"
24
25// DefaultTimeout bounds one download.
26const DefaultTimeout = 20 * time.Second
27
28// Config configures a Fetcher; tests replace BaseURL and Client.
29type Config struct {
30 BaseURL string
31 Timeout time.Duration
32 Client *http.Client
33}
34
35// Fetcher implements ports.WorkflowFetcher over HTTPS.
36type Fetcher struct {
37 base string
38 client *http.Client
39}
40
41var _ ports.WorkflowFetcher = (*Fetcher)(nil)
42
43// New builds a Fetcher.
44func New(cfg Config) *Fetcher {
45 if cfg.BaseURL == "" {
46 cfg.BaseURL = DefaultBaseURL
47 }
48 if cfg.Timeout <= 0 {
49 cfg.Timeout = DefaultTimeout
50 }
51 c := cfg.Client
52 if c == nil {
53 c = &http.Client{Transport: http.DefaultTransport}
54 }
55 cc := *c
56 cc.Timeout = cfg.Timeout
57 cc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
58 return &Fetcher{base: strings.TrimRight(cfg.BaseURL, "/"), client: &cc}
59}
60
61// FetchWorkflow downloads path of owner/repo at sha.
62func (f *Fetcher) FetchWorkflow(ctx context.Context, owner, repo, sha, path string, maxBytes int64) ([]byte, error) {
63 segs := []string{owner, repo, sha}
64 segs = append(segs, strings.Split(path, "/")...)
65 for _, s := range segs {
66 if s == "" || s == "." || s == ".." {
67 return nil, fmt.Errorf("%w: bad workflow location", domain.ErrInvalid)
68 }
69 }
70 esc := make([]string, len(segs))
71 for i, s := range segs {
72 esc[i] = url.PathEscape(s)
73 }
74 req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.base+"/"+strings.Join(esc, "/"), nil)
75 if err != nil {
76 return nil, err
77 }
78 res, err := f.client.Do(req)
79 if err != nil {
80 if ctx.Err() != nil {
81 return nil, ctx.Err()
82 }
83 return nil, fmt.Errorf("%w: download the reusable workflow: %w", domain.ErrUnavailable, err)
84 }
85 defer func() { _ = res.Body.Close() }()
86 switch res.StatusCode {
87 case http.StatusOK:
88 case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden:
89 return nil, domain.ErrNotFound
90 default:
91 return nil, fmt.Errorf("%w: the download answered %d", domain.ErrUnavailable, res.StatusCode)
92 }
93 data, err := io.ReadAll(io.LimitReader(res.Body, maxBytes+1))
94 if err != nil {
95 return nil, fmt.Errorf("%w: download the reusable workflow: %w", domain.ErrUnavailable, err)
96 }
97 if int64(len(data)) > maxBytes {
98 return nil, fmt.Errorf("%w: the reusable workflow is larger than %d bytes", domain.ErrResourceExhausted, maxBytes)
99 }
100 return data, nil
101}