Skip to content

Commit 8180932

Browse files
authored
Merge pull request netlify#20 from netlify/bitbucket
Git Gateway support for BitBucket
2 parents a9002bf + f52a11b commit 8180932

File tree

4 files changed

+281
-10
lines changed

4 files changed

+281
-10
lines changed

api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
7777
}
7878
r.With(api.requireAuthentication).Mount("/github", NewGitHubGateway())
7979
r.With(api.requireAuthentication).Mount("/gitlab", NewGitLabGateway())
80+
r.With(api.requireAuthentication).Mount("/bitbucket", NewBitBucketGateway())
8081
r.With(api.requireAuthentication).Get("/settings", api.Settings)
8182
})
8283

api/bitbucket.go

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"compress/gzip"
6+
"context"
7+
"encoding/json"
8+
"errors"
9+
"io"
10+
"io/ioutil"
11+
"net/http"
12+
"net/http/httputil"
13+
"net/url"
14+
"regexp"
15+
"strings"
16+
"sync"
17+
18+
"golang.org/x/oauth2"
19+
"golang.org/x/oauth2/bitbucket"
20+
)
21+
22+
type BitBucketGateway struct {
23+
proxy *httputil.ReverseProxy
24+
}
25+
26+
func NewBitBucketGateway() *BitBucketGateway {
27+
return &BitBucketGateway{
28+
proxy: &httputil.ReverseProxy{
29+
Director: bitbucketDirector,
30+
Transport: &BitBucketTransport{},
31+
},
32+
}
33+
}
34+
35+
var bitbucketPathRegexp = regexp.MustCompile("^/bitbucket/?")
36+
var bitbucketAllowedRegexp = regexp.MustCompile("^/bitbucket/src/?")
37+
38+
var bitbucketTokenExpirationMessageRegexp = regexp.MustCompile("(?i)^access token expired")
39+
var currentAccessToken *oauth2.Token
40+
41+
type notifyRefreshTokenSource struct {
42+
new oauth2.TokenSource
43+
mu sync.Mutex // guards t
44+
t *oauth2.Token
45+
}
46+
47+
func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) {
48+
s.mu.Lock()
49+
defer s.mu.Unlock()
50+
if s.t.Valid() {
51+
return s.t, nil
52+
}
53+
t, err := s.new.Token()
54+
if err != nil {
55+
return nil, err
56+
}
57+
s.t = t
58+
return t, nil
59+
}
60+
61+
var currentTokenSource *notifyRefreshTokenSource
62+
63+
func getTokenSource(ctx context.Context) *notifyRefreshTokenSource {
64+
config := getConfig(ctx)
65+
if currentTokenSource != nil {
66+
return currentTokenSource
67+
}
68+
69+
blankToken := &oauth2.Token{
70+
AccessToken: "",
71+
RefreshToken: config.BitBucket.RefreshToken,
72+
}
73+
oauthConfig := &oauth2.Config{
74+
ClientID: config.BitBucket.ClientID,
75+
ClientSecret: config.BitBucket.ClientSecret,
76+
Endpoint: bitbucket.Endpoint,
77+
}
78+
tokenSource := &notifyRefreshTokenSource{
79+
new: oauthConfig.TokenSource(ctx, blankToken),
80+
t: blankToken,
81+
}
82+
currentTokenSource = tokenSource
83+
return tokenSource
84+
}
85+
86+
func bitbucketDirector(r *http.Request) {
87+
ctx := r.Context()
88+
target := getProxyTarget(ctx)
89+
accessToken := getAccessToken(ctx)
90+
91+
targetQuery := target.RawQuery
92+
r.Host = target.Host
93+
r.URL.Scheme = target.Scheme
94+
r.URL.Host = target.Host
95+
r.URL.Path = singleJoiningSlash(target.Path, bitbucketPathRegexp.ReplaceAllString(r.URL.Path, "/"))
96+
if targetQuery == "" || r.URL.RawQuery == "" {
97+
r.URL.RawQuery = targetQuery + r.URL.RawQuery
98+
} else {
99+
r.URL.RawQuery = targetQuery + "&" + r.URL.RawQuery
100+
}
101+
if _, ok := r.Header["User-Agent"]; !ok {
102+
r.Header.Set("User-Agent", "")
103+
}
104+
105+
if r.Method != http.MethodOptions {
106+
r.Header.Set("Authorization", "Bearer "+accessToken)
107+
}
108+
109+
log := getLogEntry(r)
110+
log.Infof("Proxying to BitBucket: %v", r.URL.String(), r.Header.Get("Authorization"))
111+
}
112+
113+
func (bb *BitBucketGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
114+
ctx := r.Context()
115+
config := getConfig(ctx)
116+
if config == nil || config.BitBucket.RefreshToken == "" {
117+
handleError(notFoundError("No BitBucket Settings Configured"), w, r)
118+
return
119+
}
120+
121+
if err := bb.authenticate(w, r); err != nil {
122+
handleError(unauthorizedError(err.Error()), w, r)
123+
return
124+
}
125+
126+
endpoint := config.BitBucket.Endpoint
127+
apiURL := singleJoiningSlash(endpoint, "/repositories/"+config.BitBucket.Repo)
128+
target, err := url.Parse(apiURL)
129+
if err != nil {
130+
handleError(internalServerError("Unable to process BitBucket endpoint"), w, r)
131+
return
132+
}
133+
134+
tokenSource := getTokenSource(ctx)
135+
token, err := tokenSource.Token()
136+
if err != nil {
137+
handleError(internalServerError("Unable to process BitBucket endpoint"), w, r)
138+
}
139+
140+
ctx = withProxyTarget(ctx, target)
141+
ctx = withAccessToken(ctx, token.AccessToken)
142+
bb.proxy.ServeHTTP(w, r.WithContext(ctx))
143+
}
144+
145+
func (bb *BitBucketGateway) authenticate(w http.ResponseWriter, r *http.Request) error {
146+
ctx := r.Context()
147+
claims := getClaims(ctx)
148+
config := getConfig(ctx)
149+
150+
if claims == nil {
151+
return errors.New("Access to endpoint not allowed: no claims found in Bearer token")
152+
}
153+
154+
if !bitbucketAllowedRegexp.MatchString(r.URL.Path) {
155+
return errors.New("Access to endpoint not allowed: this part of BitBucket's API has been restricted")
156+
}
157+
158+
if len(config.Roles) == 0 {
159+
return nil
160+
}
161+
162+
roles, ok := claims.AppMetaData["roles"]
163+
if ok {
164+
roleStrings, _ := roles.([]interface{})
165+
for _, data := range roleStrings {
166+
role, _ := data.(string)
167+
for _, adminRole := range config.Roles {
168+
if role == adminRole {
169+
return nil
170+
}
171+
}
172+
}
173+
}
174+
175+
return errors.New("Access to endpoint not allowed: your role doesn't allow access")
176+
}
177+
178+
func rewriteBitBucketLink(link, endpointAPIURL, proxyAPIURL string) string {
179+
return proxyAPIURL + strings.TrimPrefix(link, endpointAPIURL)
180+
}
181+
182+
func rewriteLinksInBitBucketResponse(resp *http.Response, endpointAPIURL, proxyAPIURL string) error {
183+
var bodyReader io.ReadCloser
184+
var err error
185+
switch resp.Header.Get("Content-Encoding") {
186+
case "gzip":
187+
bodyReader, err = gzip.NewReader(resp.Body)
188+
if err != nil {
189+
return nil
190+
}
191+
defer bodyReader.Close()
192+
default:
193+
bodyReader = resp.Body
194+
}
195+
body, err := ioutil.ReadAll(bodyReader)
196+
if err != nil {
197+
return nil
198+
}
199+
200+
var b map[string]interface{}
201+
if err := json.Unmarshal(body, &b); err != nil {
202+
return nil
203+
}
204+
205+
if next, ok := b["next"].(string); ok {
206+
b["next"] = rewriteBitBucketLink(next, endpointAPIURL, proxyAPIURL)
207+
}
208+
209+
if prev, ok := b["previous"].(string); ok {
210+
b["previous"] = rewriteBitBucketLink(prev, endpointAPIURL, proxyAPIURL)
211+
}
212+
213+
newBodyBytes, err := json.Marshal(b)
214+
215+
switch resp.Header.Get("Content-Encoding") {
216+
case "gzip":
217+
var compressedBody bytes.Buffer
218+
w := gzip.NewWriter(&compressedBody)
219+
defer w.Close()
220+
_, err = w.Write(newBodyBytes)
221+
if err != nil {
222+
return err
223+
}
224+
resp.Body = ioutil.NopCloser(&compressedBody)
225+
default:
226+
resp.Body = ioutil.NopCloser(bytes.NewReader(newBodyBytes))
227+
}
228+
229+
return nil
230+
}
231+
232+
type BitBucketTransport struct{}
233+
234+
func (t *BitBucketTransport) RoundTrip(r *http.Request) (*http.Response, error) {
235+
ctx := r.Context()
236+
config := getConfig(ctx)
237+
resp, err := http.DefaultTransport.RoundTrip(r)
238+
if err != nil {
239+
return resp, err
240+
}
241+
242+
// remove CORS headers from BitBucket and use our own
243+
resp.Header.Del("Access-Control-Allow-Origin")
244+
245+
if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
246+
repo := config.BitBucket.Repo
247+
apiURL := singleJoiningSlash(config.BitBucket.Endpoint, "/repositories/"+repo)
248+
err = rewriteLinksInBitBucketResponse(resp, apiURL, "")
249+
if err != nil {
250+
return resp, err
251+
}
252+
}
253+
254+
return resp, nil
255+
}

api/settings.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@ package api
33
import "net/http"
44

55
type Settings struct {
6-
GitHub bool `json:"github_enabled"`
7-
GitLab bool `json:"gitlab_enabled"`
8-
Roles []string `json:"roles"`
6+
GitHub bool `json:"github_enabled"`
7+
GitLab bool `json:"gitlab_enabled"`
8+
BitBucket bool `json:"bitbucket_enabled"`
9+
Roles []string `json:"roles"`
910
}
1011

1112
func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
1213
ctx := r.Context()
1314
config := getConfig(ctx)
1415

1516
settings := Settings{
16-
GitHub: config.GitHub.Repo != "",
17-
GitLab: config.GitLab.Repo != "",
18-
Roles: config.Roles,
17+
GitHub: config.GitHub.Repo != "",
18+
GitLab: config.GitLab.Repo != "",
19+
BitBucket: config.BitBucket.Repo != "",
20+
Roles: config.Roles,
1921
}
2022

2123
return sendJSON(w, http.StatusOK, &settings)

conf/configuration.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
const DefaultGitHubEndpoint = "https://api.github.com"
1212
const DefaultGitLabEndpoint = "https://gitlab.com/api/v4"
1313
const DefaultGitLabTokenType = "oauth"
14+
const DefaultBitBucketEndpoint = "https://api.bitbucket.org/2.0"
1415

1516
type GitHubConfig struct {
1617
AccessToken string `envconfig:"ACCESS_TOKEN" json:"access_token,omitempty"`
@@ -25,6 +26,14 @@ type GitLabConfig struct {
2526
Repo string `envconfig:"REPO" json:"repo"` // Should be "owner/repo" format
2627
}
2728

29+
type BitBucketConfig struct {
30+
RefreshToken string `envconfig:"REFRESH_TOKEN" json:"refresh_token,omitempty"`
31+
ClientID string `envconfig:"CLIENT_ID" json:"client_id,omitempty"`
32+
ClientSecret string `envconfig:"CLIENT_SECRET" json:"client_secret,omitempty"`
33+
Endpoint string `envconfig:"ENDPOINT" json:"endpoint"`
34+
Repo string `envconfig:"REPO" json:"repo"`
35+
}
36+
2837
// DBConfiguration holds all the database related configuration.
2938
type DBConfiguration struct {
3039
Dialect string `json:"dialect"`
@@ -54,10 +63,11 @@ type GlobalConfiguration struct {
5463

5564
// Configuration holds all the per-instance configuration.
5665
type Configuration struct {
57-
JWT JWTConfiguration `json:"jwt"`
58-
GitHub GitHubConfig `envconfig:"GITHUB" json:"github"`
59-
GitLab GitLabConfig `envconfig:"GITLAB" json:"gitlab"`
60-
Roles []string `envconfig:"ROLES" json:"roles"`
66+
JWT JWTConfiguration `json:"jwt"`
67+
GitHub GitHubConfig `envconfig:"GITHUB" json:"github"`
68+
GitLab GitLabConfig `envconfig:"GITLAB" json:"gitlab"`
69+
BitBucket BitBucketConfig `envconfig:"BITBUCKET" json:"bitbucket"`
70+
Roles []string `envconfig:"ROLES" json:"roles"`
6171
}
6272

6373
func loadEnvironment(filename string) error {
@@ -115,4 +125,7 @@ func (config *Configuration) ApplyDefaults() {
115125
if config.GitLab.AccessTokenType == "" {
116126
config.GitLab.AccessTokenType = DefaultGitLabTokenType
117127
}
128+
if config.BitBucket.Endpoint == "" {
129+
config.BitBucket.Endpoint = DefaultBitBucketEndpoint
130+
}
118131
}

0 commit comments

Comments
 (0)