diff options
author | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2019-09-26 15:14:51 +0300 |
---|---|---|
committer | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2019-09-27 12:49:36 +0300 |
commit | 3cc7bc556307adbc8fac6942240bafceb0ff5d5d (patch) | |
tree | aa959a2b56a2fcb78830740cb5b639d8ffe39637 /internal | |
parent | bf3b3983eae2cb83e82224d4f936ccee1b4322ef (diff) |
Unify how we handle custom and group domain in serving
Diffstat (limited to 'internal')
-rw-r--r-- | internal/acme/acme.go | 4 | ||||
-rw-r--r-- | internal/acme/acme_test.go | 2 | ||||
-rw-r--r-- | internal/domain/config.go | 11 | ||||
-rw-r--r-- | internal/domain/domain.go | 138 | ||||
-rw-r--r-- | internal/domain/domain_test.go | 148 | ||||
-rw-r--r-- | internal/domain/handler.go | 42 | ||||
-rw-r--r-- | internal/domain/project.go | 10 | ||||
-rw-r--r-- | internal/domain/resolver.go | 10 | ||||
-rw-r--r-- | internal/serving/disk.go | 19 | ||||
-rw-r--r-- | internal/serving/disk/group.go | 64 | ||||
-rw-r--r-- | internal/serving/disk/handler.go | 11 | ||||
-rw-r--r-- | internal/serving/disk/project.go | 58 | ||||
-rw-r--r-- | internal/serving/disk/reader.go | 44 | ||||
-rw-r--r-- | internal/serving/disk/serving.go | 47 | ||||
-rw-r--r-- | internal/serving/handler.go | 16 | ||||
-rw-r--r-- | internal/serving/serving.go | 29 | ||||
-rw-r--r-- | internal/source/disk/custom.go | 24 | ||||
-rw-r--r-- | internal/source/disk/domain_test.go (renamed from internal/source/disk/group_domain_test.go) | 86 | ||||
-rw-r--r-- | internal/source/disk/group.go | 78 | ||||
-rw-r--r-- | internal/source/disk/map.go | 38 | ||||
-rw-r--r-- | internal/source/disk/map_test.go | 12 |
21 files changed, 388 insertions, 503 deletions
diff --git a/internal/acme/acme.go b/internal/acme/acme.go index 89881f34..a6be01de 100644 --- a/internal/acme/acme.go +++ b/internal/acme/acme.go @@ -18,7 +18,7 @@ type Middleware struct { // Domain interface represent D from domain package type Domain interface { - HasAcmeChallenge(string) bool + HasAcmeChallenge(*http.Request, string) bool } // ServeAcmeChallenges identifies if request is acme-challenge and redirects to GitLab in that case @@ -31,7 +31,7 @@ func (m *Middleware) ServeAcmeChallenges(w http.ResponseWriter, r *http.Request, return false } - if domain.HasAcmeChallenge(filepath.Base(r.URL.Path)) { + if domain.HasAcmeChallenge(r, filepath.Base(r.URL.Path)) { return false } diff --git a/internal/acme/acme_test.go b/internal/acme/acme_test.go index c0daefeb..00932d3e 100644 --- a/internal/acme/acme_test.go +++ b/internal/acme/acme_test.go @@ -11,7 +11,7 @@ type domainStub struct { hasAcmeChallenge bool } -func (d *domainStub) HasAcmeChallenge(_ string) bool { +func (d *domainStub) HasAcmeChallenge(_ *http.Request, _ string) bool { return d.hasAcmeChallenge } diff --git a/internal/domain/config.go b/internal/domain/config.go deleted file mode 100644 index 040b2279..00000000 --- a/internal/domain/config.go +++ /dev/null @@ -1,11 +0,0 @@ -package domain - -// ProjectConfig holds a custom project domain configuration -type ProjectConfig struct { - DomainName string - Certificate string - Key string - HTTPSOnly bool - ProjectID uint64 - AccessControl bool -} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 361c3b87..1dd5be51 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -5,31 +5,23 @@ import ( "errors" "net/http" "sync" - "time" "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" "gitlab.com/gitlab-org/gitlab-pages/internal/serving" ) -// GroupConfig represents a per-request config for a group domain -type GroupConfig interface { - IsHTTPSOnly(*http.Request) bool - HasAccessControl(*http.Request) bool - IsNamespaceProject(*http.Request) bool - ProjectID(*http.Request) uint64 - ProjectExists(*http.Request) bool - ProjectWithSubpath(*http.Request) (string, string, error) -} - // Domain is a domain that gitlab-pages can serve. type Domain struct { - Group string - Project string + Name string + Location string + CertificateCert string + CertificateKey string + Customized bool // TODO we should get rid of this - GroupConfig GroupConfig // handles group domain config - ProjectConfig *ProjectConfig + Resolver Resolver - serving serving.Serving + lookupPaths map[string]*Project + serving serving.Serving certificate *tls.Certificate certificateError error @@ -38,15 +30,7 @@ type Domain struct { // String implements Stringer. func (d *Domain) String() string { - if d.Group != "" && d.Project != "" { - return d.Group + "/" + d.Project - } - - if d.Group != "" { - return d.Group - } - - return d.Project + return d.Name } func (d *Domain) isCustomDomain() bool { @@ -54,7 +38,7 @@ func (d *Domain) isCustomDomain() bool { panic("project config and group config should not be nil at the same time") } - return d.ProjectConfig != nil && d.GroupConfig == nil + return d.Customized } func (d *Domain) isUnconfigured() bool { @@ -62,22 +46,52 @@ func (d *Domain) isUnconfigured() bool { return true } - return d.ProjectConfig == nil && d.GroupConfig == nil + return d.Resolver == nil +} + +func (d *Domain) resolve(r *http.Request) (*Project, string) { + // TODO use lookupPaths first + + project, subpath, _ := d.Resolver.Resolve(r) + // current implementation does not return errors in any case + + if project == nil { + return nil, "" + } + + return project, subpath +} + +func (d *Domain) getProject(r *http.Request) *Project { + project, _ := d.resolve(r) + + return project } // Serving returns domain serving driver func (d *Domain) Serving() serving.Serving { if d.serving == nil { - if d.isCustomDomain() { - d.serving = serving.NewProjectDiskServing(d.Project, d.Group) - } else { - d.serving = serving.NewGroupDiskServing(d.Group, d.GroupConfig) - } + d.serving = serving.NewDiskServing(d.Name, d.Location) } return d.serving } +func (d *Domain) toHandler(w http.ResponseWriter, r *http.Request) *handler { + project, subpath := d.resolve(r) + + return &handler{ + writer: w, + request: r, + project: project, + subpath: subpath, + } +} + +func (d *Domain) hasProject(r *http.Request) bool { + return d.getProject(r) != nil +} + // IsHTTPSOnly figures out if the request should be handled with HTTPS // only by looking at group and project level config. func (d *Domain) IsHTTPSOnly(r *http.Request) bool { @@ -85,13 +99,11 @@ func (d *Domain) IsHTTPSOnly(r *http.Request) bool { return false } - // Check custom domain config (e.g. http://example.com) - if d.isCustomDomain() { - return d.ProjectConfig.HTTPSOnly + if project := d.getProject(r); project != nil { + return project.IsHTTPSOnly } - // Check projects served under the group domain, including the default one - return d.GroupConfig.IsHTTPSOnly(r) + return false } // IsAccessControlEnabled figures out if the request is to a project that has access control enabled @@ -100,22 +112,20 @@ func (d *Domain) IsAccessControlEnabled(r *http.Request) bool { return false } - // Check custom domain config (e.g. http://example.com) - if d.isCustomDomain() { - return d.ProjectConfig.AccessControl + if project := d.getProject(r); project != nil { + return project.HasAccessControl } - // Check projects served under the group domain, including the default one - return d.GroupConfig.HasAccessControl(r) + return false } // HasAcmeChallenge checks domain directory contains particular acme challenge -func (d *Domain) HasAcmeChallenge(token string) bool { - if d.isUnconfigured() || !d.isCustomDomain() { +func (d *Domain) HasAcmeChallenge(r *http.Request, token string) bool { + if d.isUnconfigured() || !d.isCustomDomain() || !d.hasProject(r) { return false } - return d.Serving().HasAcmeChallenge(token) + return d.Serving().HasAcmeChallenge(d.toHandler(nil, r), token) // TODO } // IsNamespaceProject figures out if the request is to a namespace project @@ -126,12 +136,15 @@ func (d *Domain) IsNamespaceProject(r *http.Request) bool { // If request is to a custom domain, we do not handle it as a namespace project // as there can't be multiple projects under the same custom domain - if d.isCustomDomain() { + if d.isCustomDomain() { // TODO do we need a separate path for this return false } - // Check projects served under the group domain, including the default one - return d.GroupConfig.IsNamespaceProject(r) + if project := d.getProject(r); project != nil { + return project.IsNamespaceProject + } + + return false } // GetID figures out what is the ID of the project user tries to access @@ -140,11 +153,11 @@ func (d *Domain) GetID(r *http.Request) uint64 { return 0 } - if d.isCustomDomain() { - return d.ProjectConfig.ProjectID + if project := d.getProject(r); project != nil { + return project.ID } - return d.GroupConfig.ProjectID(r) + return 0 } // HasProject figures out if the project exists that the user tries to access @@ -153,15 +166,16 @@ func (d *Domain) HasProject(r *http.Request) bool { return false } - if d.isCustomDomain() { + if project := d.getProject(r); project != nil { return true } - return d.GroupConfig.ProjectExists(r) + return false } // EnsureCertificate parses the PEM-encoded certificate for the domain func (d *Domain) EnsureCertificate() (*tls.Certificate, error) { + // TODO check len certificates instead of custom domain! if d.isUnconfigured() || !d.isCustomDomain() { return nil, errors.New("tls certificates can be loaded only for pages with configuration") } @@ -169,8 +183,8 @@ func (d *Domain) EnsureCertificate() (*tls.Certificate, error) { d.certificateOnce.Do(func() { var cert tls.Certificate cert, d.certificateError = tls.X509KeyPair( - []byte(d.ProjectConfig.Certificate), - []byte(d.ProjectConfig.Key), + []byte(d.CertificateCert), + []byte(d.CertificateKey), ) if d.certificateError == nil { d.certificate = &cert @@ -182,26 +196,20 @@ func (d *Domain) EnsureCertificate() (*tls.Certificate, error) { // ServeFileHTTP returns true if something was served, false if not. func (d *Domain) ServeFileHTTP(w http.ResponseWriter, r *http.Request) bool { - if d.isUnconfigured() { + if d.isUnconfigured() || !d.hasProject(r) { httperrors.Serve404(w) return true } - if !d.IsAccessControlEnabled(r) { - // Set caching headers - w.Header().Set("Cache-Control", "max-age=600") - w.Header().Set("Expires", time.Now().Add(10*time.Minute).Format(time.RFC1123)) - } - - return d.Serving().ServeFileHTTP(w, r) + return d.Serving().ServeFileHTTP(d.toHandler(w, r)) } // ServeNotFoundHTTP serves the not found pages from the projects. func (d *Domain) ServeNotFoundHTTP(w http.ResponseWriter, r *http.Request) { - if d.isUnconfigured() { + if d.isUnconfigured() || !d.hasProject(r) { httperrors.Serve404(w) return } - d.Serving().ServeNotFoundHTTP(w, r) + d.Serving().ServeNotFoundHTTP(d.toHandler(w, r)) } diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go index 2be86e20..8b369075 100644 --- a/internal/domain/domain_test.go +++ b/internal/domain/domain_test.go @@ -11,10 +11,19 @@ import ( "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitlab-pages/internal/fixture" "gitlab.com/gitlab-org/gitlab-pages/internal/testhelpers" ) +type stubbedResolver struct { + project *Project + subpath string + err error +} + +func (resolver *stubbedResolver) Resolve(*http.Request) (*Project, string, error) { + return resolver.project, resolver.subpath, resolver.err +} + func serveFileOrNotFound(domain *Domain) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !domain.ServeFileHTTP(w, r) { @@ -23,27 +32,6 @@ func serveFileOrNotFound(domain *Domain) http.HandlerFunc { } } -func TestDomainServeHTTP(t *testing.T) { - cleanup := setUpTests(t) - defer cleanup() - - testDomain := &Domain{ - Project: "project2", - Group: "group", - ProjectConfig: &ProjectConfig{DomainName: "test.domain.com"}, - } - - require.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "/", nil, "project2-main") - require.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "/index.html", nil, "project2-main") - require.HTTPRedirect(t, serveFileOrNotFound(testDomain), "GET", "/subdir", nil) - require.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "/subdir", nil, - `<a href="/subdir/">Found</a>`) - require.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "/subdir/", nil, "project2-subdir") - require.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "/subdir/index.html", nil, "project2-subdir") - require.HTTPError(t, serveFileOrNotFound(testDomain), "GET", "//about.gitlab.com/%2e%2e", nil) - require.HTTPError(t, serveFileOrNotFound(testDomain), "GET", "/not-existing-file", nil) -} - func TestIsHTTPSOnly(t *testing.T) { tests := []struct { name string @@ -54,9 +42,8 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Custom domain with HTTPS-only enabled", domain: &Domain{ - Project: "project", - Group: "group", - ProjectConfig: &ProjectConfig{HTTPSOnly: true}, + Location: "group/project", + Resolver: &stubbedResolver{project: &Project{IsHTTPSOnly: true}}, }, url: "http://custom-domain", expected: true, @@ -64,9 +51,8 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Custom domain with HTTPS-only disabled", domain: &Domain{ - Project: "project", - Group: "group", - ProjectConfig: &ProjectConfig{HTTPSOnly: false}, + Location: "group/project", + Resolver: &stubbedResolver{project: &Project{IsHTTPSOnly: false}}, }, url: "http://custom-domain", expected: false, @@ -74,8 +60,7 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Unknown project", domain: &Domain{ - Project: "project", - Group: "group", + Location: "group/project", }, url: "http://test-domain/project", expected: false, @@ -90,69 +75,6 @@ func TestIsHTTPSOnly(t *testing.T) { } } -func TestHasAcmeChallenge(t *testing.T) { - cleanup := setUpTests(t) - defer cleanup() - - tests := []struct { - name string - domain *Domain - token string - expected bool - }{ - { - name: "Project containing acme challenge", - domain: &Domain{ - Group: "group.acme", - Project: "with.acme.challenge", - ProjectConfig: &ProjectConfig{HTTPSOnly: true}, - }, - token: "existingtoken", - expected: true, - }, - { - name: "Project containing acme challenge", - domain: &Domain{ - Group: "group.acme", - Project: "with.acme.challenge", - ProjectConfig: &ProjectConfig{HTTPSOnly: true}, - }, - token: "foldertoken", - expected: true, - }, - { - name: "Project containing another token", - domain: &Domain{ - Group: "group.acme", - Project: "with.acme.challenge", - ProjectConfig: &ProjectConfig{HTTPSOnly: true}, - }, - token: "notexistingtoken", - expected: false, - }, - { - name: "nil domain", - domain: nil, - token: "existingtoken", - expected: false, - }, - { - name: "Domain without config", - domain: &Domain{ - Group: "group.acme", - Project: "with.acme.challenge", - }, - token: "existingtoken", - expected: false, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - require.Equal(t, test.expected, test.domain.HasAcmeChallenge(test.token)) - }) - } -} - func testHTTPGzip(t *testing.T, handler http.HandlerFunc, mode, url string, values url.Values, acceptEncoding string, str interface{}, contentType string, ungzip bool) { w := httptest.NewRecorder() req, err := http.NewRequest(mode, url+"?"+values.Encode(), nil) @@ -180,26 +102,12 @@ func testHTTPGzip(t *testing.T, handler http.HandlerFunc, mode, url string, valu require.Equal(t, contentType, w.Header().Get("Content-Type")) } -func TestDomain404ServeHTTP(t *testing.T) { - cleanup := setUpTests(t) - defer cleanup() - - testDomain := &Domain{ - Group: "group.404", - Project: "domain.404", - ProjectConfig: &ProjectConfig{DomainName: "domain.404.com"}, - } - - testhelpers.AssertHTTP404(t, serveFileOrNotFound(testDomain), "GET", "http://group.404.test.io/not-existing-file", nil, "Custom 404 group page") - testhelpers.AssertHTTP404(t, serveFileOrNotFound(testDomain), "GET", "http://group.404.test.io/", nil, "Custom 404 group page") -} - func TestPredefined404ServeHTTP(t *testing.T) { cleanup := setUpTests(t) defer cleanup() testDomain := &Domain{ - Group: "group", + Location: "group", } testhelpers.AssertHTTP404(t, serveFileOrNotFound(testDomain), "GET", "http://group.test.io/not-existing-file", nil, "The page you're looking for could not be found") @@ -207,8 +115,7 @@ func TestPredefined404ServeHTTP(t *testing.T) { func TestGroupCertificate(t *testing.T) { testGroup := &Domain{ - Project: "", - Group: "group", + Location: "group", } tls, err := testGroup.EnsureCertificate() @@ -218,9 +125,8 @@ func TestGroupCertificate(t *testing.T) { func TestDomainNoCertificate(t *testing.T) { testDomain := &Domain{ - Group: "group", - Project: "project2", - ProjectConfig: &ProjectConfig{DomainName: "test.domain.com"}, + Name: "test.domain.com", + Location: "group/project2", } tls, err := testDomain.EnsureCertificate() @@ -232,22 +138,6 @@ func TestDomainNoCertificate(t *testing.T) { require.Equal(t, err, err2) } -func TestDomainCertificate(t *testing.T) { - testDomain := &Domain{ - Group: "group", - Project: "project2", - ProjectConfig: &ProjectConfig{ - DomainName: "test.domain.com", - Certificate: fixture.Certificate, - Key: fixture.Key, - }, - } - - tls, err := testDomain.EnsureCertificate() - require.NotNil(t, tls) - require.NoError(t, err) -} - var chdirSet = false func setUpTests(t require.TestingT) func() { diff --git a/internal/domain/handler.go b/internal/domain/handler.go new file mode 100644 index 00000000..55697700 --- /dev/null +++ b/internal/domain/handler.go @@ -0,0 +1,42 @@ +package domain + +import "net/http" + +type handler struct { + writer http.ResponseWriter + request *http.Request + project *Project + subpath string +} + +func (h *handler) Writer() http.ResponseWriter { + return h.writer +} + +func (h *handler) Request() *http.Request { + return h.request +} + +func (h *handler) LookupPath() string { + return h.project.LookupPath +} + +func (h *handler) Subpath() string { + return h.subpath +} + +func (h *handler) IsNamespaceProject() bool { + return h.project.IsNamespaceProject +} + +func (h *handler) IsHTTPSOnly() bool { + return h.project.IsHTTPSOnly +} + +func (h *handler) HasAccessControl() bool { + return h.project.HasAccessControl +} + +func (h *handler) ProjectID() uint64 { + return h.project.ID +} diff --git a/internal/domain/project.go b/internal/domain/project.go new file mode 100644 index 00000000..9ea7306f --- /dev/null +++ b/internal/domain/project.go @@ -0,0 +1,10 @@ +package domain + +// Project holds a domain / project configuration +type Project struct { + LookupPath string + IsNamespaceProject bool + IsHTTPSOnly bool + HasAccessControl bool + ID uint64 +} diff --git a/internal/domain/resolver.go b/internal/domain/resolver.go new file mode 100644 index 00000000..5bde31ec --- /dev/null +++ b/internal/domain/resolver.go @@ -0,0 +1,10 @@ +package domain + +import "net/http" + +// Resolver represents an interface responsible for resolving a project +// per-request +type Resolver interface { + // Resolve returns a project with a file path and an error if it occured + Resolve(*http.Request) (*Project, string, error) +} diff --git a/internal/serving/disk.go b/internal/serving/disk.go new file mode 100644 index 00000000..b569986f --- /dev/null +++ b/internal/serving/disk.go @@ -0,0 +1,19 @@ +package serving + +import "gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk" + +type diskServing struct { + disk *disk.Serving +} + +func (d *diskServing) ServeFileHTTP(h Handler) bool { + return d.disk.ServeFileHTTP(h) +} + +func (d *diskServing) ServeNotFoundHTTP(h Handler) { + d.disk.ServeNotFoundHTTP(h) +} + +func (d *diskServing) HasAcmeChallenge(h Handler, token string) bool { + return d.disk.HasAcmeChallenge(h, token) +} diff --git a/internal/serving/disk/group.go b/internal/serving/disk/group.go deleted file mode 100644 index 78a4ff1f..00000000 --- a/internal/serving/disk/group.go +++ /dev/null @@ -1,64 +0,0 @@ -package disk - -import ( - "net/http" - - "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" -) - -// Group serving represents a resource that can be served from a directory -// representing GitLab group -type Group struct { - Resolver - *Reader -} - -type Resolver interface { - ProjectWithSubpath(*http.Request) (string, string, error) -} - -// ServeFileHTTP returns true if something was served, false if not. -func (g *Group) ServeFileHTTP(w http.ResponseWriter, r *http.Request) bool { - return g.serveFileFromGroup(w, r) -} - -// ServeNotFoundHTTP serves the not found pages from the projects. -func (g *Group) ServeNotFoundHTTP(w http.ResponseWriter, r *http.Request) { - g.serveNotFoundFromGroup(w, r) -} - -func (g *Group) HasAcmeChallenge(token string) bool { - return false -} - -func (g *Group) serveFileFromGroup(w http.ResponseWriter, r *http.Request) bool { - projectName, subPath, err := g.Resolver.ProjectWithSubpath(r) - - if err != nil { - httperrors.Serve404(w) - return true - } - - if g.tryFile(w, r, projectName, subPath) == nil { - return true - } - - return false -} - -func (g *Group) serveNotFoundFromGroup(w http.ResponseWriter, r *http.Request) { - projectName, _, err := g.Resolver.ProjectWithSubpath(r) - - if err != nil { - httperrors.Serve404(w) - return - } - - // Try serving custom not-found page - if g.tryNotFound(w, r, projectName) == nil { - return - } - - // Generic 404 - httperrors.Serve404(w) -} diff --git a/internal/serving/disk/handler.go b/internal/serving/disk/handler.go new file mode 100644 index 00000000..fbbf9ce3 --- /dev/null +++ b/internal/serving/disk/handler.go @@ -0,0 +1,11 @@ +package disk + +import "net/http" + +type handler interface { + Writer() http.ResponseWriter + Request() *http.Request + LookupPath() string + Subpath() string + HasAccessControl() bool +} diff --git a/internal/serving/disk/project.go b/internal/serving/disk/project.go deleted file mode 100644 index b2940f3d..00000000 --- a/internal/serving/disk/project.go +++ /dev/null @@ -1,58 +0,0 @@ -package disk - -import ( - "net/http" - - "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" -) - -// Custom serving represent a resource that can be served from a directory -// representing GitLab project -type Project struct { - Location string - *Reader -} - -// ServeFileHTTP returns true if something was served, false if not. -func (p *Project) ServeFileHTTP(w http.ResponseWriter, r *http.Request) bool { - return p.serveFileFromConfig(w, r) -} - -// ServeNotFoundHTTP serves the not found pages from the projects. -func (p *Project) ServeNotFoundHTTP(w http.ResponseWriter, r *http.Request) { - p.serveNotFoundFromConfig(w, r) -} - -func (p *Project) HasAcmeChallenge(token string) bool { - _, err := p.resolvePath(p.Location, ".well-known/acme-challenge", token) - // there is an acme challenge on disk - if err == nil { - return true - } - - _, err = p.resolvePath(p.Location, ".well-known/acme-challenge", token, "index.html") - if err == nil { - return true - } - - return false -} - -func (p *Project) serveFileFromConfig(w http.ResponseWriter, r *http.Request) bool { - // Try to serve file for http://host/... => /group/project/... - if p.tryFile(w, r, p.Location, r.URL.Path) == nil { - return true - } - - return false -} - -func (p *Project) serveNotFoundFromConfig(w http.ResponseWriter, r *http.Request) { - // Try serving not found page for http://host/ => /group/project/404.html - if p.tryNotFound(w, r, p.Location) == nil { - return - } - - // Serve generic not found - httperrors.Serve404(w) -} diff --git a/internal/serving/disk/reader.go b/internal/serving/disk/reader.go index 068b4b47..1fc30da7 100644 --- a/internal/serving/disk/reader.go +++ b/internal/serving/disk/reader.go @@ -8,48 +8,54 @@ import ( "path/filepath" "strconv" "strings" + "time" ) +// Reader is a disk access driver type Reader struct { - Group string + Location string } -func (reader *Reader) tryFile(w http.ResponseWriter, r *http.Request, projectName string, subPath ...string) error { - fullPath, err := reader.resolvePath(projectName, subPath...) +func (reader *Reader) tryFile(h handler) error { + fullPath, err := reader.resolvePath(h.LookupPath(), h.Subpath()) + + request := h.Request() + host := request.Host + urlPath := request.URL.Path if locationError, _ := err.(*locationDirectoryError); locationError != nil { - if endsWithSlash(r.URL.Path) { - fullPath, err = reader.resolvePath(projectName, filepath.Join(subPath...), "index.html") + if endsWithSlash(urlPath) { + fullPath, err = reader.resolvePath(h.LookupPath(), h.Subpath(), "index.html") } else { // Concat Host with URL.Path - redirectPath := "//" + r.Host + "/" - redirectPath += strings.TrimPrefix(r.URL.Path, "/") + redirectPath := "//" + host + "/" + redirectPath += strings.TrimPrefix(urlPath, "/") // Ensure that there's always "/" at end redirectPath = strings.TrimSuffix(redirectPath, "/") + "/" - http.Redirect(w, r, redirectPath, 302) + http.Redirect(h.Writer(), h.Request(), redirectPath, 302) return nil } } if locationError, _ := err.(*locationFileNoExtensionError); locationError != nil { - fullPath, err = reader.resolvePath(projectName, strings.TrimSuffix(filepath.Join(subPath...), "/")+".html") + fullPath, err = reader.resolvePath(h.LookupPath(), strings.TrimSuffix(h.Subpath(), "/")+".html") } if err != nil { return err } - return reader.serveFile(w, r, fullPath) + return reader.serveFile(h.Writer(), h.Request(), fullPath, h.HasAccessControl()) } -func (reader *Reader) tryNotFound(w http.ResponseWriter, r *http.Request, projectName string) error { - page404, err := reader.resolvePath(projectName, "404.html") +func (reader *Reader) tryNotFound(h handler) error { + page404, err := reader.resolvePath(h.LookupPath(), "404.html") if err != nil { return err } - err = reader.serveCustomFile(w, r, http.StatusNotFound, page404) + err = reader.serveCustomFile(h.Writer(), h.Request(), http.StatusNotFound, page404) if err != nil { return err } @@ -58,8 +64,8 @@ func (reader *Reader) tryNotFound(w http.ResponseWriter, r *http.Request, projec // Resolve the HTTP request to a path on disk, converting requests for // directories to requests for index.html inside the directory if appropriate. -func (reader *Reader) resolvePath(projectName string, subPath ...string) (string, error) { - publicPath := filepath.Join(reader.Group, projectName, "public") +func (reader *Reader) resolvePath(lookupPath string, subPath ...string) (string, error) { + publicPath := filepath.Join(reader.Location, lookupPath, "public") // Don't use filepath.Join as cleans the path, // where we want to traverse full path as supplied by user @@ -103,7 +109,7 @@ func (reader *Reader) resolvePath(projectName string, subPath ...string) (string return fullPath, nil } -func (reader *Reader) serveFile(w http.ResponseWriter, r *http.Request, origPath string) error { +func (reader *Reader) serveFile(w http.ResponseWriter, r *http.Request, origPath string, accessControl bool) error { fullPath := handleGZip(w, r, origPath) file, err := openNoFollow(fullPath) @@ -118,6 +124,12 @@ func (reader *Reader) serveFile(w http.ResponseWriter, r *http.Request, origPath return err } + if !accessControl { + // Set caching headers + w.Header().Set("Cache-Control", "max-age=600") + w.Header().Set("Expires", time.Now().Add(10*time.Minute).Format(time.RFC1123)) + } + contentType, err := detectContentType(origPath) if err != nil { return err diff --git a/internal/serving/disk/serving.go b/internal/serving/disk/serving.go new file mode 100644 index 00000000..78b1b572 --- /dev/null +++ b/internal/serving/disk/serving.go @@ -0,0 +1,47 @@ +package disk + +import ( + "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" +) + +// Serving describes a disk access serving +type Serving struct { + Domain string // TODO it is not used but might be handy + *Reader +} + +// ServeFileHTTP serves a file from disk and returns true. It returns false +// when a file could not been found. +func (s *Serving) ServeFileHTTP(h handler) bool { + if s.tryFile(h) == nil { + return true + } + + return false +} + +// ServeNotFoundHTTP tries to read a custom 404 page +func (s *Serving) ServeNotFoundHTTP(h handler) { + if s.tryNotFound(h) == nil { + return + } + + // Generic 404 + httperrors.Serve404(h.Writer()) +} + +// HasAcmeChallenge checks if the ACME challenge is present on the disk +func (s *Serving) HasAcmeChallenge(h handler, token string) bool { + _, err := s.resolvePath(h.LookupPath(), ".well-known/acme-challenge", token) + // there is an acme challenge on disk + if err == nil { + return true + } + + _, err = s.resolvePath(h.LookupPath(), ".well-known/acme-challenge", token, "index.html") + if err == nil { + return true + } + + return false +} diff --git a/internal/serving/handler.go b/internal/serving/handler.go new file mode 100644 index 00000000..2a9969b7 --- /dev/null +++ b/internal/serving/handler.go @@ -0,0 +1,16 @@ +package serving + +import "net/http" + +// Handler interface represent an interface that is needed to fullfil the +// serving request +type Handler interface { + Writer() http.ResponseWriter + Request() *http.Request + LookupPath() string + Subpath() string + IsNamespaceProject() bool + IsHTTPSOnly() bool + HasAccessControl() bool + ProjectID() uint64 +} diff --git a/internal/serving/serving.go b/internal/serving/serving.go index b28d0900..c6a39241 100644 --- a/internal/serving/serving.go +++ b/internal/serving/serving.go @@ -1,32 +1,23 @@ package serving import ( - "net/http" - "gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk" ) // Serving is an interface used to define a serving driver type Serving interface { - ServeFileHTTP(http.ResponseWriter, *http.Request) bool - ServeNotFoundHTTP(http.ResponseWriter, *http.Request) - HasAcmeChallenge(token string) bool -} - -func NewProjectDiskServing(project, group string) Serving { - return &disk.Project{ - Location: project, - Reader: &disk.Reader{ - Group: group, - }, - } + ServeFileHTTP(Handler) bool + ServeNotFoundHTTP(Handler) + HasAcmeChallenge(handler Handler, token string) bool } -func NewGroupDiskServing(group string, resolver disk.Resolver) Serving { - return &disk.Group{ - Resolver: resolver, - Reader: &disk.Reader{ - Group: group, +// NewDiskServing returns a serving instance that is capable of reading files +// from the disk +func NewDiskServing(domain, location string) Serving { + return &diskServing{ + disk: &disk.Serving{ + Domain: domain, + Reader: &disk.Reader{Location: location}, }, } } diff --git a/internal/source/disk/custom.go b/internal/source/disk/custom.go new file mode 100644 index 00000000..0cf443b0 --- /dev/null +++ b/internal/source/disk/custom.go @@ -0,0 +1,24 @@ +package disk + +import ( + "net/http" + + "gitlab.com/gitlab-org/gitlab-pages/internal/domain" +) + +type customProjectResolver struct { + config *domainConfig +} + +// TODO tests +func (p *customProjectResolver) Resolve(r *http.Request) (*domain.Project, string, error) { + project := &domain.Project{ + LookupPath: "/", + IsNamespaceProject: false, + IsHTTPSOnly: p.config.HTTPSOnly, + HasAccessControl: p.config.AccessControl, + ID: p.config.ID, + } + + return project, r.URL.Path, nil +} diff --git a/internal/source/disk/group_domain_test.go b/internal/source/disk/domain_test.go index 3b4471f4..80772d37 100644 --- a/internal/source/disk/group_domain_test.go +++ b/internal/source/disk/domain_test.go @@ -27,9 +27,8 @@ func serveFileOrNotFound(domain *domain.Domain) http.HandlerFunc { func testGroupServeHTTPHost(t *testing.T, host string) { testGroup := &domain.Domain{ - Project: "", - Group: "group", - GroupConfig: &Group{ + Location: "group", + Resolver: &Group{ name: "group", projects: map[string]*projectConfig{ "group.test.io": &projectConfig{}, @@ -82,9 +81,11 @@ func TestDomainServeHTTP(t *testing.T) { defer cleanup() testDomain := &domain.Domain{ - Group: "group", - Project: "project2", - ProjectConfig: &domain.ProjectConfig{DomainName: "test.domain.com"}, + Name: "test.domain.com", + Location: "group/project2", + Resolver: &customProjectResolver{ + config: &domainConfig{}, + }, } require.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "/", nil, "project2-main") @@ -108,9 +109,8 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Default group domain with HTTPS-only enabled", domain: &domain.Domain{ - Group: "group", - Project: "project", - GroupConfig: &Group{ + Location: "group/project", + Resolver: &Group{ name: "group", projects: projects{"test-domain": &projectConfig{HTTPSOnly: true}}, }, @@ -121,9 +121,8 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Default group domain with HTTPS-only disabled", domain: &domain.Domain{ - Group: "group", - Project: "project", - GroupConfig: &Group{ + Location: "group/project", + Resolver: &Group{ name: "group", projects: projects{"test-domain": &projectConfig{HTTPSOnly: false}}, }, @@ -134,9 +133,8 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Case-insensitive default group domain with HTTPS-only enabled", domain: &domain.Domain{ - Project: "project", - Group: "group", - GroupConfig: &Group{ + Location: "group/project", + Resolver: &Group{ name: "group", projects: projects{"test-domain": &projectConfig{HTTPSOnly: true}}, }, @@ -147,9 +145,8 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Other group domain with HTTPS-only enabled", domain: &domain.Domain{ - Project: "project", - Group: "group", - GroupConfig: &Group{ + Location: "group/project", + Resolver: &Group{ name: "group", projects: projects{"project": &projectConfig{HTTPSOnly: true}}, }, @@ -160,9 +157,8 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Other group domain with HTTPS-only disabled", domain: &domain.Domain{ - Project: "project", - Group: "group", - GroupConfig: &Group{ + Location: "group/project", + Resolver: &Group{ name: "group", projects: projects{"project": &projectConfig{HTTPSOnly: false}}, }, @@ -173,8 +169,7 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Unknown project", domain: &domain.Domain{ - Group: "group", - Project: "project", + Location: "group/project", }, url: "http://test-domain/project", expected: false, @@ -221,9 +216,8 @@ func TestGroupServeHTTPGzip(t *testing.T) { defer cleanup() testGroup := &domain.Domain{ - Project: "", - Group: "group", - GroupConfig: &Group{ + Location: "group", + Resolver: &Group{ name: "group", projects: map[string]*projectConfig{ "group.test.io": &projectConfig{}, @@ -289,9 +283,8 @@ func TestGroup404ServeHTTP(t *testing.T) { defer cleanup() testGroup := &domain.Domain{ - Project: "", - Group: "group.404", - GroupConfig: &Group{ + Location: "group.404", + Resolver: &Group{ name: "group.404", projects: map[string]*projectConfig{ "domain.404": &projectConfig{}, @@ -319,9 +312,10 @@ func TestDomain404ServeHTTP(t *testing.T) { defer cleanup() testDomain := &domain.Domain{ - Project: "domain.404", - Group: "group.404", - ProjectConfig: &domain.ProjectConfig{DomainName: "domain.404.com"}, + Location: "group.404/domain.404", + Resolver: &customProjectResolver{ + config: &domainConfig{Domain: "domain.404.com"}, + }, } testhelpers.AssertHTTP404(t, serveFileOrNotFound(testDomain), "GET", "http://group.404.test.io/not-existing-file", nil, "Custom 404 group page") @@ -333,7 +327,7 @@ func TestPredefined404ServeHTTP(t *testing.T) { defer cleanup() testDomain := &domain.Domain{ - Group: "group", + Location: "group", } testhelpers.AssertHTTP404(t, serveFileOrNotFound(testDomain), "GET", "http://group.test.io/not-existing-file", nil, "The page you're looking for could not be found") @@ -341,8 +335,7 @@ func TestPredefined404ServeHTTP(t *testing.T) { func TestGroupCertificate(t *testing.T) { testGroup := &domain.Domain{ - Group: "group", - Project: "", + Location: "group", } tls, err := testGroup.EnsureCertificate() @@ -352,9 +345,10 @@ func TestGroupCertificate(t *testing.T) { func TestDomainNoCertificate(t *testing.T) { testDomain := &domain.Domain{ - Group: "group", - Project: "project2", - ProjectConfig: &domain.ProjectConfig{DomainName: "test.domain.com"}, + Location: "group/project2", + Resolver: &customProjectResolver{ + config: &domainConfig{Domain: "test.domain.com"}, + }, } tls, err := testDomain.EnsureCertificate() @@ -368,12 +362,12 @@ func TestDomainNoCertificate(t *testing.T) { func TestDomainCertificate(t *testing.T) { testDomain := &domain.Domain{ - Project: "project2", - Group: "group", - ProjectConfig: &domain.ProjectConfig{DomainName: "test.domain.com", - Certificate: fixture.Certificate, - Key: fixture.Key, - }, + Customized: true, // TODO + Location: "group/project2", + Name: "test.domain.com", + CertificateCert: fixture.Certificate, + CertificateKey: fixture.Key, + Resolver: &customProjectResolver{}, } tls, err := testDomain.EnsureCertificate() @@ -386,8 +380,8 @@ func TestCacheControlHeaders(t *testing.T) { defer cleanup() testGroup := &domain.Domain{ - Group: "group", - GroupConfig: &Group{ + Location: "group", + Resolver: &Group{ name: "group", projects: map[string]*projectConfig{ "group.test.io": &projectConfig{}, diff --git a/internal/source/disk/group.go b/internal/source/disk/group.go index 0c8d0810..efa3fce5 100644 --- a/internal/source/disk/group.go +++ b/internal/source/disk/group.go @@ -1,11 +1,11 @@ package disk import ( - "errors" "net/http" "path" "strings" + "gitlab.com/gitlab-org/gitlab-pages/internal/domain" "gitlab.com/gitlab-org/gitlab-pages/internal/host" ) @@ -74,72 +74,22 @@ func (g *Group) getProjectConfigWithSubpath(r *http.Request) (*projectConfig, st return nil, "", "" } -// IsHTTPSOnly return true if project exists and has https-only setting -// configured -func (g *Group) IsHTTPSOnly(r *http.Request) bool { - project, _, _ := g.getProjectConfigWithSubpath(r) +// Resolve tries to find project and its config recursively for a given request +// to a group domain +func (g *Group) Resolve(r *http.Request) (*domain.Project, string, error) { + projectConfig, projectPath, subPath := g.getProjectConfigWithSubpath(r) - if project != nil { - return project.HTTPSOnly + if projectConfig == nil { + return nil, "", nil // it is not an error when project does not exist } - return false -} - -// HasAccessControl returns true if a group project has access control setting -// enabled -func (g *Group) HasAccessControl(r *http.Request) bool { - project, _, _ := g.getProjectConfigWithSubpath(r) - - if project != nil { - return project.AccessControl - } - - return false -} - -// IsNamespaceProject return true if per-request config belongs to a namespace -// project -func (g *Group) IsNamespaceProject(r *http.Request) bool { - project, _, _ := g.getProjectConfigWithSubpath(r) - - if project != nil { - return project.NamespaceProject - } - - return false -} - -// ProjectID return a per-request group project ID -func (g *Group) ProjectID(r *http.Request) uint64 { - project, _, _ := g.getProjectConfigWithSubpath(r) - - if project != nil { - return project.ID - } - - return 0 -} - -// ProjectExists return true if project config has been found -func (g *Group) ProjectExists(r *http.Request) bool { - project, _, _ := g.getProjectConfigWithSubpath(r) - - if project != nil { - return true - } - - return false -} - -// ProjectWithSubpath tries to find project and its config recursively for a -// given request to a group domain -func (g *Group) ProjectWithSubpath(r *http.Request) (string, string, error) { - project, projectName, subPath := g.getProjectConfigWithSubpath(r) - - if project != nil { - return projectName, subPath, nil + project := &domain.Project{ + LookupPath: projectPath, + IsNamespaceProject: projectConfig.NamespaceProject, + IsHTTPSOnly: projectConfig.HTTPSOnly, + HasAccessControl: projectConfig.AccessControl, + ID: projectConfig.ID, } - return "", "", errors.New("project not found") + return project, subPath, nil } diff --git a/internal/source/disk/map.go b/internal/source/disk/map.go index dd368501..c330a2ea 100644 --- a/internal/source/disk/map.go +++ b/internal/source/disk/map.go @@ -24,11 +24,9 @@ type domainsUpdater func(Map) func (dm Map) updateDomainMap(domainName string, domain *domain.Domain) { if old, ok := dm[domainName]; ok { log.WithFields(log.Fields{ - "domain_name": domainName, - "new_group": domain.Group, - "new_project_name": domain.Project, - "old_group": old.Group, - "old_project_name": old.Project, + "domain_name": domainName, + "new_location": domain.Location, + "old_location": old.Location, }).Error("Duplicate domain") } @@ -37,21 +35,15 @@ func (dm Map) updateDomainMap(domainName string, domain *domain.Domain) { func (dm Map) addDomain(rootDomain, groupName, projectName string, config *domainConfig) { newDomain := &domain.Domain{ - Group: groupName, - Project: projectName, - ProjectConfig: &domain.ProjectConfig{ - DomainName: config.Domain, - Certificate: config.Certificate, - Key: config.Key, - HTTPSOnly: config.HTTPSOnly, - ProjectID: config.ID, - AccessControl: config.AccessControl, - }, + Name: strings.ToLower(config.Domain), + Customized: true, // TODO remove + CertificateCert: config.Certificate, + CertificateKey: config.Key, + Location: filepath.Join(groupName, projectName), + Resolver: &customProjectResolver{config: config}, } - var domainName string - domainName = strings.ToLower(config.Domain) - dm.updateDomainMap(domainName, newDomain) + dm.updateDomainMap(newDomain.Name, newDomain) } func (dm Map) updateGroupDomain(rootDomain, groupName, projectPath string, httpsOnly bool, accessControl bool, id uint64) { @@ -59,21 +51,23 @@ func (dm Map) updateGroupDomain(rootDomain, groupName, projectPath string, https groupDomain := dm[domainName] if groupDomain == nil { - group := &Group{ + groupResolver := &Group{ name: groupName, projects: make(projects), subgroups: make(subgroups), } groupDomain = &domain.Domain{ - Group: groupName, - GroupConfig: group, + Name: domainName, + Customized: false, // TODO remove + Location: groupName, + Resolver: groupResolver, } } split := strings.SplitN(strings.ToLower(projectPath), "/", maxProjectDepth) projectName := split[len(split)-1] - g := groupDomain.GroupConfig.(*Group) + g := groupDomain.Resolver.(*Group) for i := 0; i < len(split)-1; i++ { subgroupName := split[i] diff --git a/internal/source/disk/map_test.go b/internal/source/disk/map_test.go index 9aa96072..c15f29c6 100644 --- a/internal/source/disk/map_test.go +++ b/internal/source/disk/map_test.go @@ -68,15 +68,15 @@ func TestReadProjects(t *testing.T) { } // Check that multiple domains in the same project are recorded faithfully - require.Equal(t, "test.domain.com", dm["test.domain.com"].ProjectConfig.DomainName) - require.Equal(t, "other.domain.com", dm["other.domain.com"].ProjectConfig.DomainName) - require.Equal(t, "test", dm["other.domain.com"].ProjectConfig.Certificate) - require.Equal(t, "key", dm["other.domain.com"].ProjectConfig.Key) + require.Equal(t, "test.domain.com", dm["test.domain.com"].Name) + require.Equal(t, "other.domain.com", dm["other.domain.com"].Name) + require.Equal(t, "test", dm["other.domain.com"].CertificateCert) + require.Equal(t, "key", dm["other.domain.com"].CertificateKey) // check subgroups domain, ok := dm["group.test.io"] require.True(t, ok, "missing group.test.io domain") - subgroup, ok := domain.GroupConfig.(*Group).subgroups["subgroup"] + subgroup, ok := domain.Resolver.(*Group).subgroups["subgroup"] require.True(t, ok, "missing group.test.io subgroup") _, ok = subgroup.projects["project"] require.True(t, ok, "missing project for subgroup in group.test.io domain") @@ -117,7 +117,7 @@ func TestReadProjectsMaxDepth(t *testing.T) { // check subgroups domain, ok := dm["group-0.test.io"] require.True(t, ok, "missing group-0.test.io domain") - subgroup := domain.GroupConfig.(*Group) + subgroup := domain.Resolver.(*Group) for i := 0; i < levels; i++ { subgroup, ok = subgroup.subgroups["sub"] if i <= subgroupScanLimit { |