Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-pages.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJ. Shuster <joshuagregoryshuster@gmail.com>2017-09-08 11:58:42 +0300
committerNick Thomas <nick@gitlab.com>2017-09-08 11:58:42 +0300
commit0a144bc6055b41fac726fdc6eeaa7150f622bd20 (patch)
tree9ed2a4dbecbd8146245ae8fa609cd9f54d038bf6
parent0173d4e6b6b17443155d121a9098d0e742b9c4e3 (diff)
Add an artifacts proxy to GitLab Pages
-rw-r--r--404.go70
-rw-r--r--acceptance_test.go97
-rw-r--r--app.go20
-rw-r--r--app_config.go9
-rw-r--r--domain.go5
-rw-r--r--internal/artifact/artifact.go120
-rw-r--r--internal/artifact/artifact_test.go303
-rw-r--r--internal/httperrors/httperrors.go162
-rw-r--r--internal/httperrors/httperrors_test.go105
-rw-r--r--main.go51
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"))
+ }
+ })
+ }
+}
diff --git a/app.go b/app.go
index 61b1176c..3714582a 100644
--- a/app.go
+++ b/app.go
@@ -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
diff --git a/domain.go b/domain.go
index 45e59f94..fc6611e1 100644
--- a/domain.go
+++ b/domain.go
@@ -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)
+}
diff --git a/main.go b/main.go
index cacddb4f..1cfac409 100644
--- a/main.go
+++ b/main.go
@@ -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
}