diff --git a/cmd/server.go b/cmd/server.go index 4d867fa52d..62e9174f72 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -74,7 +74,7 @@ const redTermEnd = "\033[39m" var stringFlags = []stringFlag{ { name: AtlantisURLFlag, - description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ".", + description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path, e.g. https://example.com/basepath", }, { name: BitbucketUserFlag, diff --git a/server/locks_controller.go b/server/locks_controller.go index 8000e9984e..83a5c21cb3 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -16,6 +16,7 @@ import ( // LocksController handles all requests relating to Atlantis locks. type LocksController struct { AtlantisVersion string + AtlantisURL url.URL Locker locking.Locker Logger *logging.SimpleLogger VCSClient vcs.ClientProxy @@ -57,6 +58,7 @@ func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) { LockedBy: lock.Pull.Author, Workspace: lock.Workspace, AtlantisVersion: l.AtlantisVersion, + AtlantisURL: l.AtlantisURL, } l.LockDetailTemplate.Execute(w, viewData) // nolint: errcheck } diff --git a/server/router.go b/server/router.go index 4f4e2840da..f9f284d5d6 100644 --- a/server/router.go +++ b/server/router.go @@ -21,11 +21,11 @@ type Router struct { LockViewRouteIDQueryParam string // AtlantisURL is the fully qualified URL (scheme included) that Atlantis is // being served at, ex: https://example.com. - AtlantisURL string + AtlantisURL url.URL } // GenerateLockURL returns a fully qualified URL to view the lock at lockID. func (r *Router) GenerateLockURL(lockID string) string { path, _ := r.Underlying.Get(r.LockViewRouteName).URL(r.LockViewRouteIDQueryParam, url.QueryEscape(lockID)) - return fmt.Sprintf("%s%s", r.AtlantisURL, path) + return fmt.Sprintf("%s%s", r.AtlantisURL.String(), path) } diff --git a/server/router_test.go b/server/router_test.go index 91bf9f3fc9..6efef7e4a2 100644 --- a/server/router_test.go +++ b/server/router_test.go @@ -2,6 +2,7 @@ package server_test import ( "net/http" + "net/url" "testing" "github.com/gorilla/mux" @@ -12,13 +13,14 @@ import ( func TestRouter_GenerateLockURL(t *testing.T) { queryParam := "queryparam" routeName := "routename" - atlantisURL := "https://example.com" + atlantisURL, err := url.Parse("https://example.com") + Ok(t, err) underlyingRouter := mux.NewRouter() underlyingRouter.HandleFunc("/lock", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Queries(queryParam, "{queryparam}").Name(routeName) router := &server.Router{ - AtlantisURL: atlantisURL, + AtlantisURL: *atlantisURL, LockViewRouteIDQueryParam: queryParam, LockViewRouteName: routeName, Underlying: underlyingRouter, diff --git a/server/server.go b/server/server.go index d938335077..ac74e48b1a 100644 --- a/server/server.go +++ b/server/server.go @@ -64,6 +64,7 @@ const ( // Server runs the Atlantis web server. type Server struct { AtlantisVersion string + AtlantisURL url.URL Router *mux.Router Port int CommandRunner *events.DefaultCommandRunner @@ -229,9 +230,17 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { projectLocker := &events.DefaultProjectLocker{ Locker: lockingClient, } + atlantisURL, err := url.Parse(userConfig.AtlantisURL) + if err != nil { + return nil, errors.Wrap(err, "parsing atlantis URL") + } + atlantisURL, err = NormalizeBaseURL(atlantisURL) + if err != nil { + return nil, errors.Wrap(err, "normalizing atlantis URL") + } underlyingRouter := mux.NewRouter() router := &Router{ - AtlantisURL: userConfig.AtlantisURL, + AtlantisURL: *atlantisURL, LockViewRouteIDQueryParam: LockViewRouteIDQueryParam, LockViewRouteName: LockViewRouteName, Underlying: underlyingRouter, @@ -309,6 +318,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } locksController := &LocksController{ AtlantisVersion: config.AtlantisVersion, + AtlantisURL: *atlantisURL, Locker: lockingClient, Logger: logger, VCSClient: vcsClient, @@ -334,6 +344,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } return &Server{ AtlantisVersion: config.AtlantisVersion, + AtlantisURL: *atlantisURL, Router: underlyingRouter, Port: userConfig.Port, CommandRunner: commandRunner, @@ -411,7 +422,7 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { for id, v := range locks { lockURL, _ := s.Router.Get(LockViewRouteName).URL("id", url.QueryEscape(id)) lockResults = append(lockResults, LockIndexData{ - LockURL: lockURL.String(), + LockURL: *lockURL, RepoFullName: v.Project.RepoFullName, PullNum: v.Pull.Num, Time: v.Time, @@ -421,6 +432,7 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { s.IndexTemplate.Execute(w, IndexData{ Locks: lockResults, AtlantisVersion: s.AtlantisVersion, + AtlantisURL: s.AtlantisURL, }) } diff --git a/server/server_test.go b/server/server_test.go index 5e6e408e6c..7a9147cbbb 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -19,6 +19,7 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -37,7 +38,8 @@ func TestNewServer(t *testing.T) { tmpDir, err := ioutil.TempDir("", "") Ok(t, err) _, err = server.NewServer(server.UserConfig{ - DataDir: tmpDir, + DataDir: tmpDir, + AtlantisURL: "http://example.com", }, server.Config{}) Ok(t, err) } @@ -91,7 +93,7 @@ func TestIndex_Success(t *testing.T) { it.VerifyWasCalledOnce().Execute(w, server.IndexData{ Locks: []server.LockIndexData{ { - LockURL: "", + LockURL: url.URL{}, RepoFullName: "owner/repo", PullNum: 9, Time: now, diff --git a/server/url.go b/server/url.go new file mode 100644 index 0000000000..8bfb7e57db --- /dev/null +++ b/server/url.go @@ -0,0 +1,24 @@ +package server + +import ( + "fmt" + "net/url" + "strings" +) + +// NormalizeBaseURL ensures the given URL is a valid base URL for Atlantis. +// +// URLs that are fundamentally invalid (e.g. "hi") will return an error. +// Otherwise, the returned URL will have no trailing slashes and be guaranteed +// to be suitable for use as a base URL. +func NormalizeBaseURL(u *url.URL) (*url.URL, error) { + if !u.IsAbs() { + return nil, fmt.Errorf("Base URLs must be absolute.") + } + if !(u.Scheme == "http" || u.Scheme == "https") { + return nil, fmt.Errorf("Base URLs must be HTTP or HTTPS.") + } + out := *u + out.Path = strings.TrimRight(out.Path, "/") + return &out, nil +} diff --git a/server/url_test.go b/server/url_test.go new file mode 100644 index 0000000000..3fb001c59c --- /dev/null +++ b/server/url_test.go @@ -0,0 +1,62 @@ +package server_test + +import ( + "net/url" + "testing" + + "github.com/runatlantis/atlantis/server" + . "github.com/runatlantis/atlantis/testing" +) + +func TestNormalizeBaseURL_Valid(t *testing.T) { + t.Log("When given a valid base URL, NormalizeBaseURL returns such URLs unchanged.") + examples := []string{ + "https://example.com", + "https://example.com/some/path", + "http://example.com:8080", + } + for _, example := range examples { + url, err := url.Parse(example) + Ok(t, err) + normalized, err := server.NormalizeBaseURL(url) + Ok(t, err) + Equals(t, url, normalized) + } +} + +func TestNormalizeBaseURL_Relative(t *testing.T) { + t.Log("We do not allow relative URLs as base URLs.") + _, err := server.NormalizeBaseURL(&url.URL{Path: "hi"}) + Assert(t, err != nil, "should be an error") + Equals(t, "Base URLs must be absolute.", err.Error()) +} + +func TestNormalizeBaseURL_NonHTTP(t *testing.T) { + t.Log("Base URLs must be http or https.") + _, err := server.NormalizeBaseURL(&url.URL{Scheme: "ftp", Host: "example", Path: "hi"}) + Assert(t, err != nil, "should be an error") + Equals(t, "Base URLs must be HTTP or HTTPS.", err.Error()) +} + +func TestNormalizeBaseURL_TrailingSlashes(t *testing.T) { + t.Log("We strip off any trailing slashes from the base URL.") + examples := []struct { + input string + output string + }{ + {"https://example.com/", "https://example.com"}, + {"https://example.com/some/path/", "https://example.com/some/path"}, + {"http://example.com:8080/", "http://example.com:8080"}, + {"https://example.com//", "https://example.com"}, + {"https://example.com/path///", "https://example.com/path"}, + } + for _, example := range examples { + inputURL, err := url.Parse(example.input) + Ok(t, err) + outputURL, err := url.Parse(example.output) + Ok(t, err) + normalized, err := server.NormalizeBaseURL(inputURL) + Ok(t, err) + Equals(t, outputURL, normalized) + } +} diff --git a/server/web_templates.go b/server/web_templates.go index de1fb8a7c9..b25e141959 100644 --- a/server/web_templates.go +++ b/server/web_templates.go @@ -16,6 +16,7 @@ package server import ( "html/template" "io" + "net/url" "time" ) @@ -31,7 +32,7 @@ type TemplateWriter interface { // LockIndexData holds the fields needed to display the index view for locks. type LockIndexData struct { - LockURL string + LockURL url.URL RepoFullName string PullNum int Time time.Time @@ -41,6 +42,7 @@ type LockIndexData struct { type IndexData struct { Locks []LockIndexData AtlantisVersion string + AtlantisURL url.URL } var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(` @@ -52,7 +54,7 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(` - + - - - - + + + +
- +

atlantis

Plan discarded and unlocked!

@@ -83,7 +85,7 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`

Locks

{{ if .Locks }} {{ range .Locks }} - +
{{.RepoFullName}} - #{{.PullNum}}
Locked
@@ -105,7 +107,6 @@ v{{ .AtlantisVersion }} // LockDetailData holds the fields needed to display the lock detail view. type LockDetailData struct { - UnlockURL string LockKeyEncoded string LockKey string RepoOwner string @@ -115,6 +116,7 @@ type LockDetailData struct { Workspace string Time time.Time AtlantisVersion string + AtlantisURL url.URL } var lockTemplate = template.Must(template.New("lock.html.tmpl").Parse(` @@ -126,16 +128,16 @@ var lockTemplate = template.Must(template.New("lock.html.tmpl").Parse(` - - - - - + + + + +
- +

atlantis

{{.LockKey}} Locked

@@ -200,10 +202,10 @@ v{{ .AtlantisVersion }} btnDiscard.click(function() { $.ajax({ - url: '/locks?id='+lockId, + url: '{{ .AtlantisURL }}/locks?id='+lockId, type: 'DELETE', success: function(result) { - window.location.replace("/?discard=true"); + window.location.replace("{{ .AtlantisURL }}/?discard=true"); } }); });