1 package ghinstallation
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "io/ioutil"
10 "net/http"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/google/go-github/v45/github"
16 )
17
18 const (
19 acceptHeader = "application/vnd.github.v3+json"
20 apiBaseURL = "https://api.github.com"
21 )
22
23
24
25
26
27
28
29
30
31 type Transport struct {
32 BaseURL string
33 Client Client
34 tr http.RoundTripper
35 appID int64
36 installationID int64
37 InstallationTokenOptions *github.InstallationTokenOptions
38 appsTransport *AppsTransport
39
40 mu *sync.Mutex
41 token *accessToken
42 }
43
44
45 type accessToken struct {
46 Token string `json:"token"`
47 ExpiresAt time.Time `json:"expires_at"`
48 Permissions github.InstallationPermissions `json:"permissions,omitempty"`
49 Repositories []github.Repository `json:"repositories,omitempty"`
50 }
51
52
53
54
55 type HTTPError struct {
56 Message string
57 RootCause error
58 InstallationID int64
59 Response *http.Response
60 }
61
62 func (e *HTTPError) Error() string {
63 return e.Message
64 }
65
66 var _ http.RoundTripper = &Transport{}
67
68
69 func NewKeyFromFile(tr http.RoundTripper, appID, installationID int64, privateKeyFile string) (*Transport, error) {
70 privateKey, err := ioutil.ReadFile(privateKeyFile)
71 if err != nil {
72 return nil, fmt.Errorf("could not read private key: %s", err)
73 }
74 return New(tr, appID, installationID, privateKey)
75 }
76
77
78
79 type Client interface {
80 Do(*http.Request) (*http.Response, error)
81 }
82
83
84
85
86
87
88
89
90 func New(tr http.RoundTripper, appID, installationID int64, privateKey []byte) (*Transport, error) {
91 atr, err := NewAppsTransport(tr, appID, privateKey)
92 if err != nil {
93 return nil, err
94 }
95
96 return NewFromAppsTransport(atr, installationID), nil
97 }
98
99
100 func NewFromAppsTransport(atr *AppsTransport, installationID int64) *Transport {
101 return &Transport{
102 BaseURL: atr.BaseURL,
103 Client: &http.Client{Transport: atr.tr},
104 tr: atr.tr,
105 appID: atr.appID,
106 installationID: installationID,
107 appsTransport: atr,
108 mu: &sync.Mutex{},
109 }
110 }
111
112
113 func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
114 reqBodyClosed := false
115 if req.Body != nil {
116 defer func() {
117 if !reqBodyClosed {
118 req.Body.Close()
119 }
120 }()
121 }
122
123 token, err := t.Token(req.Context())
124 if err != nil {
125 return nil, err
126 }
127
128 creq := cloneRequest(req)
129 creq.Header.Set("Authorization", "token "+token)
130 creq.Header.Add("Accept", acceptHeader)
131 reqBodyClosed = true
132 resp, err := t.tr.RoundTrip(creq)
133 return resp, err
134 }
135
136
137
138 func (t *Transport) Token(ctx context.Context) (string, error) {
139 t.mu.Lock()
140 defer t.mu.Unlock()
141 if t.token == nil || t.token.ExpiresAt.Add(-time.Minute).Before(time.Now()) {
142
143 if err := t.refreshToken(ctx); err != nil {
144 return "", fmt.Errorf("could not refresh installation id %v's token: %w", t.installationID, err)
145 }
146 }
147
148 return t.token.Token, nil
149 }
150
151
152 func (t *Transport) Permissions() (github.InstallationPermissions, error) {
153 if t.token == nil {
154 return github.InstallationPermissions{}, fmt.Errorf("Permissions() = nil, err: nil token")
155 }
156 return t.token.Permissions, nil
157 }
158
159
160 func (t *Transport) Repositories() ([]github.Repository, error) {
161 if t.token == nil {
162 return nil, fmt.Errorf("Repositories() = nil, err: nil token")
163 }
164 return t.token.Repositories, nil
165 }
166
167 func (t *Transport) refreshToken(ctx context.Context) error {
168
169 body, err := GetReadWriter(t.InstallationTokenOptions)
170 if err != nil {
171 return fmt.Errorf("could not convert installation token parameters into json: %s", err)
172 }
173
174 requestURL := fmt.Sprintf("%s/app/installations/%v/access_tokens", strings.TrimRight(t.BaseURL, "/"), t.installationID)
175 req, err := http.NewRequest("POST", requestURL, body)
176 if err != nil {
177 return fmt.Errorf("could not create request: %s", err)
178 }
179
180
181 if body != nil {
182 req.Header.Set("Content-Type", "application/json")
183 }
184 req.Header.Set("Accept", acceptHeader)
185
186 if ctx != nil {
187 req = req.WithContext(ctx)
188 }
189
190 t.appsTransport.BaseURL = t.BaseURL
191 t.appsTransport.Client = t.Client
192 resp, err := t.appsTransport.RoundTrip(req)
193 e := &HTTPError{
194 RootCause: err,
195 InstallationID: t.installationID,
196 Response: resp,
197 }
198 if err != nil {
199 e.Message = fmt.Sprintf("could not get access_tokens from GitHub API for installation ID %v: %v", t.installationID, err)
200 return e
201 }
202
203 if resp.StatusCode/100 != 2 {
204 e.Message = fmt.Sprintf("received non 2xx response status %q when fetching %v", resp.Status, req.URL)
205 return e
206 }
207
208 defer resp.Body.Close()
209
210 return json.NewDecoder(resp.Body).Decode(&t.token)
211 }
212
213
214 func GetReadWriter(i interface{}) (io.ReadWriter, error) {
215 var buf io.ReadWriter
216 if i != nil {
217 buf = new(bytes.Buffer)
218 enc := json.NewEncoder(buf)
219 err := enc.Encode(i)
220 if err != nil {
221 return nil, err
222 }
223 }
224 return buf, nil
225 }
226
227
228
229 func cloneRequest(r *http.Request) *http.Request {
230
231 r2 := new(http.Request)
232 *r2 = *r
233
234 r2.Header = make(http.Header, len(r.Header))
235 for k, s := range r.Header {
236 r2.Header[k] = append([]string(nil), s...)
237 }
238 return r2
239 }
240
View as plain text