package server import ( "context" "encoding/json" "fmt" "net/http" "net/url" "github.com/google/go-github/v28/github" "github.com/runatlantis/atlantis/server/logging" ) // GithubAppController handles the creation and setup of a new GitHub app type GithubAppController struct { AtlantisURL *url.URL Logger *logging.SimpleLogger GithubSetupComplete bool GithubHostname string GithubOrg string } type githubWebhook struct { URL string `json:"url"` Active bool `json:"active"` } // githubAppRequest contains the query parameters for // https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest type githubAppRequest struct { Description string `json:"description"` Events []string `json:"default_events"` Name string `json:"name"` Permissions map[string]string `json:"default_permissions"` Public bool `json:"public"` RedirectURL string `json:"redirect_url"` URL string `json:"url"` Webhook *githubWebhook `json:"hook_attributes"` } // ExchangeCode handles the user coming back from creating their app // A code query parameter is exchanged for this app's ID, key, and webhook_secret // Implements https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest/#implementing-the-github-app-manifest-flow func (g *GithubAppController) ExchangeCode(w http.ResponseWriter, r *http.Request) { if g.GithubSetupComplete { g.respond(w, logging.Error, http.StatusBadRequest, "Atlantis already has GitHub credentials") return } code := r.URL.Query().Get("code") if code == "" { g.respond(w, logging.Debug, http.StatusOK, "Ignoring callback, missing code query parameter") } g.Logger.Debug("Exchanging GitHub app code for app credentials") tr := http.DefaultTransport client := github.NewClient(&http.Client{Transport: tr}) ctx := context.Background() app := &struct { ID int64 `json:"id"` Key string `json:"pem"` WebhookSecret string `json:"webhook_secret"` Name string `json:"name"` }{} url := fmt.Sprintf("https://api.%s/app-manifests/%s/conversions", g.GithubHostname, code) req, err := http.NewRequest("POST", url, nil) req.Header.Add("Accept", "application/vnd.github.fury-preview+json") if err != nil { g.respond(w, logging.Error, http.StatusBadGateway, "Error creating http request to exchange token: %s", err) return } _, err = client.Do(ctx, req, app) if err != nil { g.respond(w, logging.Error, http.StatusBadGateway, "Error exchanging code for token: %s", err) return } g.Logger.Debug("Found credentials for GitHub app %q with id %d", app.Name, app.ID) err = githubAppSetupTemplate.Execute(w, GithubSetupData{ Target: "", Manifest: "", ID: app.ID, Key: app.Key, WebhookSecret: app.WebhookSecret, }) if err != nil { g.Logger.Err(err.Error()) } } // New redirects the user to create a new GitHub app func (g *GithubAppController) New(w http.ResponseWriter, r *http.Request) { if g.GithubSetupComplete { g.respond(w, logging.Error, http.StatusBadRequest, "Atlantis already has GitHub credentials") return } manifest := &githubAppRequest{ Name: "Atlantis", Description: fmt.Sprintf("Terraform Pull Request Automation at %s", g.AtlantisURL), URL: g.AtlantisURL.String(), RedirectURL: fmt.Sprintf("%s/github-app/exchange-code", g.AtlantisURL), Public: false, Webhook: &githubWebhook{ URL: fmt.Sprintf("%s/events", g.AtlantisURL), }, Events: []string{ "check_run", "create", "delete", "pull_request", "push", "issues", }, Permissions: map[string]string{ "checks": "write", "contents": "write", "issues": "write", "pull_requests": "write", "repository_hooks": "write", "statuses": "write", }, } url := &url.URL{ Scheme: "https", Host: g.GithubHostname, Path: "/settings/apps/new", } // https://developer.github.com/apps/building-github-apps/creating-github-apps-using-url-parameters/#about-github-app-url-parameters if g.GithubOrg != "" { url.Path = fmt.Sprintf("organizations/%s%s", g.GithubOrg, url.Path) } jsonManifest, err := json.MarshalIndent(manifest, "", " ") if err != nil { g.respond(w, logging.Error, http.StatusBadRequest, "Failed to serialize manifest: %s", err) return } err = githubAppSetupTemplate.Execute(w, GithubSetupData{ Target: url.String(), Manifest: string(jsonManifest), }) if err != nil { g.Logger.Err(err.Error()) } } func (g *GithubAppController) respond(w http.ResponseWriter, lvl logging.LogLevel, code int, format string, args ...interface{}) { response := fmt.Sprintf(format, args...) g.Logger.Log(lvl, response) w.WriteHeader(code) fmt.Fprintln(w, response) }