diff options
author | Nick Thomas <nick@gitlab.com> | 2019-10-02 16:08:18 +0300 |
---|---|---|
committer | Nick Thomas <nick@gitlab.com> | 2019-10-02 16:08:18 +0300 |
commit | 5b5e846dd117fe9b406b52681436553799f96635 (patch) | |
tree | 6c91b45200631720338466a49a05f849feddfc3a | |
parent | 9943255d61c5646f6cf9e1a8a03e4a2dc19831f5 (diff) | |
parent | 9ecd5703d7c0325c01c65c51a9ec625b1436a1ad (diff) |
Merge branch 'backstage/gb/domain-serving-refactoring' into 'master'
Refactor domain package and extract disk serving
See merge request gitlab-org/gitlab-pages!189
-rw-r--r-- | app.go | 4 | ||||
-rw-r--r-- | internal/acme/acme.go | 4 | ||||
-rw-r--r-- | internal/acme/acme_test.go | 8 | ||||
-rw-r--r-- | internal/domain/config.go | 11 | ||||
-rw-r--r-- | internal/domain/domain.go | 446 | ||||
-rw-r--r-- | internal/domain/domain_test.go | 187 | ||||
-rw-r--r-- | internal/domain/resolver.go | 14 | ||||
-rw-r--r-- | internal/logging/logging.go | 2 | ||||
-rw-r--r-- | internal/serving/disk/errors.go | 18 | ||||
-rw-r--r-- | internal/serving/disk/helpers.go | 78 | ||||
-rw-r--r-- | internal/serving/disk/helpers_test.go | 32 | ||||
-rw-r--r-- | internal/serving/disk/reader.go | 173 | ||||
-rw-r--r-- | internal/serving/disk/serving.go | 37 | ||||
-rw-r--r-- | internal/serving/handler.go | 13 | ||||
-rw-r--r-- | internal/serving/lookup_path.go | 11 | ||||
-rw-r--r-- | internal/serving/serving.go | 7 | ||||
-rw-r--r-- | internal/source/disk/custom.go | 27 | ||||
-rw-r--r-- | internal/source/disk/domain_test.go (renamed from internal/source/disk/group_domain_test.go) | 83 | ||||
-rw-r--r-- | internal/source/disk/group.go | 89 | ||||
-rw-r--r-- | internal/source/disk/map.go | 43 | ||||
-rw-r--r-- | internal/source/disk/map_test.go | 12 |
21 files changed, 590 insertions, 709 deletions
@@ -103,7 +103,7 @@ func (a *theApp) domain(host string) *domain.Domain { } func (a *theApp) checkAuthenticationIfNotExists(domain *domain.Domain, w http.ResponseWriter, r *http.Request) bool { - if domain == nil || !domain.HasProject(r) { + if domain == nil || !domain.HasLookupPath(r) { // Only if auth is supported if a.Auth.IsAuthSupported() { @@ -231,7 +231,7 @@ func (a *theApp) accessControlMiddleware(handler http.Handler) http.Handler { // Only for projects that have access control enabled if domain.IsAccessControlEnabled(r) { // accessControlMiddleware - if a.Auth.CheckAuthentication(w, r, domain.GetID(r)) { + if a.Auth.CheckAuthentication(w, r, domain.GetProjectID(r)) { return } } diff --git a/internal/acme/acme.go b/internal/acme/acme.go index 89881f34..3bfa8f2e 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 + ServeFileHTTP(w http.ResponseWriter, r *http.Request) 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.ServeFileHTTP(w, r) { return false } diff --git a/internal/acme/acme_test.go b/internal/acme/acme_test.go index c0daefeb..ab191694 100644 --- a/internal/acme/acme_test.go +++ b/internal/acme/acme_test.go @@ -11,8 +11,12 @@ type domainStub struct { hasAcmeChallenge bool } -func (d *domainStub) HasAcmeChallenge(_ string) bool { - return d.hasAcmeChallenge +func (d *domainStub) ServeFileHTTP(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path == "/.well-known/acme-challenge/token" { + return d.hasAcmeChallenge + } + + return false } func serveAcmeOrNotFound(m *Middleware, domain Domain) http.HandlerFunc { 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 784f5d41..f7eba5ca 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -3,49 +3,23 @@ package domain import ( "crypto/tls" "errors" - "fmt" - "io" - "mime" "net/http" - "os" - "path/filepath" - "strconv" - "strings" "sync" - "time" - - "golang.org/x/sys/unix" "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" - "gitlab.com/gitlab-org/gitlab-pages/internal/httputil" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk" ) -type locationDirectoryError struct { - FullPath string - RelativePath string -} - -type locationFileNoExtensionError struct { - FullPath string -} - -// 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 + CertificateCert string + CertificateKey string + + Resolver Resolver - ProjectConfig *ProjectConfig - GroupConfig GroupConfig // handles group domain config + serving serving.Serving certificate *tls.Certificate certificateError error @@ -54,23 +28,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 -} - -func (d *Domain) isCustomDomain() bool { - if d.isUnconfigured() { - panic("project config and group config should not be nil at the same time") - } - - return d.ProjectConfig != nil && d.GroupConfig == nil + return d.Name } func (d *Domain) isUnconfigured() bool { @@ -78,42 +36,45 @@ func (d *Domain) isUnconfigured() bool { return true } - return d.ProjectConfig == nil && d.GroupConfig == nil -} - -func (l *locationDirectoryError) Error() string { - return "location error accessing directory where file expected" + return d.Resolver == nil } -func (l *locationFileNoExtensionError) Error() string { - return "error accessing a path without an extension" -} +func (d *Domain) resolve(r *http.Request) (*serving.LookupPath, string) { + lookupPath, subpath, _ := d.Resolver.Resolve(r) -func acceptsGZip(r *http.Request) bool { - if r.Header.Get("Range") != "" { - return false + // Current implementation does not return errors in any case + if lookupPath == nil { + return nil, "" } - offers := []string{"gzip", "identity"} - acceptedEncoding := httputil.NegotiateContentEncoding(r, offers) - return acceptedEncoding == "gzip" + return lookupPath, subpath } -func handleGZip(w http.ResponseWriter, r *http.Request, fullPath string) string { - if !acceptsGZip(r) { - return fullPath - } +// GetLookupPath returns a project details based on the request +func (d *Domain) GetLookupPath(r *http.Request) *serving.LookupPath { + lookupPath, _ := d.resolve(r) - gzipPath := fullPath + ".gz" + return lookupPath +} - // Ensure the .gz file is not a symlink - if fi, err := os.Lstat(gzipPath); err != nil || !fi.Mode().IsRegular() { - return fullPath +// Serving returns domain serving driver +func (d *Domain) Serving() serving.Serving { + if d.serving == nil { + d.serving = disk.New() } - w.Header().Set("Content-Encoding", "gzip") + return d.serving +} + +func (d *Domain) toHandler(w http.ResponseWriter, r *http.Request) serving.Handler { + project, subpath := d.resolve(r) - return gzipPath + return serving.Handler{ + Writer: w, + Request: r, + LookupPath: project, + SubPath: subpath, + } } // IsHTTPSOnly figures out if the request should be handled with HTTPS @@ -123,13 +84,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 lookupPath := d.GetLookupPath(r); lookupPath != nil { + return lookupPath.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 @@ -138,32 +97,8 @@ 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 - } - - // Check projects served under the group domain, including the default one - return d.GroupConfig.HasAccessControl(r) -} - -// HasAcmeChallenge checks domain directory contains particular acme challenge -func (d *Domain) HasAcmeChallenge(token string) bool { - if d.isUnconfigured() || !d.isCustomDomain() { - return false - } - - _, err := d.resolvePath(d.Project, ".well-known/acme-challenge", token) - - // there is an acme challenge on disk - if err == nil { - return true - } - - _, err = d.resolvePath(d.Project, ".well-known/acme-challenge", token, "index.html") - - if err == nil { - return true + if lookupPath := d.GetLookupPath(r); lookupPath != nil { + return lookupPath.HasAccessControl } return false @@ -175,281 +110,46 @@ func (d *Domain) IsNamespaceProject(r *http.Request) bool { return false } - // 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() { - return false + if lookupPath := d.GetLookupPath(r); lookupPath != nil { + return lookupPath.IsNamespaceProject } - // Check projects served under the group domain, including the default one - return d.GroupConfig.IsNamespaceProject(r) + return false } -// GetID figures out what is the ID of the project user tries to access -func (d *Domain) GetID(r *http.Request) uint64 { +// GetProjectID figures out what is the ID of the project user tries to access +func (d *Domain) GetProjectID(r *http.Request) uint64 { if d.isUnconfigured() { return 0 } - if d.isCustomDomain() { - return d.ProjectConfig.ProjectID + if lookupPath := d.GetLookupPath(r); lookupPath != nil { + return lookupPath.ProjectID } - return d.GroupConfig.ProjectID(r) + return 0 } -// HasProject figures out if the project exists that the user tries to access -func (d *Domain) HasProject(r *http.Request) bool { +// HasLookupPath figures out if the project exists that the user tries to access +func (d *Domain) HasLookupPath(r *http.Request) bool { if d.isUnconfigured() { return false } - if d.isCustomDomain() { - return true - } - - return d.GroupConfig.ProjectExists(r) -} - -// Detect file's content-type either by extension or mime-sniffing. -// Implementation is adapted from Golang's `http.serveContent()` -// See https://github.com/golang/go/blob/902fc114272978a40d2e65c2510a18e870077559/src/net/http/fs.go#L194 -func (d *Domain) detectContentType(path string) (string, error) { - contentType := mime.TypeByExtension(filepath.Ext(path)) - - if contentType == "" { - var buf [512]byte - - file, err := os.Open(path) - if err != nil { - return "", err - } - - defer file.Close() - - // Using `io.ReadFull()` because `file.Read()` may be chunked. - // Ignoring errors because we don't care if the 512 bytes cannot be read. - n, _ := io.ReadFull(file, buf[:]) - contentType = http.DetectContentType(buf[:n]) - } - - return contentType, nil -} - -func (d *Domain) serveFile(w http.ResponseWriter, r *http.Request, origPath string) error { - fullPath := handleGZip(w, r, origPath) - - file, err := openNoFollow(fullPath) - if err != nil { - return err - } - - defer file.Close() - - fi, err := file.Stat() - if err != nil { - return err - } - - 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)) - } - - contentType, err := d.detectContentType(origPath) - if err != nil { - return err - } - - w.Header().Set("Content-Type", contentType) - http.ServeContent(w, r, origPath, fi.ModTime(), file) - - return nil -} - -func (d *Domain) serveCustomFile(w http.ResponseWriter, r *http.Request, code int, origPath string) error { - fullPath := handleGZip(w, r, origPath) - - // Open and serve content of file - file, err := openNoFollow(fullPath) - if err != nil { - return err - } - defer file.Close() - - fi, err := file.Stat() - if err != nil { - return err - } - - contentType, err := d.detectContentType(origPath) - if err != nil { - return err - } - - w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) - w.WriteHeader(code) - - if r.Method != "HEAD" { - _, err := io.CopyN(w, file, fi.Size()) - return err - } - - return nil -} - -// Resolve the HTTP request to a path on disk, converting requests for -// directories to requests for index.html inside the directory if appropriate. -func (d *Domain) resolvePath(projectName string, subPath ...string) (string, error) { - publicPath := filepath.Join(d.Group, projectName, "public") - - // Don't use filepath.Join as cleans the path, - // where we want to traverse full path as supplied by user - // (including ..) - testPath := publicPath + "/" + strings.Join(subPath, "/") - fullPath, err := filepath.EvalSymlinks(testPath) - if err != nil { - if endsWithoutHTMLExtension(testPath) { - return "", &locationFileNoExtensionError{ - FullPath: fullPath, - } - } - - return "", err - } - - // The requested path resolved to somewhere outside of the public/ directory - if !strings.HasPrefix(fullPath, publicPath+"/") && fullPath != publicPath { - return "", fmt.Errorf("%q should be in %q", fullPath, publicPath) - } - - fi, err := os.Lstat(fullPath) - if err != nil { - return "", err - } - - // The requested path is a directory, so try index.html via recursion - if fi.IsDir() { - return "", &locationDirectoryError{ - FullPath: fullPath, - RelativePath: strings.TrimPrefix(fullPath, publicPath), - } - } - - // The file exists, but is not a supported type to serve. Perhaps a block - // special device or something else that may be a security risk. - if !fi.Mode().IsRegular() { - return "", fmt.Errorf("%s: is not a regular file", fullPath) - } - - return fullPath, nil -} - -func (d *Domain) tryNotFound(w http.ResponseWriter, r *http.Request, projectName string) error { - page404, err := d.resolvePath(projectName, "404.html") - if err != nil { - return err - } - - err = d.serveCustomFile(w, r, http.StatusNotFound, page404) - if err != nil { - return err - } - return nil -} - -func (d *Domain) tryFile(w http.ResponseWriter, r *http.Request, projectName string, subPath ...string) error { - fullPath, err := d.resolvePath(projectName, subPath...) - - if locationError, _ := err.(*locationDirectoryError); locationError != nil { - if endsWithSlash(r.URL.Path) { - fullPath, err = d.resolvePath(projectName, filepath.Join(subPath...), "index.html") - } else { - // Concat Host with URL.Path - redirectPath := "//" + r.Host + "/" - redirectPath += strings.TrimPrefix(r.URL.Path, "/") - - // Ensure that there's always "/" at end - redirectPath = strings.TrimSuffix(redirectPath, "/") + "/" - http.Redirect(w, r, redirectPath, 302) - return nil - } - } - - if locationError, _ := err.(*locationFileNoExtensionError); locationError != nil { - fullPath, err = d.resolvePath(projectName, strings.TrimSuffix(filepath.Join(subPath...), "/")+".html") - } - - if err != nil { - return err - } - - return d.serveFile(w, r, fullPath) -} - -func (d *Domain) serveFileFromGroup(w http.ResponseWriter, r *http.Request) bool { - projectName, subPath, err := d.GroupConfig.ProjectWithSubpath(r) - if err != nil { - httperrors.Serve404(w) - return true - } - - if d.tryFile(w, r, projectName, subPath) == nil { - return true - } - - return false -} - -func (d *Domain) serveNotFoundFromGroup(w http.ResponseWriter, r *http.Request) { - projectName, _, err := d.GroupConfig.ProjectWithSubpath(r) - - if err != nil { - httperrors.Serve404(w) - return - } - - // Try serving custom not-found page - if d.tryNotFound(w, r, projectName) == nil { - return - } - - // Generic 404 - httperrors.Serve404(w) -} - -func (d *Domain) serveFileFromConfig(w http.ResponseWriter, r *http.Request) bool { - // Try to serve file for http://host/... => /group/project/... - if d.tryFile(w, r, d.Project, r.URL.Path) == nil { - return true - } - - return false -} - -func (d *Domain) serveNotFoundFromConfig(w http.ResponseWriter, r *http.Request) { - // Try serving not found page for http://host/ => /group/project/404.html - if d.tryNotFound(w, r, d.Project) == nil { - return - } - - // Serve generic not found - httperrors.Serve404(w) + return d.GetLookupPath(r) != nil } // EnsureCertificate parses the PEM-encoded certificate for the domain func (d *Domain) EnsureCertificate() (*tls.Certificate, error) { - if d.isUnconfigured() || !d.isCustomDomain() { + if d.isUnconfigured() || len(d.CertificateKey) == 0 || len(d.CertificateCert) == 0 { return nil, errors.New("tls certificates can be loaded only for pages with configuration") } 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 @@ -459,42 +159,26 @@ func (d *Domain) EnsureCertificate() (*tls.Certificate, error) { return d.certificate, d.certificateError } -// ServeFileHTTP implements http.Handler. Returns true if something was served, false if not. +// 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.HasLookupPath(r) { + // TODO: this seems to be wrong: + // as we should rather return false, + // and fallback to `ServeNotFoundHTTP` + // to handle this case httperrors.Serve404(w) return true } - if d.isCustomDomain() { - return d.serveFileFromConfig(w, r) - } - - return d.serveFileFromGroup(w, r) + return d.Serving().ServeFileHTTP(d.toHandler(w, r)) } -// ServeNotFoundHTTP implements http.Handler. Serves the not found pages from the projects. +// 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.HasLookupPath(r) { httperrors.Serve404(w) return } - if d.isCustomDomain() { - d.serveNotFoundFromConfig(w, r) - } else { - d.serveNotFoundFromGroup(w, r) - } -} - -func endsWithSlash(path string) bool { - return strings.HasSuffix(path, "/") -} - -func endsWithoutHTMLExtension(path string) bool { - return !strings.HasSuffix(path, ".html") -} - -func openNoFollow(path string) (*os.File, error) { - return os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) + d.Serving().ServeNotFoundHTTP(d.toHandler(w, r)) } diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go index d59b9ce3..a43d3c9a 100644 --- a/internal/domain/domain_test.go +++ b/internal/domain/domain_test.go @@ -11,10 +11,20 @@ import ( "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitlab-pages/internal/fixture" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" "gitlab.com/gitlab-org/gitlab-pages/internal/testhelpers" ) +type stubbedResolver struct { + project *serving.LookupPath + subpath string + err error +} + +func (resolver *stubbedResolver) Resolve(*http.Request) (*serving.LookupPath, 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 +33,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 +43,12 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Custom domain with HTTPS-only enabled", domain: &Domain{ - Project: "project", - Group: "group", - ProjectConfig: &ProjectConfig{HTTPSOnly: true}, + Resolver: &stubbedResolver{ + project: &serving.LookupPath{ + Path: "group/project/public", + IsHTTPSOnly: true, + }, + }, }, url: "http://custom-domain", expected: true, @@ -64,19 +56,19 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Custom domain with HTTPS-only disabled", domain: &Domain{ - Project: "project", - Group: "group", - ProjectConfig: &ProjectConfig{HTTPSOnly: false}, + Resolver: &stubbedResolver{ + project: &serving.LookupPath{ + Path: "group/project/public", + IsHTTPSOnly: false, + }, + }, }, url: "http://custom-domain", expected: false, }, { - name: "Unknown project", - domain: &Domain{ - Project: "project", - Group: "group", - }, + name: "Unknown project", + domain: &Domain{}, url: "http://test-domain/project", expected: false, }, @@ -90,69 +82,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,36 +109,17 @@ 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", - } + testDomain := &Domain{} testhelpers.AssertHTTP404(t, serveFileOrNotFound(testDomain), "GET", "http://group.test.io/not-existing-file", nil, "The page you're looking for could not be found") } func TestGroupCertificate(t *testing.T) { - testGroup := &Domain{ - Project: "", - Group: "group", - } + testGroup := &Domain{} tls, err := testGroup.EnsureCertificate() require.Nil(t, tls) @@ -218,9 +128,7 @@ 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", } tls, err := testDomain.EnsureCertificate() @@ -232,45 +140,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) -} - -func TestOpenNoFollow(t *testing.T) { - tmpfile, err := ioutil.TempFile("", "link-test") - require.NoError(t, err) - defer tmpfile.Close() - - orig := tmpfile.Name() - softLink := orig + ".link" - defer os.Remove(orig) - - source, err := openNoFollow(orig) - require.NoError(t, err) - require.NotNil(t, source) - defer source.Close() - - err = os.Symlink(orig, softLink) - require.NoError(t, err) - defer os.Remove(softLink) - - link, err := openNoFollow(softLink) - require.Error(t, err) - require.Nil(t, link) -} - var chdirSet = false func setUpTests(t require.TestingT) func() { diff --git a/internal/domain/resolver.go b/internal/domain/resolver.go new file mode 100644 index 00000000..0de66ad5 --- /dev/null +++ b/internal/domain/resolver.go @@ -0,0 +1,14 @@ +package domain + +import ( + "net/http" + + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" +) + +// 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) (*serving.LookupPath, string, error) +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 9269261d..28c43c2e 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -55,7 +55,7 @@ func getAccessLogger(format string) (*logrus.Logger, error) { func getExtraLogFields(r *http.Request) log.Fields { var projectID uint64 if d := request.GetDomain(r); d != nil { - projectID = d.GetID(r) + projectID = d.GetProjectID(r) } return log.Fields{ diff --git a/internal/serving/disk/errors.go b/internal/serving/disk/errors.go new file mode 100644 index 00000000..5e55220b --- /dev/null +++ b/internal/serving/disk/errors.go @@ -0,0 +1,18 @@ +package disk + +type locationDirectoryError struct { + FullPath string + RelativePath string +} + +type locationFileNoExtensionError struct { + FullPath string +} + +func (l *locationDirectoryError) Error() string { + return "location error accessing directory where file expected" +} + +func (l *locationFileNoExtensionError) Error() string { + return "error accessing a path without an extension" +} diff --git a/internal/serving/disk/helpers.go b/internal/serving/disk/helpers.go new file mode 100644 index 00000000..01f59767 --- /dev/null +++ b/internal/serving/disk/helpers.go @@ -0,0 +1,78 @@ +package disk + +import ( + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "gitlab.com/gitlab-org/gitlab-pages/internal/httputil" +) + +func endsWithSlash(path string) bool { + return strings.HasSuffix(path, "/") +} + +func endsWithoutHTMLExtension(path string) bool { + return !strings.HasSuffix(path, ".html") +} + +func openNoFollow(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) +} + +// Detect file's content-type either by extension or mime-sniffing. +// Implementation is adapted from Golang's `http.serveContent()` +// See https://github.com/golang/go/blob/902fc114272978a40d2e65c2510a18e870077559/src/net/http/fs.go#L194 +func detectContentType(path string) (string, error) { + contentType := mime.TypeByExtension(filepath.Ext(path)) + + if contentType == "" { + var buf [512]byte + + file, err := os.Open(path) + if err != nil { + return "", err + } + + defer file.Close() + + // Using `io.ReadFull()` because `file.Read()` may be chunked. + // Ignoring errors because we don't care if the 512 bytes cannot be read. + n, _ := io.ReadFull(file, buf[:]) + contentType = http.DetectContentType(buf[:n]) + } + + return contentType, nil +} + +func acceptsGZip(r *http.Request) bool { + if r.Header.Get("Range") != "" { + return false + } + + offers := []string{"gzip", "identity"} + acceptedEncoding := httputil.NegotiateContentEncoding(r, offers) + return acceptedEncoding == "gzip" +} + +func handleGZip(w http.ResponseWriter, r *http.Request, fullPath string) string { + if !acceptsGZip(r) { + return fullPath + } + + gzipPath := fullPath + ".gz" + + // Ensure the .gz file is not a symlink + if fi, err := os.Lstat(gzipPath); err != nil || !fi.Mode().IsRegular() { + return fullPath + } + + w.Header().Set("Content-Encoding", "gzip") + + return gzipPath +} diff --git a/internal/serving/disk/helpers_test.go b/internal/serving/disk/helpers_test.go new file mode 100644 index 00000000..eea51500 --- /dev/null +++ b/internal/serving/disk/helpers_test.go @@ -0,0 +1,32 @@ +package disk + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenNoFollow(t *testing.T) { + tmpfile, err := ioutil.TempFile("", "link-test") + require.NoError(t, err) + defer tmpfile.Close() + + orig := tmpfile.Name() + softLink := orig + ".link" + defer os.Remove(orig) + + source, err := openNoFollow(orig) + require.NoError(t, err) + require.NotNil(t, source) + defer source.Close() + + err = os.Symlink(orig, softLink) + require.NoError(t, err) + defer os.Remove(softLink) + + link, err := openNoFollow(softLink) + require.Error(t, err) + require.Nil(t, link) +} diff --git a/internal/serving/disk/reader.go b/internal/serving/disk/reader.go new file mode 100644 index 00000000..b52b5cff --- /dev/null +++ b/internal/serving/disk/reader.go @@ -0,0 +1,173 @@ +package disk + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" +) + +// Reader is a disk access driver +type Reader struct { +} + +func (reader *Reader) tryFile(h serving.Handler) error { + fullPath, err := reader.resolvePath(h.LookupPath.Path, h.SubPath) + + request := h.Request + host := request.Host + urlPath := request.URL.Path + + if locationError, _ := err.(*locationDirectoryError); locationError != nil { + if endsWithSlash(urlPath) { + fullPath, err = reader.resolvePath(h.LookupPath.Path, h.SubPath, "index.html") + } else { + // Concat Host with URL.Path + redirectPath := "//" + host + "/" + redirectPath += strings.TrimPrefix(urlPath, "/") + + // Ensure that there's always "/" at end + redirectPath = strings.TrimSuffix(redirectPath, "/") + "/" + http.Redirect(h.Writer, h.Request, redirectPath, 302) + return nil + } + } + + if locationError, _ := err.(*locationFileNoExtensionError); locationError != nil { + fullPath, err = reader.resolvePath(h.LookupPath.Path, strings.TrimSuffix(h.SubPath, "/")+".html") + } + + if err != nil { + return err + } + + return reader.serveFile(h.Writer, h.Request, fullPath, h.LookupPath.HasAccessControl) +} + +func (reader *Reader) tryNotFound(h serving.Handler) error { + page404, err := reader.resolvePath(h.LookupPath.Path, "404.html") + if err != nil { + return err + } + + err = reader.serveCustomFile(h.Writer, h.Request, http.StatusNotFound, page404) + if err != nil { + return err + } + return nil +} + +// 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(publicPath string, subPath ...string) (string, error) { + // Don't use filepath.Join as cleans the path, + // where we want to traverse full path as supplied by user + // (including ..) + testPath := publicPath + "/" + strings.Join(subPath, "/") + fullPath, err := filepath.EvalSymlinks(testPath) + if err != nil { + if endsWithoutHTMLExtension(testPath) { + return "", &locationFileNoExtensionError{ + FullPath: fullPath, + } + } + + return "", err + } + + // The requested path resolved to somewhere outside of the public/ directory + if !strings.HasPrefix(fullPath, publicPath+"/") && fullPath != publicPath { + return "", fmt.Errorf("%q should be in %q", fullPath, publicPath) + } + + fi, err := os.Lstat(fullPath) + if err != nil { + return "", err + } + + // The requested path is a directory, so try index.html via recursion + if fi.IsDir() { + return "", &locationDirectoryError{ + FullPath: fullPath, + RelativePath: strings.TrimPrefix(fullPath, publicPath), + } + } + + // The file exists, but is not a supported type to serve. Perhaps a block + // special device or something else that may be a security risk. + if !fi.Mode().IsRegular() { + return "", fmt.Errorf("%s: is not a regular file", fullPath) + } + + return fullPath, nil +} + +func (reader *Reader) serveFile(w http.ResponseWriter, r *http.Request, origPath string, accessControl bool) error { + fullPath := handleGZip(w, r, origPath) + + file, err := openNoFollow(fullPath) + if err != nil { + return err + } + + defer file.Close() + + fi, err := file.Stat() + if err != nil { + 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 + } + + w.Header().Set("Content-Type", contentType) + http.ServeContent(w, r, origPath, fi.ModTime(), file) + + return nil +} + +func (reader *Reader) serveCustomFile(w http.ResponseWriter, r *http.Request, code int, origPath string) error { + fullPath := handleGZip(w, r, origPath) + + // Open and serve content of file + file, err := openNoFollow(fullPath) + if err != nil { + return err + } + defer file.Close() + + fi, err := file.Stat() + if err != nil { + return err + } + + contentType, err := detectContentType(origPath) + if err != nil { + return err + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) + w.WriteHeader(code) + + if r.Method != "HEAD" { + _, err := io.CopyN(w, file, fi.Size()) + return err + } + + return nil +} diff --git a/internal/serving/disk/serving.go b/internal/serving/disk/serving.go new file mode 100644 index 00000000..db184d3c --- /dev/null +++ b/internal/serving/disk/serving.go @@ -0,0 +1,37 @@ +package disk + +import ( + "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" +) + +// Serving describes a disk access serving +type Serving struct { + 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 serving.Handler) bool { + if s.tryFile(h) == nil { + return true + } + + return false +} + +// ServeNotFoundHTTP tries to read a custom 404 page +func (s *Serving) ServeNotFoundHTTP(h serving.Handler) { + if s.tryNotFound(h) == nil { + return + } + + // Generic 404 + httperrors.Serve404(h.Writer) +} + +// New returns a serving instance that is capable of reading files +// from the disk +func New() serving.Serving { + return &Serving{} +} diff --git a/internal/serving/handler.go b/internal/serving/handler.go new file mode 100644 index 00000000..53a783ce --- /dev/null +++ b/internal/serving/handler.go @@ -0,0 +1,13 @@ +package serving + +import "net/http" + +// Handler agregates response/request and lookup path + subpath needed to +// handle a request and response. +type Handler struct { + Writer http.ResponseWriter + Request *http.Request + LookupPath *LookupPath + // Parsed representation of Request.URI that is part of LookupPath.Prefix + SubPath string +} diff --git a/internal/serving/lookup_path.go b/internal/serving/lookup_path.go new file mode 100644 index 00000000..ba6e8f7a --- /dev/null +++ b/internal/serving/lookup_path.go @@ -0,0 +1,11 @@ +package serving + +// LookupPath holds a domain project configuration needed to handle a request +type LookupPath struct { + Location string + Path string + IsNamespaceProject bool + IsHTTPSOnly bool + HasAccessControl bool + ProjectID uint64 +} diff --git a/internal/serving/serving.go b/internal/serving/serving.go new file mode 100644 index 00000000..6fde8216 --- /dev/null +++ b/internal/serving/serving.go @@ -0,0 +1,7 @@ +package serving + +// Serving is an interface used to define a serving driver +type Serving interface { + ServeFileHTTP(Handler) bool + ServeNotFoundHTTP(Handler) +} diff --git a/internal/source/disk/custom.go b/internal/source/disk/custom.go new file mode 100644 index 00000000..8a080f20 --- /dev/null +++ b/internal/source/disk/custom.go @@ -0,0 +1,27 @@ +package disk + +import ( + "net/http" + + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" +) + +type customProjectResolver struct { + config *domainConfig + + path string +} + +// TODO tests +func (p *customProjectResolver) Resolve(r *http.Request) (*serving.LookupPath, string, error) { + lookupPath := &serving.LookupPath{ + Location: "/", + Path: p.path, + IsNamespaceProject: false, + IsHTTPSOnly: p.config.HTTPSOnly, + HasAccessControl: p.config.AccessControl, + ProjectID: p.config.ID, + } + + return lookupPath, r.URL.Path, nil +} diff --git a/internal/source/disk/group_domain_test.go b/internal/source/disk/domain_test.go index 3b4471f4..ba4fb161 100644 --- a/internal/source/disk/group_domain_test.go +++ b/internal/source/disk/domain_test.go @@ -27,9 +27,7 @@ func serveFileOrNotFound(domain *domain.Domain) http.HandlerFunc { func testGroupServeHTTPHost(t *testing.T, host string) { testGroup := &domain.Domain{ - Project: "", - Group: "group", - GroupConfig: &Group{ + Resolver: &Group{ name: "group", projects: map[string]*projectConfig{ "group.test.io": &projectConfig{}, @@ -82,9 +80,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", + Resolver: &customProjectResolver{ + path: "group/project2/public", + config: &domainConfig{}, + }, } require.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "/", nil, "project2-main") @@ -108,9 +108,7 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Default group domain with HTTPS-only enabled", domain: &domain.Domain{ - Group: "group", - Project: "project", - GroupConfig: &Group{ + Resolver: &Group{ name: "group", projects: projects{"test-domain": &projectConfig{HTTPSOnly: true}}, }, @@ -121,9 +119,7 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Default group domain with HTTPS-only disabled", domain: &domain.Domain{ - Group: "group", - Project: "project", - GroupConfig: &Group{ + Resolver: &Group{ name: "group", projects: projects{"test-domain": &projectConfig{HTTPSOnly: false}}, }, @@ -134,9 +130,7 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Case-insensitive default group domain with HTTPS-only enabled", domain: &domain.Domain{ - Project: "project", - Group: "group", - GroupConfig: &Group{ + Resolver: &Group{ name: "group", projects: projects{"test-domain": &projectConfig{HTTPSOnly: true}}, }, @@ -147,9 +141,7 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Other group domain with HTTPS-only enabled", domain: &domain.Domain{ - Project: "project", - Group: "group", - GroupConfig: &Group{ + Resolver: &Group{ name: "group", projects: projects{"project": &projectConfig{HTTPSOnly: true}}, }, @@ -160,9 +152,7 @@ func TestIsHTTPSOnly(t *testing.T) { { name: "Other group domain with HTTPS-only disabled", domain: &domain.Domain{ - Project: "project", - Group: "group", - GroupConfig: &Group{ + Resolver: &Group{ name: "group", projects: projects{"project": &projectConfig{HTTPSOnly: false}}, }, @@ -171,11 +161,8 @@ func TestIsHTTPSOnly(t *testing.T) { expected: false, }, { - name: "Unknown project", - domain: &domain.Domain{ - Group: "group", - Project: "project", - }, + name: "Unknown project", + domain: &domain.Domain{}, url: "http://test-domain/project", expected: false, }, @@ -221,9 +208,7 @@ func TestGroupServeHTTPGzip(t *testing.T) { defer cleanup() testGroup := &domain.Domain{ - Project: "", - Group: "group", - GroupConfig: &Group{ + Resolver: &Group{ name: "group", projects: map[string]*projectConfig{ "group.test.io": &projectConfig{}, @@ -289,9 +274,7 @@ func TestGroup404ServeHTTP(t *testing.T) { defer cleanup() testGroup := &domain.Domain{ - Project: "", - Group: "group.404", - GroupConfig: &Group{ + Resolver: &Group{ name: "group.404", projects: map[string]*projectConfig{ "domain.404": &projectConfig{}, @@ -319,9 +302,10 @@ func TestDomain404ServeHTTP(t *testing.T) { defer cleanup() testDomain := &domain.Domain{ - Project: "domain.404", - Group: "group.404", - ProjectConfig: &domain.ProjectConfig{DomainName: "domain.404.com"}, + Resolver: &customProjectResolver{ + path: "group.404/domain.404/public", + 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") @@ -332,18 +316,13 @@ func TestPredefined404ServeHTTP(t *testing.T) { cleanup := setUpTests(t) defer cleanup() - testDomain := &domain.Domain{ - Group: "group", - } + testDomain := &domain.Domain{} testhelpers.AssertHTTP404(t, serveFileOrNotFound(testDomain), "GET", "http://group.test.io/not-existing-file", nil, "The page you're looking for could not be found") } func TestGroupCertificate(t *testing.T) { - testGroup := &domain.Domain{ - Group: "group", - Project: "", - } + testGroup := &domain.Domain{} tls, err := testGroup.EnsureCertificate() require.Nil(t, tls) @@ -352,9 +331,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"}, + Resolver: &customProjectResolver{ + path: "group/project2/public", + config: &domainConfig{Domain: "test.domain.com"}, + }, } tls, err := testDomain.EnsureCertificate() @@ -368,11 +348,11 @@ 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, + Name: "test.domain.com", + CertificateCert: fixture.Certificate, + CertificateKey: fixture.Key, + Resolver: &customProjectResolver{ + path: "group/project2/public", }, } @@ -386,8 +366,7 @@ func TestCacheControlHeaders(t *testing.T) { defer cleanup() testGroup := &domain.Domain{ - Group: "group", - GroupConfig: &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..59aedc73 100644 --- a/internal/source/disk/group.go +++ b/internal/source/disk/group.go @@ -1,12 +1,13 @@ package disk import ( - "errors" "net/http" "path" + "path/filepath" "strings" "gitlab.com/gitlab-org/gitlab-pages/internal/host" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" ) const ( @@ -52,14 +53,14 @@ func (g *Group) digProjectWithSubpath(parentPath string, keys []string) (*projec // Look up a project inside the domain based on the host and path. Returns the // project and its name (if applicable) -func (g *Group) getProjectConfigWithSubpath(r *http.Request) (*projectConfig, string, string) { +func (g *Group) getProjectConfigWithSubpath(r *http.Request) (*projectConfig, string, string, string) { // Check for a project specified in the URL: http://group.gitlab.io/projectA // If present, these projects shadow the group domain. split := strings.SplitN(r.URL.Path, "/", maxProjectDepth) if len(split) >= 2 { projectConfig, projectPath, urlPath := g.digProjectWithSubpath("", split[1:]) if projectConfig != nil { - return projectConfig, projectPath, urlPath + return projectConfig, "/" + projectPath, projectPath, urlPath } } @@ -67,79 +68,31 @@ func (g *Group) getProjectConfigWithSubpath(r *http.Request) (*projectConfig, st // return the group project if it exists. if host := host.FromRequest(r); host != "" { if groupProject := g.projects[host]; groupProject != nil { - return groupProject, host, strings.Join(split[1:], "/") + // TODOHERE: the location here should be "/", so we return "" + return groupProject, "/", host, strings.Join(split[1:], "/") } } - 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) - - if project != nil { - return project.HTTPSOnly - } - - 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 + return nil, "", "", "" } -// ProjectExists return true if project config has been found -func (g *Group) ProjectExists(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) (*serving.LookupPath, string, error) { + projectConfig, location, projectPath, subPath := g.getProjectConfigWithSubpath(r) - if project != nil { - return true + if projectConfig == nil { + return nil, "", nil // it is not an error when project does not exist } - 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 + lookupPath := &serving.LookupPath{ + Location: location, + Path: filepath.Join(g.name, projectPath, "public"), + IsNamespaceProject: projectConfig.NamespaceProject, + IsHTTPSOnly: projectConfig.HTTPSOnly, + HasAccessControl: projectConfig.AccessControl, + ProjectID: projectConfig.ID, } - return "", "", errors.New("project not found") + return lookupPath, subPath, nil } diff --git a/internal/source/disk/map.go b/internal/source/disk/map.go index 2a6ada2d..b5843301 100644 --- a/internal/source/disk/map.go +++ b/internal/source/disk/map.go @@ -22,13 +22,9 @@ type Map map[string]*domain.Domain type domainsUpdater func(Map) func (dm Map) updateDomainMap(domainName string, domain *domain.Domain) { - if old, ok := dm[domainName]; ok { + if _, 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, }).Error("Duplicate domain") } @@ -37,21 +33,16 @@ 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), + CertificateCert: config.Certificate, + CertificateKey: config.Key, + Resolver: &customProjectResolver{ + config: config, + path: filepath.Join(groupName, projectName, "public"), }, } - 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,19 +50,21 @@ func (dm Map) updateGroupDomain(rootDomain, groupName, projectPath string, https groupDomain := dm[domainName] if groupDomain == nil { + groupResolver := &Group{ + name: groupName, + projects: make(projects), + subgroups: make(subgroups), + } + groupDomain = &domain.Domain{ - Group: groupName, - GroupConfig: &Group{ - name: groupName, - projects: make(projects), - subgroups: make(subgroups), - }, + Name: domainName, + 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 { |