Преглед изворни кода

Merge branch 'master' of https://github.com/porter-dev/porter into main

sunguroku пре 5 година
родитељ
комит
becb342523

+ 10 - 2
dashboard/src/components/image-selector/TagList.tsx

@@ -82,18 +82,26 @@ export default class TagList extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <div>
+<>
         <TagNameAlt>
           <img src={info} /> Select Image Tag
         </TagNameAlt>
+              <StyledTagList>
         {this.renderTagList()}
-      </div>
+      </StyledTagList>
+      </>
     );
   }
 }
 
 TagList.contextType = Context;
 
+const StyledTagList = styled.div`
+  max-height: 175px;
+  position: relative;
+  overflow: auto;
+`;
+
 const TagName = styled.div`
   display: flex;
   width: 100%;

+ 1 - 1
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -71,7 +71,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
     if (loading) {
       return <LoadingWrapper><Loading /></LoadingWrapper>
     } else if (error || !contents) {
-      return <LoadingWrapper>Error loading repo contents</LoadingWrapper>
+      return <LoadingWrapper>Error loading repo contents.</LoadingWrapper>
     }
 
     return contents.map((item: FileType, i: number) => {

+ 6 - 3
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -40,11 +40,12 @@ export default class RepoSelector extends Component<PropsType, StateType> {
     let { currentProject, currentCluster } = this.context;
 
     // Get repos
-    api.getRepos('<token>', {
-    }, { id: currentProject.id }, (err: any, res: any) => {
+    api.getGitRepos('<token>', {
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
+        console.log(res.data);
         this.setState({ repos: res.data, loading: false, error: false });
       }
     });
@@ -55,7 +56,9 @@ export default class RepoSelector extends Component<PropsType, StateType> {
     if (loading) {
       return <LoadingWrapper><Loading /></LoadingWrapper>
     } else if (error || !repos) {
-      return <LoadingWrapper>Error loading repos</LoadingWrapper>
+      return <LoadingWrapper>Error loading repos.</LoadingWrapper>
+    } else if (repos.length == 0) {
+      return <LoadingWrapper>No connected repos found.</LoadingWrapper>
     }
 
     return repos.map((repo: RepoType, i: number) => {

+ 1 - 1
dashboard/src/main/home/Home.tsx

@@ -42,7 +42,7 @@ export default class Home extends Component<PropsType, StateType> {
     if (prevProps !== this.props && this.context.currentProject) {
 
       // Set view to dashboard on project change
-      if (this.state.prevProjectId !== this.context.currentProject.id) {
+      if (this.state.prevProjectId && this.state.prevProjectId !== this.context.currentProject.id) {
         this.setState({
           prevProjectId: this.context.currentProject.id,
           currentView: 'dashboard'

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -53,7 +53,6 @@ export default class ChartList extends Component<PropsType, StateType> {
         this.setState({ loading: false, error: true });
       } else {
         let charts = res.data || [];
-        console.log(charts)
         this.setState({ charts }, () => {
           this.setState({ loading: false, error: false });
         });

+ 50 - 13
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -29,6 +29,7 @@ type StateType = {
   selectedBranch: string,
   subdirectory: string,
   webhookToken: string,
+  highlightCopyButton: boolean,
 };
 
 export default class SettingsSection extends Component<PropsType, StateType> {
@@ -42,6 +43,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     selectedBranch: '',
     subdirectory: '',
     webhookToken: '',
+    highlightCopyButton: false,
   }
 
   // TODO: read in set image from form context instead of config
@@ -113,9 +115,9 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       return (
         <>
           <Helper>
-            Specify a container image and tag
+            Specify a container image and tag or
             <Highlight onClick={() => this.setState({ sourceType: 'repo' })}>
-              or link a repo
+              link a repo
             </Highlight>.
           </Helper>
           <ImageSelector
@@ -129,12 +131,17 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         </>
       );
     }
+
+    let { currentProject } = this.context;
     return (
       <>
         <Helper>
-          Select a repo to conenct to
+          Select a repo to connect to. You can 
+          <A padRight={true} href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}>
+            log in with GitHub
+          </A> or
           <Highlight onClick={() => this.setState({ sourceType: 'registry' })}>
-            or link a container registry
+            link an image registry
           </Highlight>.
         </Helper>
         <RepoSelector
@@ -150,18 +157,38 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     );
   }
 
+  renderWebhookSection = () => {
+    if (this.state.webhookToken) {
+      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=???&repository=???'`;
+      return (
+        <>
+          <Heading>Redeploy Webhook</Heading>
+          <Helper>Programmatically deploy by calling this secret webhook.</Helper>
+          <Webhook copiedToClipboard={this.state.highlightCopyButton}>
+            <div>{webhookText}</div>
+            <i 
+              className="material-icons"
+              onClick={() => { 
+                navigator.clipboard.writeText(webhookText);
+                this.setState({ highlightCopyButton: true });
+              }}
+              onMouseLeave={() => this.setState({ highlightCopyButton: false })}
+            >
+              content_copy
+            </i>
+          </Webhook>
+        </>
+      );
+    }
+  }
+
   render() {
     return (
       <Wrapper>
         <StyledSettingsSection>
           <Heading>Connected source</Heading>
           {this.renderSourceSection()}
-          <Heading>Redeploy Webhook</Heading>
-          <Helper>Programmatically deploy by calling this secret webhook.</Helper>
-          <Webhook>
-            <div>curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/{this.state.webhookToken}?commit=???&repository=???'</div>
-            <i className="material-icons">content_copy</i>
-          </Webhook>
+          {this.renderWebhookSection()}
         </StyledSettingsSection>
         <SaveButton
           text='Save Settings'
@@ -197,24 +224,34 @@ const Webhook = styled.div`
   
   > i {
     padding: 5px;
-    background: #ffffff22;
+    background: ${(props: { copiedToClipboard: boolean }) => props.copiedToClipboard ? '#616FEEcc' : '#ffffff22'};
     border-radius: 5px;
     position: absolute;
     right: 10px;
     font-size: 14px;
     cursor: pointer;
+    color: #ffffff;
 
     :hover {
-      background: #ffffff44;
+      background: ${(props: { copiedToClipboard: boolean }) => props.copiedToClipboard ? '' : '#ffffff44'};;
     }
   }
 `;
 
 const Highlight = styled.div`
-  color: #949effff;
+  color: #949eff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+  padding-right: ${(props: { padRight?: boolean }) => props.padRight ? '5px' : ''};
+`;
+
+const A = styled.a`
+  color: #949eff;
   text-decoration: underline;
   margin-left: 5px;
   cursor: pointer;
+  padding-right: ${(props: { padRight?: boolean }) => props.padRight ? '5px' : ''};
 `;
 
 const Wrapper = styled.div`

+ 25 - 6
dashboard/src/shared/api.tsx

@@ -137,7 +137,9 @@ const getBranches = baseApi<{}, { kind: string, repo: string }>('GET', pathParam
   return `/api/repos/${pathParams.kind}/${pathParams.repo}/branches`;
 });
 
-const getBranchContents = baseApi<{ dir: string }, {
+const getBranchContents = baseApi<{ 
+  dir: string 
+}, {
   kind: string,
   repo: string,
   branch: string
@@ -215,24 +217,41 @@ const createECR = baseApi<{
   return `/api/projects/${pathParams.id}/registries`;
 });
 
-const getImageRepos = baseApi<{}, {   
-  project_id: number,
-  registry_id: number,
+const getImageRepos = baseApi<{
+}, {
+  project_id: number, 
+  registry_id: number 
 }>('GET', pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories`;
 });
 
-const getImageTags = baseApi<{}, {   
+const getImageTags = baseApi<{
+}, {   
   project_id: number,
   registry_id: number,
   repo_name: string,
- }>('GET', pathParams => {
+}>('GET', pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
 });
 
+const linkGithubProject = baseApi<{
+}, {
+  project_id: number,
+}>('GET', pathParams => {
+  return `/api/oauth/projects/${pathParams.project_id}/github`;
+});
+
+const getGitRepos = baseApi<{  
+}, {
+  project_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
+  linkGithubProject,
+  getGitRepos,
   checkAuth,
   registerUser,
   logInUser,

+ 2 - 0
internal/repository/gitrepo.go

@@ -8,4 +8,6 @@ type GitRepoRepository interface {
 	CreateGitRepo(gr *models.GitRepo) (*models.GitRepo, error)
 	ReadGitRepo(id uint) (*models.GitRepo, error)
 	ListGitReposByProjectID(projectID uint) ([]*models.GitRepo, error)
+	UpdateGitRepo(gr *models.GitRepo) (*models.GitRepo, error)
+	DeleteGitRepo(gr *models.GitRepo) error
 }

+ 29 - 5
internal/repository/gorm/gitrepo.go

@@ -19,7 +19,7 @@ func NewGitRepoRepository(db *gorm.DB, key *[32]byte) repository.GitRepoReposito
 	return &GitRepoRepository{db, key}
 }
 
-// CreateGitRepo creates a new repo client and appends it to the in-memory list
+// CreateGitRepo creates a new git repo
 func (repo *GitRepoRepository) CreateGitRepo(gr *models.GitRepo) (*models.GitRepo, error) {
 	project := &models.Project{}
 
@@ -40,11 +40,10 @@ func (repo *GitRepoRepository) CreateGitRepo(gr *models.GitRepo) (*models.GitRep
 	return gr, nil
 }
 
-// ReadGitRepo returns a repo client by id
+// ReadGitRepo gets a git repo specified by a unique id
 func (repo *GitRepoRepository) ReadGitRepo(id uint) (*models.GitRepo, error) {
 	gr := &models.GitRepo{}
 
-	// preload Clusters association
 	if err := repo.db.Where("id = ?", id).First(&gr).Error; err != nil {
 		return nil, err
 	}
@@ -52,8 +51,11 @@ func (repo *GitRepoRepository) ReadGitRepo(id uint) (*models.GitRepo, error) {
 	return gr, nil
 }
 
-// ListGitReposByProjectID returns a list of repo clients that match a project id
-func (repo *GitRepoRepository) ListGitReposByProjectID(projectID uint) ([]*models.GitRepo, error) {
+// ListGitReposByProjectID finds all git repos
+// for a given project id
+func (repo *GitRepoRepository) ListGitReposByProjectID(
+	projectID uint,
+) ([]*models.GitRepo, error) {
 	grs := []*models.GitRepo{}
 
 	if err := repo.db.Where("project_id = ?", projectID).Find(&grs).Error; err != nil {
@@ -62,3 +64,25 @@ func (repo *GitRepoRepository) ListGitReposByProjectID(projectID uint) ([]*model
 
 	return grs, nil
 }
+
+// UpdateGitRepo modifies an existing GitRepo in the database
+func (repo *GitRepoRepository) UpdateGitRepo(
+	gr *models.GitRepo,
+) (*models.GitRepo, error) {
+	if err := repo.db.Save(gr).Error; err != nil {
+		return nil, err
+	}
+
+	return gr, nil
+}
+
+// DeleteGitRepo removes a git repo from the db
+func (repo *GitRepoRepository) DeleteGitRepo(
+	gr *models.GitRepo,
+) error {
+	if err := repo.db.Where("id = ?", gr.ID).Delete(&models.GitRepo{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}

+ 40 - 0
internal/repository/gorm/gitrepo_test.go

@@ -91,3 +91,43 @@ func TestListGitReposByProjectID(t *testing.T) {
 		t.Error(diff)
 	}
 }
+
+func TestUpdateGitRepo(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_update_gr.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initGitRepo(tester, t)
+	defer cleanup(tester, t)
+
+	gr := tester.initGRs[0]
+
+	gr.RepoEntity = "porter-dev-new-name"
+
+	gr, err := tester.repo.GitRepo.UpdateGitRepo(
+		gr,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	gr, err = tester.repo.GitRepo.ReadGitRepo(tester.initGRs[0].ID)
+
+	// make sure data is correct
+	expGR := models.GitRepo{
+		RepoEntity:         "porter-dev-new-name",
+		ProjectID:          tester.initProjects[0].Model.ID,
+		OAuthIntegrationID: tester.initOAuths[0].ID,
+	}
+
+	// reset fields for reflect.DeepEqual
+	gr.Model = orm.Model{}
+
+	if diff := deep.Equal(expGR, *gr); diff != nil {
+		t.Errorf("incorrect git repo")
+		t.Error(diff)
+	}
+}

+ 36 - 0
internal/repository/memory/gitrepo.go

@@ -65,3 +65,39 @@ func (repo *GitRepoRepository) ListGitReposByProjectID(projectID uint) ([]*model
 
 	return res, nil
 }
+
+// UpdateGitRepo modifies an existing GitRepo in the database
+func (repo *GitRepoRepository) UpdateGitRepo(
+	gr *models.GitRepo,
+) (*models.GitRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(gr.ID-1) >= len(repo.gitRepos) || repo.gitRepos[gr.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(gr.ID - 1)
+	repo.gitRepos[index] = gr
+
+	return gr, nil
+}
+
+// DeleteGitRepo removes a repoistry from the array by setting it to nil
+func (repo *GitRepoRepository) DeleteGitRepo(
+	gr *models.GitRepo,
+) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(gr.ID-1) >= len(repo.gitRepos) || repo.gitRepos[gr.ID-1] == nil {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(gr.ID - 1)
+	repo.gitRepos[index] = nil
+
+	return nil
+}

+ 191 - 0
server/api/git_repo_handler.go

@@ -0,0 +1,191 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"golang.org/x/oauth2"
+
+	"github.com/go-chi/chi"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// HandleListProjectGitRepos returns a list of git repos for a project
+func (app *App) HandleListProjectGitRepos(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	grs, err := app.Repo.GitRepo.ListGitReposByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extGRs := make([]*models.GitRepoExternal, 0)
+
+	for _, gr := range grs {
+		extGRs = append(extGRs, gr.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extGRs); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// Repo represents a GitHub or Gitab repository
+type Repo struct {
+	FullName string
+	Kind     string
+}
+
+// DirectoryItem represents a file or subfolder in a repository
+type DirectoryItem struct {
+	Path string
+	Type string
+}
+
+// HandleListRepos retrieves a list of repo names
+func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.githubTokenFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	res := make([]Repo, 0)
+
+	client := github.NewClient(app.GithubConf.Client(oauth2.NoContext, tok))
+
+	// list all repositories for specified user
+	repos, _, err := client.Repositories.List(context.Background(), "", nil)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// TODO -- check if repo has already been appended -- there may be duplicates
+	for _, repo := range repos {
+		res = append(res, Repo{
+			FullName: repo.GetFullName(),
+			Kind:     "github",
+		})
+	}
+
+	json.NewEncoder(w).Encode(res)
+}
+
+// HandleGetBranches retrieves a list of branch names for a specified repo
+func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.githubTokenFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	name := chi.URLParam(r, "name")
+
+	client := github.NewClient(app.GithubConf.Client(oauth2.NoContext, tok))
+
+	// List all branches for a specified repo
+	branches, _, err := client.Repositories.ListBranches(context.Background(), "", name, nil)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	res := []string{}
+	for _, b := range branches {
+		res = append(res, b.GetName())
+	}
+
+	json.NewEncoder(w).Encode(res)
+}
+
+// HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
+func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.githubTokenFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	client := github.NewClient(app.GithubConf.Client(oauth2.NoContext, tok))
+
+	queryParams, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	name := chi.URLParam(r, "name")
+	branch := chi.URLParam(r, "branch")
+
+	repoContentOptions := github.RepositoryContentGetOptions{}
+	repoContentOptions.Ref = branch
+	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "", name, queryParams["dir"][0], &repoContentOptions)
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	res := []DirectoryItem{}
+	for i := range directoryContents {
+		d := DirectoryItem{}
+		d.Path = *directoryContents[i].Path
+		d.Type = *directoryContents[i].Type
+		res = append(res, d)
+	}
+
+	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
+	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
+	// fmt.Println(res)
+	json.NewEncoder(w).Encode(res)
+}
+
+// finds the github token given the git repo id and the project id
+func (app *App) githubTokenFromRequest(
+	r *http.Request,
+) (*oauth2.Token, error) {
+	grID, err := strconv.ParseUint(chi.URLParam(r, "git_repo_id"), 0, 64)
+
+	if err != nil || grID == 0 {
+		return nil, fmt.Errorf("could not read git repo id")
+	}
+
+	// query for the git repo
+	gr, err := app.Repo.GitRepo.ReadGitRepo(uint(grID))
+
+	if err != nil {
+		return nil, err
+	}
+
+	// get the oauth integration
+	oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(gr.OAuthIntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &oauth2.Token{
+		AccessToken:  string(oauthInt.AccessToken),
+		RefreshToken: string(oauthInt.RefreshToken),
+		TokenType:    "Bearer",
+	}, nil
+}

+ 0 - 0
server/api/repo_handler_test.go → server/api/git_repo_handler_test.go


+ 191 - 180
server/api/oauth_github_handler.go

@@ -1,182 +1,193 @@
 package api
 
-// import (
-// 	"context"
-// 	"fmt"
-// 	"net/http"
-// 	"strconv"
-
-// 	"github.com/porter-dev/porter/internal/models"
-
-// 	"github.com/go-chi/chi"
-// 	"github.com/google/go-github/github"
-// 	"github.com/porter-dev/porter/internal/oauth"
-// 	"golang.org/x/oauth2"
-// )
-
-// // HandleGithubOAuthStartUser starts the oauth2 flow for a user login request.
-// func (app *App) HandleGithubOAuthStartUser(w http.ResponseWriter, r *http.Request) {
-// 	state := oauth.CreateRandomState()
-
-// 	err := app.populateOAuthSession(w, r, state, false)
-
-// 	if err != nil {
-// 		app.handleErrorDataRead(err, w)
-// 		return
-// 	}
-
-// 	// specify access type offline to get a refresh token
-// 	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOnline)
-
-// 	http.Redirect(w, r, url, 302)
-// }
-
-// // HandleGithubOAuthStartProject starts the oauth2 flow for a project repo request.
-// // In this handler, the project id gets written to the session (along with the oauth
-// // state param), so that the correct project id can be identified in the callback.
-// func (app *App) HandleGithubOAuthStartProject(w http.ResponseWriter, r *http.Request) {
-// 	state := oauth.CreateRandomState()
-
-// 	err := app.populateOAuthSession(w, r, state, true)
-
-// 	if err != nil {
-// 		app.handleErrorDataRead(err, w)
-// 		return
-// 	}
-
-// 	// specify access type offline to get a refresh token
-// 	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
-
-// 	http.Redirect(w, r, url, 302)
-// }
-
-// // HandleGithubOAuthCallback verifies the callback request by checking that the
-// // state parameter has not been modified, and validates the token.
-// // There is a difference between the oauth flow when logging a user in, and when
-// // linking a repository.
-// //
-// // When logging a user in, the access token gets stored in the session, and no refresh
-// // token is requested. We store the access token in the session because a user can be
-// // logged in multiple times with a single access token.
-// //
-// // NOTE: this user flow will likely be augmented with Dex, or entirely replaced with Dex.
-// //
-// // However, when linking a repository, the access token and refresh token are requested when
-// // the flow has started. A project also gets linked to the session. After callback, a new
-// // github config gets stored for the project, and the user will then get redirected to
-// // a URL that allows them to select their repositories they'd like to link. We require a refresh
-// // token because we need permanent access to the linked repository.
-// func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request) {
-// 	session, err := app.store.Get(r, app.cookieName)
-
-// 	if err != nil {
-// 		app.handleErrorDataRead(err, w)
-// 		return
-// 	}
-
-// 	if _, ok := session.Values["state"]; !ok {
-// 		app.sendExternalError(
-// 			err,
-// 			http.StatusForbidden,
-// 			HTTPError{
-// 				Code: http.StatusForbidden,
-// 				Errors: []string{
-// 					"Could not read cookie: are cookies enabled?",
-// 				},
-// 			},
-// 			w,
-// 		)
-
-// 		return
-// 	}
-
-// 	if r.URL.Query().Get("state") != session.Values["state"] {
-// 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-// 		return
-// 	}
-
-// 	token, err := app.GithubConfig.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
-
-// 	if err != nil {
-// 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-// 		return
-// 	}
-
-// 	if !token.Valid() {
-// 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-// 		return
-// 	}
-
-// 	userID, _ := session.Values["user_id"].(uint)
-// 	projID, _ := session.Values["project_id"].(uint)
-
-// 	app.updateProjectFromToken(projID, userID, token)
-
-// 	if session.Values["query_params"] != "" {
-// 		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
-// 	} else {
-// 		http.Redirect(w, r, "/dashboard", 302)
-// 	}
-// }
-
-// func (app *App) populateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
-// 	session, err := app.store.Get(r, app.cookieName)
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	// need state parameter to validate when redirected
-// 	session.Values["state"] = state
-
-// 	if isProject {
-// 		// read the project id and add it to the session
-// 		projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
-
-// 		if err != nil || projID == 0 {
-// 			return fmt.Errorf("could not read project id")
-// 		}
-
-// 		session.Values["project_id"] = projID
-// 		session.Values["query_params"] = r.URL.RawQuery
-// 	}
-
-// 	if err := session.Save(r, w); err != nil {
-// 		app.logger.Warn().Err(err)
-// 	}
-
-// 	return nil
-// }
-
-// func (app *App) upsertUserFromToken() error {
-// 	return fmt.Errorf("UNIMPLEMENTED")
-// }
-
-// // updates a project's repository clients with the token information.
-// func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.Token) error {
-// 	// get the list of repositories that this token has access to
-// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-// 	user, _, err := client.Users.Get(context.Background(), "")
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	repoClient := &models.RepoClient{
-// 		ProjectID:    projectID,
-// 		UserID:       userID,
-// 		RepoUserID:   uint(user.GetID()),
-// 		Kind:         models.RepoClientGithub,
-// 		AccessToken:  []byte(tok.AccessToken),
-// 		RefreshToken: []byte(tok.RefreshToken),
-// 	}
-
-// 	repoClient, err = app.repo.RepoClient.CreateRepoClient(repoClient)
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	return nil
-// }
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/go-chi/chi"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// HandleGithubOAuthStartUser starts the oauth2 flow for a user login request.
+func (app *App) HandleGithubOAuthStartUser(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, false)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GithubConf.AuthCodeURL(state, oauth2.AccessTypeOnline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubOAuthStartProject starts the oauth2 flow for a project repo request.
+// In this handler, the project id gets written to the session (along with the oauth
+// state param), so that the correct project id can be identified in the callback.
+func (app *App) HandleGithubOAuthStartProject(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, true)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GithubConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubOAuthCallback verifies the callback request by checking that the
+// state parameter has not been modified, and validates the token.
+// There is a difference between the oauth flow when logging a user in, and when
+// linking a repository.
+//
+// When logging a user in, the access token gets stored in the session, and no refresh
+// token is requested. We store the access token in the session because a user can be
+// logged in multiple times with a single access token.
+//
+// NOTE: this user flow will likely be augmented with Dex, or entirely replaced with Dex.
+//
+// However, when linking a repository, the access token and refresh token are requested when
+// the flow has started. A project also gets linked to the session. After callback, a new
+// github config gets stored for the project, and the user will then get redirected to
+// a URL that allows them to select their repositories they'd like to link. We require a refresh
+// token because we need permanent access to the linked repository.
+func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	token, err := app.GithubConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	if !token.Valid() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+
+	app.updateProjectFromToken(projID, userID, token)
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}
+
+func (app *App) populateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		return err
+	}
+
+	// need state parameter to validate when redirected
+	session.Values["state"] = state
+
+	if isProject {
+		// read the project id and add it to the session
+		projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+		if err != nil || projID == 0 {
+			return fmt.Errorf("could not read project id")
+		}
+
+		session.Values["project_id"] = projID
+		session.Values["query_params"] = r.URL.RawQuery
+	}
+
+	if err := session.Save(r, w); err != nil {
+		app.Logger.Warn().Err(err)
+	}
+
+	return nil
+}
+
+func (app *App) upsertUserFromToken() error {
+	return fmt.Errorf("UNIMPLEMENTED")
+}
+
+// updates a project's repository clients with the token information.
+func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.Token) error {
+	// get the list of repositories that this token has access to
+	client := github.NewClient(app.GithubConf.Client(oauth2.NoContext, tok))
+
+	user, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		return err
+	}
+
+	oauthInt := &integrations.OAuthIntegration{
+		Client:       integrations.OAuthGithub,
+		UserID:       userID,
+		ProjectID:    projectID,
+		AccessToken:  []byte(tok.AccessToken),
+		RefreshToken: []byte(tok.RefreshToken),
+	}
+
+	// create the oauth integration first
+	oauthInt, err = app.Repo.OAuthIntegration.CreateOAuthIntegration(oauthInt)
+
+	if err != nil {
+		return err
+	}
+
+	// create the git repo
+	gr := &models.GitRepo{
+		ProjectID:          projectID,
+		RepoEntity:         *user.Name,
+		OAuthIntegrationID: oauthInt.ID,
+	}
+
+	gr, err = app.Repo.GitRepo.CreateGitRepo(gr)
+
+	return err
+}

+ 0 - 175
server/api/repo_handler.go

@@ -1,175 +0,0 @@
-package api
-
-// import (
-// 	"context"
-// 	"encoding/json"
-// 	"fmt"
-// 	"net/http"
-// 	"net/url"
-// 	"strconv"
-
-// 	"golang.org/x/oauth2"
-
-// 	"github.com/go-chi/chi"
-// 	"github.com/google/go-github/v32/github"
-// )
-
-// // Repo represents a GitHub or Gitab repository
-// type Repo struct {
-// 	FullName string
-// 	Kind     string
-// }
-
-// // DirectoryItem represents a file or subfolder in a repository
-// type DirectoryItem struct {
-// 	Path string
-// 	Type string
-// }
-
-// // HandleListRepos retrieves a list of repo names
-// func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
-// 	tok, err := app.githubTokenFromRequest(r)
-
-// 	if err != nil {
-// 		app.handleErrorInternal(err, w)
-// 		return
-// 	}
-
-// 	res := make([]Repo, 0)
-
-// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-// 	// list all repositories for specified user
-// 	repos, _, err := client.Repositories.List(context.Background(), "", nil)
-
-// 	if err != nil {
-// 		app.handleErrorInternal(err, w)
-// 		return
-// 	}
-
-// 	// TODO -- check if repo has already been appended -- there may be duplicates
-// 	for _, repo := range repos {
-// 		res = append(res, Repo{
-// 			FullName: repo.GetFullName(),
-// 			Kind:     "github",
-// 		})
-// 	}
-
-// 	json.NewEncoder(w).Encode(res)
-// }
-
-// // HandleGetBranches retrieves a list of branch names for a specified repo
-// func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
-// 	tok, err := app.githubTokenFromRequest(r)
-
-// 	if err != nil {
-// 		app.handleErrorInternal(err, w)
-// 		return
-// 	}
-
-// 	name := chi.URLParam(r, "name")
-
-// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-// 	// List all branches for a specified repo
-// 	branches, _, err := client.Repositories.ListBranches(context.Background(), "", name, nil)
-// 	if err != nil {
-// 		fmt.Println(err)
-// 		return
-// 	}
-
-// 	res := []string{}
-// 	for _, b := range branches {
-// 		res = append(res, b.GetName())
-// 	}
-
-// 	json.NewEncoder(w).Encode(res)
-// }
-
-// // HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
-// func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
-// 	tok, err := app.githubTokenFromRequest(r)
-
-// 	if err != nil {
-// 		app.handleErrorInternal(err, w)
-// 		return
-// 	}
-
-// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-// 	queryParams, err := url.ParseQuery(r.URL.RawQuery)
-// 	if err != nil {
-// 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
-// 		return
-// 	}
-
-// 	name := chi.URLParam(r, "name")
-// 	branch := chi.URLParam(r, "branch")
-
-// 	repoContentOptions := github.RepositoryContentGetOptions{}
-// 	repoContentOptions.Ref = branch
-// 	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "", name, queryParams["dir"][0], &repoContentOptions)
-// 	if err != nil {
-// 		app.handleErrorInternal(err, w)
-// 		return
-// 	}
-
-// 	res := []DirectoryItem{}
-// 	for i := range directoryContents {
-// 		d := DirectoryItem{}
-// 		d.Path = *directoryContents[i].Path
-// 		d.Type = *directoryContents[i].Type
-// 		res = append(res, d)
-// 	}
-
-// 	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
-// 	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
-// 	// fmt.Println(res)
-// 	json.NewEncoder(w).Encode(res)
-// }
-
-// func (app *App) githubTokenFromRequest(
-// 	r *http.Request,
-// ) (*oauth2.Token, error) {
-// 	// read project id
-// 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
-
-// 	if err != nil || projID == 0 {
-// 		return nil, fmt.Errorf("could not read project id")
-// 	}
-
-// 	// read user id
-// 	session, err := app.store.Get(r, app.cookieName)
-
-// 	if err != nil {
-// 		return nil, fmt.Errorf("could not read user id")
-// 	}
-
-// 	userID, ok := session.Values["user_id"].(uint)
-
-// 	if !ok {
-// 		return nil, fmt.Errorf("could not read user id")
-// 	}
-
-// 	// query for repo client
-// 	gitRepos, err := app.repo.GitRepo.ListGitReposByProjectID(uint(projID))
-
-// 	if err != nil {
-// 		return nil, err
-// 	}
-
-// 	for _, rc := range repoClients {
-// 		// find the RepoClient that matches the user id in the request
-// 		if rc.UserID == userID {
-// 			// TODO -- refresh token is irrelevant at the moment, because the access token
-// 			// doesn't expire.
-// 			return &oauth2.Token{
-// 				AccessToken:  string(rc.AccessToken),
-// 				RefreshToken: string(rc.RefreshToken),
-// 				TokenType:    "Bearer",
-// 			}, nil
-// 		}
-// 	}
-
-// 	return nil, fmt.Errorf("could not find matching token")
-// }

+ 99 - 0
server/router/middleware/auth.go

@@ -74,6 +74,10 @@ type bodyRegistryID struct {
 	RegistryID uint64 `json:"registry_id"`
 }
 
+type bodyGitRepoID struct {
+	GitRepoID uint64 `json:"git_repo_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
@@ -260,6 +264,56 @@ func (auth *Auth) DoesUserHaveRegistryAccess(
 	})
 }
 
+// DoesUserHaveGitRepoAccess looks for a project_id parameter and a
+// git_repo_id parameter, and verifies that the git repo belongs
+// to the project
+func (auth *Auth) DoesUserHaveGitRepoAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	gitRepoLoc IDLocation,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		grID, err := findGitRepoIDInRequest(r, gitRepoLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		// get the service accounts belonging to the project
+		grs, err := auth.repo.GitRepo.ListGitReposByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, gr := range grs {
+			if gr.ID == uint(grID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
 // Helpers
 func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 	session, _ := auth.store.Get(r, auth.cookieName)
@@ -466,3 +520,48 @@ func findRegistryIDInRequest(r *http.Request, registryLoc IDLocation) (uint64, e
 
 	return regID, nil
 }
+
+func findGitRepoIDInRequest(r *http.Request, gitRepoLoc IDLocation) (uint64, error) {
+	var grID uint64
+	var err error
+
+	if gitRepoLoc == URLParam {
+		grID, err = strconv.ParseUint(chi.URLParam(r, "git_repo_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if gitRepoLoc == BodyParam {
+		form := &bodyGitRepoID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		grID = form.GitRepoID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if regStrArr, ok := vals["git_repo_id"]; ok && len(regStrArr) == 1 {
+			grID, err = strconv.ParseUint(regStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("git repo id not found")
+		}
+	}
+
+	return grID, nil
+}

+ 64 - 42
server/router/router.go

@@ -132,21 +132,21 @@ func New(a *api.App) *chi.Mux {
 		)
 
 		// /api/oauth routes
-		// r.Method(
-		// 	"GET",
-		// 	"/oauth/projects/{project_id}/github",
-		// 	auth.DoesUserHaveProjectAccess(
-		// 		requestlog.NewHandler(a.HandleGithubOAuthStartProject, l),
-		// 		mw.URLParam,
-		// 		mw.WriteAccess,
-		// 	),
-		// )
+		r.Method(
+			"GET",
+			"/oauth/projects/{project_id}/github",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGithubOAuthStartProject, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
 
-		// r.Method(
-		// 	"GET",
-		// 	"/oauth/github/callback",
-		// 	requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
-		// )
+		r.Method(
+			"GET",
+			"/oauth/github/callback",
+			requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
+		)
 
 		// /api/projects routes
 		r.Method(
@@ -539,36 +539,58 @@ func New(a *api.App) *chi.Mux {
 			requestlog.NewHandler(a.HandleReleaseDeployWebhook, l),
 		)
 
-		// /api/projects/{project_id}/repos routes
-		// r.Method(
-		// 	"GET",
-		// 	"/projects/{project_id}/repos",
-		// 	auth.DoesUserHaveProjectAccess(
-		// 		requestlog.NewHandler(a.HandleListRepos, l),
-		// 		mw.URLParam,
-		// 		mw.ReadAccess,
-		// 	),
-		// )
+		// /api/projects/{project_id}/gitrepos routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/gitrepos",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectGitRepos, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 
-		// r.Method(
-		// 	"GET",
-		// 	"/projects/{project_id}/repos/{kind}/{name}/branches",
-		// 	auth.DoesUserHaveProjectAccess(
-		// 		requestlog.NewHandler(a.HandleGetBranches, l),
-		// 		mw.URLParam,
-		// 		mw.ReadAccess,
-		// 	),
-		// )
+		r.Method(
+			"GET",
+			"/projects/{project_id}/gitrepos/{git_repo_id}/repos",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveGitRepoAccess(
+					requestlog.NewHandler(a.HandleListRepos, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 
-		// r.Method(
-		// 	"GET",
-		// 	"/projects/{project_id}/repos/{kind}/{name}/{branch}/contents",
-		// 	auth.DoesUserHaveProjectAccess(
-		// 		requestlog.NewHandler(a.HandleGetBranchContents, l),
-		// 		mw.URLParam,
-		// 		mw.ReadAccess,
-		// 	),
-		// )
+		r.Method(
+			"GET",
+			"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{name}/branches",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveGitRepoAccess(
+					requestlog.NewHandler(a.HandleGetBranches, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{name}/{branch}/contents",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveGitRepoAccess(
+					requestlog.NewHandler(a.HandleGetBranchContents, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 
 		// /api/projects/{project_id}/deploy routes
 		r.Method(