diff options
author | Nick Thomas <nick@gitlab.com> | 2017-09-08 12:00:21 +0300 |
---|---|---|
committer | Nick Thomas <nick@gitlab.com> | 2017-09-08 12:00:21 +0300 |
commit | 095a3406a96593fed87649073611c5844af02276 (patch) | |
tree | 61cc37a8840db1ae180da269c19367cdff5460be | |
parent | 243bdbc4980905220706bdb9215a7d802eced502 (diff) | |
parent | b1dc6395fefaf68c2f0197df7f0e30b8a750d1bb (diff) |
Merge branch 'master' of gitlab.com:gitlab-org/gitlab-pages
-rw-r--r-- | 404.go | 70 | ||||
-rw-r--r-- | acceptance_test.go | 97 | ||||
-rw-r--r-- | app.go | 20 | ||||
-rw-r--r-- | app_config.go | 9 | ||||
-rw-r--r-- | domain.go | 5 | ||||
-rw-r--r-- | internal/artifact/artifact.go | 120 | ||||
-rw-r--r-- | internal/artifact/artifact_test.go | 303 | ||||
-rw-r--r-- | internal/httperrors/httperrors.go | 162 | ||||
-rw-r--r-- | internal/httperrors/httperrors_test.go | 105 | ||||
-rw-r--r-- | main.go | 51 |
10 files changed, 851 insertions, 91 deletions
diff --git a/404.go b/404.go deleted file mode 100644 index 0ac0be54..00000000 --- a/404.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "fmt" - "net/http" -) - -const predefined404 = ` -<!DOCTYPE html> -<html> - <head> - <title>The page you're looking for could not be found (404)</title> - <style> - body { - color: #666; - text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; - margin: auto; - font-size: 14px; - } - - h1 { - font-size: 56px; - line-height: 100px; - font-weight: normal; - color: #456; - } - - h2 { - font-size: 24px; - color: #666; - line-height: 1.5em; - } - - h3 { - color: #456; - font-size: 20px; - font-weight: normal; - line-height: 28px; - } - - hr { - margin: 18px 0; - border: 0; - border-top: 1px solid #EEE; - border-bottom: 1px solid white; - } - </style> - </head> - - <body> - <h1> - <img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjIxMHB4IiBoZWlnaHQ9IjIxMHB4IiB2aWV3Qm94PSIwIDAgMjEwIDIxMCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWxuczpza2V0Y2g9Imh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaC9ucyI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDMuMy4yICgxMjA0MykgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+U2xpY2UgMTwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPgogICAgICAgIDxnIGlkPSJsb2dvIiBza2V0Y2g6dHlwZT0iTVNMYXllckdyb3VwIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLjAwMDAwMCwgMTAuMDAwMDAwKSI+CiAgICAgICAgICAgIDxnIGlkPSJQYWdlLTEiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPgogICAgICAgICAgICAgICAgPGcgaWQ9IkZpbGwtMS0rLUdyb3VwLTI0Ij4KICAgICAgICAgICAgICAgICAgICA8ZyBpZD0iR3JvdXAtMjQiPgogICAgICAgICAgICAgICAgICAgICAgICA8ZyBpZD0iR3JvdXAiPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEwNS4wNjE0LDE5My42NTUgTDEwNS4wNjE0LDE5My42NTUgTDE0My43MDE0LDc0LjczNCBMNjYuNDIxNCw3NC43MzQgTDEwNS4wNjE0LDE5My42NTUgTDEwNS4wNjE0LDE5My42NTUgWiIgaWQ9IkZpbGwtNCIgZmlsbD0iI0UyNDMyOSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEwNS4wNjE0LDE5My42NTQ4IEw2Ni40MjE0LDc0LjczMzggTDEyLjI2ODQsNzQuNzMzOCBMMTA1LjA2MTQsMTkzLjY1NDggTDEwNS4wNjE0LDE5My42NTQ4IFoiIGlkPSJGaWxsLTgiIGZpbGw9IiNGQzZEMjYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMi4yNjg1LDc0LjczNDEgTDEyLjI2ODUsNzQuNzM0MSBMMC41MjY1LDExMC44NzMxIEMtMC41NDQ1LDExNC4xNjkxIDAuNjI4NSwxMTcuNzgwMSAzLjQzMjUsMTE5LjgxNzEgTDEwNS4wNjE1LDE5My42NTUxIEwxMi4yNjg1LDc0LjczNDEgTDEyLjI2ODUsNzQuNzM0MSBaIiBpZD0iRmlsbC0xMiIgZmlsbD0iI0ZDQTMyNiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEyLjI2ODUsNzQuNzM0MiBMNjYuNDIxNSw3NC43MzQyIEw0My4xNDg1LDMuMTA5MiBDNDEuOTUxNSwtMC41NzY4IDM2LjczNzUsLTAuNTc1OCAzNS41NDA1LDMuMTA5MiBMMTIuMjY4NSw3NC43MzQyIEwxMi4yNjg1LDc0LjczNDIgWiIgaWQ9IkZpbGwtMTYiIGZpbGw9IiNFMjQzMjkiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMDUuMDYxNCwxOTMuNjU0OCBMMTQzLjcwMTQsNzQuNzMzOCBMMTk3Ljg1NDQsNzQuNzMzOCBMMTA1LjA2MTQsMTkzLjY1NDggTDEwNS4wNjE0LDE5My42NTQ4IFoiIGlkPSJGaWxsLTE4IiBmaWxsPSIjRkM2RDI2Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTk3Ljg1NDQsNzQuNzM0MSBMMTk3Ljg1NDQsNzQuNzM0MSBMMjA5LjU5NjQsMTEwLjg3MzEgQzIxMC42Njc0LDExNC4xNjkxIDIwOS40OTQ0LDExNy43ODAxIDIwNi42OTA0LDExOS44MTcxIEwxMDUuMDYxNCwxOTMuNjU1MSBMMTk3Ljg1NDQsNzQuNzM0MSBMMTk3Ljg1NDQsNzQuNzM0MSBaIiBpZD0iRmlsbC0yMCIgZmlsbD0iI0ZDQTMyNiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE5Ny44NTQ0LDc0LjczNDIgTDE0My43MDE0LDc0LjczNDIgTDE2Ni45NzQ0LDMuMTA5MiBDMTY4LjE3MTQsLTAuNTc2OCAxNzMuMzg1NCwtMC41NzU4IDE3NC41ODI0LDMuMTA5MiBMMTk3Ljg1NDQsNzQuNzM0MiBMMTk3Ljg1NDQsNzQuNzM0MiBaIiBpZD0iRmlsbC0yMiIgZmlsbD0iI0UyNDMyOSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4=" /><br /> - 404 - </h1> - <h3>The page you're looking for could not be found.</h3> - <hr/> - <p>Make sure the address is correct and that the page hasn't moved.</p> - </body> -</html> -` - -func serve404(w http.ResponseWriter) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(http.StatusNotFound) - fmt.Fprintln(w, predefined404) -} diff --git a/acceptance_test.go b/acceptance_test.go index 8adef2a6..3e575bdf 100644 --- a/acceptance_test.go +++ b/acceptance_test.go @@ -2,10 +2,13 @@ package main import ( "flag" + "fmt" "io/ioutil" "net/http" + "net/http/httptest" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -207,3 +210,97 @@ func TestStatusPage(t *testing.T) { defer rsp.Body.Close() assert.Equal(t, http.StatusOK, rsp.StatusCode) } + +func TestProxyRequest(t *testing.T) { + skipUnlessEnabled(t) + content := "<!DOCTYPE html><html><head><title>Title of the document</title></head><body></body></html>" + contentLength := int64(len(content)) + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/projects/1/jobs/2/artifacts/delayed_200.html": + time.Sleep(2 * time.Second) + case "/projects/1/jobs/2/artifacts/200.html": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, content) + case "/projects/1/jobs/2/artifacts/500.html": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, content) + default: + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, content) + } + })) + defer testServer.Close() + cases := []struct { + Path string + Status int + BinaryOption string + Content string + Length int64 + CacheControl string + ContentType string + Description string + }{ + { + "200.html", + http.StatusOK, + "", + content, + contentLength, + "max-age=3600", + "text/html; charset=utf-8", + "basic proxied request", + }, + { + "delayed_200.html", + http.StatusBadGateway, + "-artifacts-server-timeout=1", + "", + 0, + "", + "text/html; charset=utf-8", + "502 error while attempting to proxy", + }, + { + "404.html", + http.StatusNotFound, + "", + "", + 0, + "", + "text/html; charset=utf-8", + "Proxying 404 from server", + }, + { + "500.html", + http.StatusInternalServerError, + "", + "", + 0, + "", + "text/html; charset=utf-8", + "Proxying 500 from server", + }, + } + + for _, c := range cases { + t.Run(fmt.Sprintf("Proxy Request Test: %s", c.Description), func(t *testing.T) { + teardown := RunPagesProcess(t, *pagesBinary, listeners, "", "-artifacts-server="+testServer.URL, c.BinaryOption) + defer teardown() + resp, err := GetPageFromListener(t, httpListener, "artifact~1~2.gitlab-example.com", c.Path) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, c.Status, resp.StatusCode) + assert.Equal(t, c.ContentType, resp.Header.Get("Content-Type")) + if !((c.Status == http.StatusBadGateway) || (c.Status == http.StatusNotFound) || (c.Status == http.StatusInternalServerError)) { + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, c.Content, string(body)) + assert.Equal(t, c.Length, resp.ContentLength) + assert.Equal(t, c.CacheControl, resp.Header.Get("Cache-Control")) + } + }) + } +} @@ -14,6 +14,8 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/cors" + "gitlab.com/gitlab-org/gitlab-pages/internal/artifact" + "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" "gitlab.com/gitlab-org/gitlab-pages/metrics" ) @@ -26,8 +28,9 @@ var ( type theApp struct { appConfig - domains domains - lock sync.RWMutex + domains domains + lock sync.RWMutex + Artifact *artifact.Artifact } func (a *theApp) domain(host string) *domain { @@ -79,9 +82,16 @@ func (a *theApp) serveContent(ww http.ResponseWriter, r *http.Request, https boo if err != nil { host = r.Host } + + // In the event a host is prefixed with the artifact prefix an artifact + // value is created, and an attempt to proxy the request is made + if a.Artifact.TryMakeRequest(host, &w, r) { + return + } + domain := a.domain(host) if domain == nil { - serve404(&w) + httperrors.Serve404(&w) return } @@ -173,5 +183,9 @@ func (a *theApp) Run() { func runApp(config appConfig) { a := theApp{appConfig: config} + + if config.ArtifactsServer != "" { + a.Artifact = artifact.New(config.ArtifactsServer, config.ArtifactsServerTimeout, config.Domain) + } a.Run() } diff --git a/app_config.go b/app_config.go index e45dda24..dd47ed01 100644 --- a/app_config.go +++ b/app_config.go @@ -1,10 +1,11 @@ package main type appConfig struct { - Domain string - - RootCertificate []byte - RootKey []byte + Domain string + ArtifactsServer string + ArtifactsServerTimeout int + RootCertificate []byte + RootKey []byte ListenHTTP []uintptr ListenHTTPS []uintptr @@ -13,6 +13,7 @@ import ( "strings" "time" + "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" "gitlab.com/gitlab-org/gitlab-pages/internal/httputil" ) @@ -215,7 +216,7 @@ func (d *domain) serveFromGroup(w http.ResponseWriter, r *http.Request) { } // Serve generic not found - serve404(w) + httperrors.Serve404(w) } func (d *domain) serveFromConfig(w http.ResponseWriter, r *http.Request) { @@ -230,7 +231,7 @@ func (d *domain) serveFromConfig(w http.ResponseWriter, r *http.Request) { } // Serve generic not found - serve404(w) + httperrors.Serve404(w) } func (d *domain) ensureCertificate() (*tls.Certificate, error) { diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go new file mode 100644 index 00000000..bcb525ac --- /dev/null +++ b/internal/artifact/artifact.go @@ -0,0 +1,120 @@ +package artifact + +import ( + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" +) + +const ( + baseURL = "/projects/%s/jobs/%s/artifacts" + hostPatternTemplate = `(?i)\Aartifact~(\d+)~(\d+)\.%s\z` + minStatusCode = 200 + maxStatusCode = 299 +) + +// Artifact is a struct that is made up of a url.URL, http.Client, and +// regexp.Regexp that is used to proxy requests where applicable. +type Artifact struct { + server string + client *http.Client + pattern *regexp.Regexp +} + +// New when provided the arguments defined herein, returns a pointer to an +// Artifact that is used to proxy requests. +func New(s string, timeout int, pagesDomain string) *Artifact { + return &Artifact{ + server: s, + client: &http.Client{Timeout: time.Second * time.Duration(timeout)}, + pattern: hostPatternGen(pagesDomain), + } + +} + +// TryMakeRequest will attempt to proxy a request and write it to the argument +// http.ResponseWriter, ultimately returning a bool that indicates if the +// http.ResponseWriter has been written to in any capacity. +func (a *Artifact) TryMakeRequest(host string, w http.ResponseWriter, r *http.Request) bool { + if a == nil || a.server == "" { + return false + } + + reqURL, ok := a.buildURL(host, r.URL.Path) + if !ok { + return false + } + + resp, err := a.client.Get(reqURL.String()) + if err != nil { + httperrors.Serve502(w) + return true + } + + if resp.StatusCode == http.StatusNotFound { + httperrors.Serve404(w) + return true + } + + if resp.StatusCode == http.StatusInternalServerError { + httperrors.Serve500(w) + return true + } + + // we only cache responses within the 2xx series response codes + if (resp.StatusCode >= minStatusCode) && (resp.StatusCode <= maxStatusCode) { + w.Header().Set("Cache-Control", "max-age=3600") + } + + w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + return true +} + +// buildURL returns a pointer to a url.URL for where the request should be +// proxied to. The returned bool will indicate if there is some sort of issue +// with the url while it is being generated. +func (a *Artifact) buildURL(host, path string) (*url.URL, bool) { + ids := a.pattern.FindAllStringSubmatch(host, -1) + if len(ids) != 1 || len(ids[0]) != 3 { + return nil, false + } + + strippedIds := ids[0][1:3] + body := fmt.Sprintf(baseURL, strippedIds[0], strippedIds[1]) + ourPath := a.server + if strings.HasSuffix(ourPath, "/") { + ourPath = ourPath[0:len(ourPath)-1] + body + } else { + ourPath = ourPath + body + } + + if len(path) == 0 || strings.HasPrefix(path, "/") { + ourPath = ourPath + path + } else { + ourPath = ourPath + "/" + path + } + + u, err := url.Parse(ourPath) + if err != nil { + return nil, false + } + return u, true +} + +// hostPatternGen returns a pointer to a regexp.Regexp that is made up of +// the constant hostPatternTemplate and the argument which represents the pages domain. +// This is used to ensure that the requested page meets not only the hostPatternTemplate +// requirements, but is suffixed with the proper pagesDomain. +func hostPatternGen(pagesDomain string) *regexp.Regexp { + return regexp.MustCompile(fmt.Sprintf(hostPatternTemplate, regexp.QuoteMeta(pagesDomain))) +} diff --git a/internal/artifact/artifact_test.go b/internal/artifact/artifact_test.go new file mode 100644 index 00000000..ad2285f6 --- /dev/null +++ b/internal/artifact/artifact_test.go @@ -0,0 +1,303 @@ +package artifact + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTryMakeRequest(t *testing.T) { + content := "<!DOCTYPE html><html><head><title>Title of the document</title></head><body></body></html>" + contentType := "text/html; charset=utf-8" + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", contentType) + switch r.URL.Path { + case "/projects/1/jobs/2/artifacts/200.html": + w.WriteHeader(http.StatusOK) + case "/projects/1/jobs/2/artifacts/max-caching.html": + w.WriteHeader(http.StatusIMUsed) + case "/projects/1/jobs/2/artifacts/non-caching.html": + w.WriteHeader(http.StatusTeapot) + case "/projects/1/jobs/2/artifacts/500.html": + w.WriteHeader(http.StatusInternalServerError) + case "/projects/1/jobs/2/artifacts/404.html": + w.WriteHeader(http.StatusNotFound) + } + fmt.Fprint(w, content) + })) + defer testServer.Close() + + cases := []struct { + Path string + Status int + Content string + Length string + CacheControl string + ContentType string + Description string + }{ + { + "/200.html", + http.StatusOK, + content, + "90", + "max-age=3600", + "text/html; charset=utf-8", + "basic successful request", + }, + { + "/max-caching.html", + http.StatusIMUsed, + content, + "90", + "max-age=3600", + "text/html; charset=utf-8", + "max caching request", + }, + { + "/non-caching.html", + http.StatusTeapot, + content, + "90", + "", + "text/html; charset=utf-8", + "no caching request", + }, + } + + for _, c := range cases { + result := httptest.NewRecorder() + reqURL, err := url.Parse(c.Path) + assert.NoError(t, err) + r := &http.Request{URL: reqURL} + art := &Artifact{ + server: testServer.URL, + client: &http.Client{Timeout: time.Second * time.Duration(1)}, + pattern: regexp.MustCompile(fmt.Sprintf(hostPatternTemplate, "gitlab-example.io")), + } + + assert.True(t, art.TryMakeRequest("artifact~1~2.gitlab-example.io", result, r)) + assert.Equal(t, c.ContentType, result.Header().Get("Content-Type")) + assert.Equal(t, c.Length, result.Header().Get("Content-Length")) + assert.Equal(t, c.CacheControl, result.Header().Get("Cache-Control")) + assert.Equal(t, c.Content, string(result.Body.Bytes())) + assert.Equal(t, c.Status, result.Code) + } +} + +func TestBuildURL(t *testing.T) { + cases := []struct { + RawServer string + Host string + Path string + Expected string + PagesDomain string + Ok bool + Description string + }{ + { + "https://gitlab.com/api/v4", + "artifact~1~2.gitlab.io", + "/path/to/file.txt", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts/path/to/file.txt", + "gitlab.io", + true, + "basic case", + }, + { + "https://gitlab.com/api/v4/", + "artifact~1~2.gitlab.io", + "/path/to/file.txt", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts/path/to/file.txt", + "gitlab.io", + true, + "basic case 2", + }, + { + "https://gitlab.com/api/v4", + "artifact~1~2.gitlab.io", + "path/to/file.txt", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts/path/to/file.txt", + "gitlab.io", + true, + "basic case 3", + }, + { + "https://gitlab.com/api/v4/", + "artifact~1~2.gitlab.io", + "path/to/file.txt", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts/path/to/file.txt", + "gitlab.io", + true, + "basic case 4", + }, + { + "https://gitlab.com/api/v4", + "artifact~1~2.gitlab.io", + "", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts", + "gitlab.io", + true, + "basic case 5", + }, + { + "https://gitlab.com/api/v4/", + "artifact~1~2.gitlab.io", + "", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts", + "gitlab.io", + true, + "basic case 6", + }, + { + "https://gitlab.com/api/v4", + "artifact~1~2.gitlab.io", + "/", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts/", + "gitlab.io", + true, + "basic case 7", + }, + { + "https://gitlab.com/api/v4/", + "artifact~1~2.gitlab.io", + "/", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts/", + "gitlab.io", + true, + "basic case 8", + }, + { + "https://gitlab.com/api/v4", + "artifact~100000~200000.gitlab.io", + "/file.txt", + "https://gitlab.com/api/v4/projects/100000/jobs/200000/artifacts/file.txt", + "gitlab.io", + true, + "expanded case", + }, + { + "https://gitlab.com/api/v4/", + "artifact~1~2.gitlab.io", + "/file.txt", + "https://gitlab.com/api/v4/projects/1/jobs/2/artifacts/file.txt", + "gitlab.io", + true, + "server with tailing slash", + }, + { + "https://gitlab.com/api/v4", + "artifact~A~B.gitlab.io", + "/index.html", + "", + "example.com", + false, + "non matching domain and request", + }, + { + "", + "artifact~A~B.gitlab.io", + "", + "", + "", + false, + "un-parseable Host", + }, + } + + for _, c := range cases { + a := &Artifact{server: c.RawServer, pattern: regexp.MustCompile(fmt.Sprintf(hostPatternTemplate, c.PagesDomain))} + u, ok := a.buildURL(c.Host, c.Path) + assert.Equal(t, c.Ok, ok, c.Description) + if c.Ok { + assert.Equal(t, c.Expected, u.String(), c.Description) + } + } +} + +func TestMatchHostGen(t *testing.T) { + cases := []struct { + URLHost string + PagesDomain string + Expected bool + Description string + }{ + { + "artifact~1~2.gitlab.io", + "gitlab.io", + true, + "basic case", + }, + { + "ARTIFACT~1~2.gitlab.io", + "gitlab.io", + true, + "capital letters case", + }, + { + "ARTIFACT~11234~2908908.gitlab.io", + "gitlab.io", + true, + "additional capital letters case", + }, + { + "artifact~10000~20000.gitlab.io", + "gitlab.io", + true, + "expanded case", + }, + { + "artifact~86753095555~55550935768.gitlab.io", + "gitlab.io", + true, + "large number case", + }, + { + "artifact~one~two.gitlab.io", + "gitlab.io", + false, + "letters rather than numbers", + }, + { + "artifact~One111~tWo222.gitlab.io", + "gitlab.io", + false, + "Mixture of alphanumeric", + }, + { + "artifact~!@#$%~%$#@!.gitlab.io", + "gitlab.io", + false, + "special characters", + }, + { + "artifact~1.gitlab.io", + "gitlab.io", + false, + "not enough ids", + }, + { + "artifact~1~2~34444~1~4.gitlab.io", + "gitlab.io", + false, + "too many ids", + }, + { + "artifact~1~2.gitlab.io", + "otherhost.io", + false, + "different domain / suffix", + }, + } + + for _, c := range cases { + reg := hostPatternGen(c.PagesDomain) + assert.Equal(t, c.Expected, reg.MatchString(c.URLHost), c.Description) + } +} diff --git a/internal/httperrors/httperrors.go b/internal/httperrors/httperrors.go new file mode 100644 index 00000000..82d43fc5 --- /dev/null +++ b/internal/httperrors/httperrors.go @@ -0,0 +1,162 @@ +package httperrors + +import ( + "fmt" + "net/http" +) + +type content struct { + status int + title string + statusString string + header string + subHeader string +} + +var ( + content404 = content{ + http.StatusNotFound, + "The page you're looking for could not be found (404)", + "404", + "The page you're looking for could not be found.", + `<p>The resource that you are attempting to access does not exist or you don't have the necessary permissions to view it.</p> + <p>Make sure the address is correct and that the page hasn't moved.</p> + <p>Please contact your GitLab administrator if you think this is a mistake.</p>`, + } + content500 = content{ + http.StatusInternalServerError, + "Something went wrong (500)", + "500", + "Whoops, something went wrong on our end.", + `<p>Try refreshing the page, or going back and attempting the action again.</p> + <p>Please contact your GitLab administrator if this problem persists.</p>`, + } + + content502 = content{ + http.StatusBadGateway, + "Something went wrong (502)", + "502", + "Whoops, something went wrong on our end.", + `<p>Try refreshing the page, or going back and attempting the action again.</p> + <p>Please contact your GitLab administrator if this problem persists.</p>`, + } +) + +const predefinedErrorPage = ` +<!DOCTYPE html> +<html> +<head> + <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> + <title>%v</title> + <style> + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 14px; + } + + h1 { + font-size: 56px; + line-height: 100px; + font-weight: 400; + color: #456; + } + + h2 { + font-size: 24px; + color: #666; + line-height: 1.5em; + } + + h3 { + color: #456; + font-size: 20px; + font-weight: 400; + line-height: 28px; + } + + hr { + max-width: 800px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + border-bottom: 1px solid white; + } + + img { + max-width: 40vw; + display: block; + margin: 40px auto; + } + + a { + line-height: 100px; + font-weight: 400; + color: #4A8BEE; + font-size: 18px; + text-decoration: none; + } + + .container { + margin: auto 20px; + } + + .go-back { + display: none; + } + + </style> +</head> + +<body> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" + alt="GitLab Logo" /> + <h1> + %v + </h1> + <div class="container"> + <h3>%v</h3> + <hr /> + %v + <a href="javascript:history.back()" class="js-go-back go-back">Go back</a> + </div> + <script> + (function () { + var goBack = document.querySelector('.js-go-back'); + + if (history.length > 1) { + goBack.style.display = 'inline'; + } + })(); + </script> +</body> +</html> +` + +func generateErrorHTML(c content) string { + return fmt.Sprintf(predefinedErrorPage, c.title, c.statusString, c.header, c.subHeader) +} + +func serveErrorPage(w http.ResponseWriter, c content) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(c.status) + fmt.Fprintln(w, generateErrorHTML(c)) +} + +// Serve404 returns a 404 error response / HTML page to the http.ResponseWriter +func Serve404(w http.ResponseWriter) { + serveErrorPage(w, content404) +} + +// Serve500 returns a 500 error response / HTML page to the http.ResponseWriter +func Serve500(w http.ResponseWriter) { + serveErrorPage(w, content500) +} + +// Serve502 returns a 502 error response / HTML page to the http.ResponseWriter +func Serve502(w http.ResponseWriter) { + serveErrorPage(w, content502) +} diff --git a/internal/httperrors/httperrors_test.go b/internal/httperrors/httperrors_test.go new file mode 100644 index 00000000..1a79d850 --- /dev/null +++ b/internal/httperrors/httperrors_test.go @@ -0,0 +1,105 @@ +package httperrors + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// creates a new implementation of http.ResponseWriter that allows the +// casting of values in order to aid testing efforts. +type testResponseWriter struct { + status int + content string + http.ResponseWriter +} + +func newTestResponseWriter(w http.ResponseWriter) *testResponseWriter { + return &testResponseWriter{0, "", w} +} + +func (w *testResponseWriter) Status() int { + return w.status +} + +func (w *testResponseWriter) Content() string { + return w.content +} + +func (w *testResponseWriter) Header() http.Header { + return w.ResponseWriter.Header() +} + +func (w *testResponseWriter) Write(data []byte) (int, error) { + w.content = string(data) + return w.ResponseWriter.Write(data) +} + +func (w *testResponseWriter) WriteHeader(statusCode int) { + w.status = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + +var ( + testingContent = content{ + http.StatusNotFound, + "Title", + "533", + "Header test", + "subheader text", + } +) + +func TestGenerateemailHTML(t *testing.T) { + actual := generateErrorHTML(testingContent) + assert.Contains(t, actual, testingContent.title) + assert.Contains(t, actual, testingContent.statusString) + assert.Contains(t, actual, testingContent.header) + assert.Contains(t, actual, testingContent.subHeader) +} + +func TestServeErrorPage(t *testing.T) { + w := newTestResponseWriter(httptest.NewRecorder()) + serveErrorPage(w, testingContent) + assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8") + assert.Equal(t, w.Header().Get("X-Content-Type-Options"), "nosniff") + assert.Equal(t, w.Status(), testingContent.status) +} + +func TestServe404(t *testing.T) { + w := newTestResponseWriter(httptest.NewRecorder()) + Serve404(w) + assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8") + assert.Equal(t, w.Header().Get("X-Content-Type-Options"), "nosniff") + assert.Equal(t, w.Status(), content404.status) + assert.Contains(t, w.Content(), content404.title) + assert.Contains(t, w.Content(), content404.statusString) + assert.Contains(t, w.Content(), content404.header) + assert.Contains(t, w.Content(), content404.subHeader) +} + +func TestServe500(t *testing.T) { + w := newTestResponseWriter(httptest.NewRecorder()) + Serve500(w) + assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8") + assert.Equal(t, w.Header().Get("X-Content-Type-Options"), "nosniff") + assert.Equal(t, w.Status(), content500.status) + assert.Contains(t, w.Content(), content500.title) + assert.Contains(t, w.Content(), content500.statusString) + assert.Contains(t, w.Content(), content500.header) + assert.Contains(t, w.Content(), content500.subHeader) +} + +func TestServe502(t *testing.T) { + w := newTestResponseWriter(httptest.NewRecorder()) + Serve502(w) + assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8") + assert.Equal(t, w.Header().Get("X-Content-Type-Options"), "nosniff") + assert.Equal(t, w.Status(), content502.status) + assert.Contains(t, w.Content(), content502.title) + assert.Contains(t, w.Content(), content502.statusString) + assert.Contains(t, w.Content(), content502.header) + assert.Contains(t, w.Content(), content502.subHeader) +} @@ -1,8 +1,10 @@ package main import ( + "errors" "flag" "log" + "net/url" "os" "strings" ) @@ -14,18 +16,22 @@ var VERSION = "dev" var REVISION = "HEAD" var ( - pagesRootCert = flag.String("root-cert", "", "The default path to file certificate to serve static pages") - pagesRootKey = flag.String("root-key", "", "The default path to file certificate to serve static pages") - redirectHTTP = flag.Bool("redirect-http", false, "Redirect pages from HTTP to HTTPS") - useHTTP2 = flag.Bool("use-http2", true, "Enable HTTP2 support") - pagesRoot = flag.String("pages-root", "shared/pages", "The directory where pages are stored") - pagesDomain = flag.String("pages-domain", "gitlab-example.com", "The domain to serve static pages") - pagesStatus = flag.String("pages-status", "", "The url path for a status page, e.g., /@status") - metricsAddress = flag.String("metrics-address", "", "The address to listen on for metrics requests") - daemonUID = flag.Uint("daemon-uid", 0, "Drop privileges to this user") - daemonGID = flag.Uint("daemon-gid", 0, "Drop privileges to this group") - - disableCrossOriginRequests = flag.Bool("disable-cross-origin-requests", false, "Disable cross-origin requests") + pagesRootCert = flag.String("root-cert", "", "The default path to file certificate to serve static pages") + pagesRootKey = flag.String("root-key", "", "The default path to file certificate to serve static pages") + redirectHTTP = flag.Bool("redirect-http", false, "Redirect pages from HTTP to HTTPS") + useHTTP2 = flag.Bool("use-http2", true, "Enable HTTP2 support") + pagesRoot = flag.String("pages-root", "shared/pages", "The directory where pages are stored") + pagesDomain = flag.String("pages-domain", "gitlab-example.com", "The domain to serve static pages") + artifactsServer = flag.String("artifacts-server", "", "API URL to proxy artifact requests to, e.g.: 'https://gitlab.com/api/v4'") + artifactsServerTimeout = flag.Int("artifacts-server-timeout", 10, "Timeout (in seconds) for a proxied request to the artifacts server") + pagesStatus = flag.String("pages-status", "", "The url path for a status page, e.g., /@status") + metricsAddress = flag.String("metrics-address", "", "The address to listen on for metrics requests") + daemonUID = flag.Uint("daemon-uid", 0, "Drop privileges to this user") + daemonGID = flag.Uint("daemon-gid", 0, "Drop privileges to this group") + + disableCrossOriginRequests = flag.Bool("disable-cross-origin-requests", false, "Disable cross-origin requests") + errArtifactSchemaUnsupported = errors.New("artifacts-server scheme must be either http:// or https://") + errArtifactsServerTimeoutValue = errors.New("artifacts-server-timeout must be greater than or equal to 1") ) func configFromFlags() appConfig { @@ -45,6 +51,27 @@ func configFromFlags() appConfig { config.RootKey = readFile(*pagesRootKey) } + if *artifactsServerTimeout < 1 { + log.Fatal(errArtifactsServerTimeoutValue) + } + + if *artifactsServer != "" { + u, err := url.Parse(*artifactsServer) + if err != nil { + log.Fatal(err) + } + // url.Parse ensures that the Scheme arttribute is always lower case. + if u.Scheme != "http" && u.Scheme != "https" { + log.Fatal(errArtifactSchemaUnsupported) + } + + if *artifactsServerTimeout < 1 { + log.Fatal(errArtifactsServerTimeoutValue) + } + + config.ArtifactsServerTimeout = *artifactsServerTimeout + config.ArtifactsServer = *artifactsServer + } return config } |