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

gitlab.com/gitlab-org/gitlab-pages.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNick Thomas <nick@gitlab.com>2019-10-02 16:08:18 +0300
committerNick Thomas <nick@gitlab.com>2019-10-02 16:08:18 +0300
commit5b5e846dd117fe9b406b52681436553799f96635 (patch)
tree6c91b45200631720338466a49a05f849feddfc3a
parent9943255d61c5646f6cf9e1a8a03e4a2dc19831f5 (diff)
parent9ecd5703d7c0325c01c65c51a9ec625b1436a1ad (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.go4
-rw-r--r--internal/acme/acme.go4
-rw-r--r--internal/acme/acme_test.go8
-rw-r--r--internal/domain/config.go11
-rw-r--r--internal/domain/domain.go446
-rw-r--r--internal/domain/domain_test.go187
-rw-r--r--internal/domain/resolver.go14
-rw-r--r--internal/logging/logging.go2
-rw-r--r--internal/serving/disk/errors.go18
-rw-r--r--internal/serving/disk/helpers.go78
-rw-r--r--internal/serving/disk/helpers_test.go32
-rw-r--r--internal/serving/disk/reader.go173
-rw-r--r--internal/serving/disk/serving.go37
-rw-r--r--internal/serving/handler.go13
-rw-r--r--internal/serving/lookup_path.go11
-rw-r--r--internal/serving/serving.go7
-rw-r--r--internal/source/disk/custom.go27
-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.go89
-rw-r--r--internal/source/disk/map.go43
-rw-r--r--internal/source/disk/map_test.go12
21 files changed, 590 insertions, 709 deletions
diff --git a/app.go b/app.go
index 2f47d155..e9130467 100644
--- a/app.go
+++ b/app.go
@@ -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 {