// Copyright (c) 2020 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.

package proxy

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/gitpod-io/golang-crypto/ssh"
	"github.com/gorilla/handlers"
	"github.com/gorilla/mux"
	"github.com/sirupsen/logrus"
	"golang.org/x/xerrors"

	"github.com/gitpod-io/gitpod/common-go/log"
)

// RouteHandlerConfig configures a RouteHandler.
type RouteHandlerConfig struct {
	Config               *Config
	DefaultTransport     http.RoundTripper
	CorsHandler          mux.MiddlewareFunc
	WorkspaceAuthHandler mux.MiddlewareFunc
}

// RouteHandlerConfigOpt modifies the router handler config.
type RouteHandlerConfigOpt func(*Config, *RouteHandlerConfig)

// WithDefaultAuth enables workspace access authentication.
func WithDefaultAuth(infoprov WorkspaceInfoProvider) RouteHandlerConfigOpt {
	return func(config *Config, c *RouteHandlerConfig) {
		c.WorkspaceAuthHandler = WorkspaceAuthHandler(config.GitpodInstallation.HostName, infoprov)
	}
}

// NewRouteHandlerConfig creates a new instance.
func NewRouteHandlerConfig(config *Config, opts ...RouteHandlerConfigOpt) (*RouteHandlerConfig, error) {
	corsHandler, err := corsHandler(config.GitpodInstallation.Scheme, config.GitpodInstallation.HostName)
	if err != nil {
		return nil, err
	}

	cfg := &RouteHandlerConfig{
		Config:               config,
		DefaultTransport:     createDefaultTransport(config.TransportConfig),
		CorsHandler:          corsHandler,
		WorkspaceAuthHandler: func(h http.Handler) http.Handler { return h },
	}
	for _, o := range opts {
		o(config, cfg)
	}
	return cfg, nil
}

// RouteHandler is a function that handles a HTTP route.
type RouteHandler = func(r *mux.Router, config *RouteHandlerConfig)

// installWorkspaceRoutes configures routing of workspace and IDE requests.
func installWorkspaceRoutes(r *mux.Router, config *RouteHandlerConfig, ip WorkspaceInfoProvider, hostKeyList []ssh.Signer) {
	r.Use(logHandler)

	// Note: the order of routes defines their priority.
	//       Routes registered first have priority over those that come afterwards.
	routes := newIDERoutes(config, ip)

	// if host key is not empty, we use /_ssh/host_keys to provider public host key
	if len(hostKeyList) > 0 {
		routes.HandleSSHHostKeyRoute(r.Path("/_ssh/host_keys"), hostKeyList)
	}

	// The favicon warants special handling, because we pull that from the supervisor frontend
	// rather than the IDE.
	faviconRouter := r.Path("/favicon.ico").Subrouter()
	faviconRouter.Use(handlers.CompressHandler)
	faviconRouter.Use(func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
			req.URL.Path = "/_supervisor/frontend/favicon.ico"
			h.ServeHTTP(resp, req)
		})
	})
	routes.HandleSupervisorFrontendRoute(faviconRouter.NewRoute())

	routes.HandleSupervisorFrontendRoute(enableCompression(r).PathPrefix("/_supervisor/frontend"))

	routes.HandleDirectSupervisorRoute(r.PathPrefix("/_supervisor/v1/status/supervisor"), false)
	routes.HandleDirectSupervisorRoute(r.PathPrefix("/_supervisor/v1/status/ide"), false)
	routes.HandleDirectSupervisorRoute(r.PathPrefix("/_supervisor/v1"), true)
	routes.HandleDirectSupervisorRoute(r.PathPrefix("/_supervisor"), true)

	rootRouter := enableCompression(r)
	rootRouter.Use(func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
			// This is just an alias to callback.html to make its purpose more explicit,
			// it will be served by blobserve.
			if req.URL.Path == "/vscode-extension-auth-callback" {
				req.URL.Path = "/callback.html"
			}
			h.ServeHTTP(resp, req)
		})
	})
	routes.HandleRoot(rootRouter.NewRoute())
}

func enableCompression(r *mux.Router) *mux.Router {
	res := r.NewRoute().Subrouter()
	res.Use(handlers.CompressHandler)
	return res
}

func newIDERoutes(config *RouteHandlerConfig, ip WorkspaceInfoProvider) *ideRoutes {
	return &ideRoutes{
		Config:                    config,
		InfoProvider:              ip,
		workspaceMustExistHandler: workspaceMustExistHandler(config.Config, ip),
	}
}

type ideRoutes struct {
	Config       *RouteHandlerConfig
	InfoProvider WorkspaceInfoProvider

	workspaceMustExistHandler mux.MiddlewareFunc
}

func (ir *ideRoutes) HandleSSHHostKeyRoute(route *mux.Route, hostKeyList []ssh.Signer) {
	shk := make([]struct {
		Type    string `json:"type"`
		HostKey string `json:"host_key"`
	}, len(hostKeyList))
	for i, hk := range hostKeyList {
		shk[i].Type = hk.PublicKey().Type()
		shk[i].HostKey = base64.StdEncoding.EncodeToString(hk.PublicKey().Marshal())
	}
	byt, err := json.Marshal(shk)
	if err != nil {
		log.WithError(err).Error("ssh_host_key router setup failed")
		return
	}
	r := route.Subrouter()
	r.Use(logRouteHandlerHandler("HandleSSHHostKeyRoute"))
	r.Use(ir.Config.CorsHandler)
	r.NewRoute().HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		rw.Header().Add("Content-Type", "application/json")
		rw.Write(byt)
	})
}

func (ir *ideRoutes) HandleDirectSupervisorRoute(route *mux.Route, authenticated bool) {
	r := route.Subrouter()
	r.Use(logRouteHandlerHandler(fmt.Sprintf("HandleDirectSupervisorRoute (authenticated: %v)", authenticated)))
	r.Use(ir.Config.CorsHandler)
	r.Use(ir.workspaceMustExistHandler)
	if authenticated {
		r.Use(ir.Config.WorkspaceAuthHandler)
	}

	r.NewRoute().HandlerFunc(proxyPass(ir.Config, ir.InfoProvider, workspacePodSupervisorResolver))
}

func (ir *ideRoutes) HandleSupervisorFrontendRoute(route *mux.Route) {
	if ir.Config.Config.BlobServer == nil {
		// if we don't have blobserve, we serve the supervisor frontend from supervisor directly
		ir.HandleDirectSupervisorRoute(route, false)
		return
	}

	r := route.Subrouter()
	r.Use(logRouteHandlerHandler("SupervisorIDEHostHandler"))
	r.Use(ir.workspaceMustExistHandler)
	// strip the frontend prefix, just for good measure
	r.Use(func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
			req.URL.Path = strings.TrimPrefix(req.URL.Path, "/_supervisor/frontend")
			h.ServeHTTP(resp, req)
		})
	})
	// always hit the blobserver to ensure that blob is downloaded
	r.NewRoute().HandlerFunc(proxyPass(ir.Config, ir.InfoProvider, func(cfg *Config, infoProvider WorkspaceInfoProvider, req *http.Request) (*url.URL, error) {
		info := getWorkspaceInfoFromContext(req.Context())
		return resolveSupervisorURL(cfg, info, req)
	}, func(h *proxyPassConfig) {
		h.Transport = &blobserveTransport{
			transport: h.Transport,
			Config:    ir.Config.Config,
			resolveImage: func(t *blobserveTransport, req *http.Request) string {
				info := getWorkspaceInfoFromContext(req.Context())
				if info == nil && len(ir.Config.Config.WorkspacePodConfig.SupervisorImage) == 0 {
					// no workspace information available - cannot resolve supervisor image
					return ""
				}

				// use the config value for backwards compatibility when info.SupervisorImage is not set
				image := ir.Config.Config.WorkspacePodConfig.SupervisorImage
				if info != nil && len(info.SupervisorImage) > 0 {
					image = info.SupervisorImage
				}

				path := strings.TrimPrefix(req.URL.Path, "/"+image)
				if path == "/worker-proxy.js" {
					// worker must be served from the same origin
					return ""
				}
				return image
			},
		}
	}, withUseTargetHost()))
}

func resolveSupervisorURL(cfg *Config, info *WorkspaceInfo, req *http.Request) (*url.URL, error) {
	if info == nil && len(cfg.WorkspacePodConfig.SupervisorImage) == 0 {
		log.WithFields(log.OWI("", getWorkspaceCoords(req).ID, "")).Warn("no workspace info available - cannot resolve supervisor route")
		return nil, xerrors.Errorf("no workspace information available - cannot resolve supervisor route")
	}

	// use the config value for backwards compatibility when info.SupervisorImage is not set
	supervisorImage := cfg.WorkspacePodConfig.SupervisorImage
	if info != nil && len(info.SupervisorImage) > 0 {
		supervisorImage = info.SupervisorImage
	}

	var dst url.URL
	dst.Scheme = cfg.BlobServer.Scheme
	dst.Host = cfg.BlobServer.Host
	dst.Path = cfg.BlobServer.PathPrefix + "/" + supervisorImage
	return &dst, nil
}

type BlobserveInlineVars struct {
	IDE             string `json:"ide"`
	SupervisorImage string `json:"supervisor"`
}

func (ir *ideRoutes) HandleRoot(route *mux.Route) {
	r := route.Subrouter()
	r.Use(logRouteHandlerHandler("handleRoot"))
	r.Use(ir.Config.CorsHandler)
	r.Use(ir.workspaceMustExistHandler)

	workspaceIDEPass := ir.Config.WorkspaceAuthHandler(
		proxyPass(ir.Config, ir.InfoProvider, workspacePodResolver),
	)
	// always hit the blobserver to ensure that blob is downloaded
	r.NewRoute().HandlerFunc(proxyPass(ir.Config, ir.InfoProvider, dynamicIDEResolver, func(h *proxyPassConfig) {
		h.Transport = &blobserveTransport{
			transport: h.Transport,
			Config:    ir.Config.Config,
			resolveImage: func(t *blobserveTransport, req *http.Request) string {
				info := getWorkspaceInfoFromContext(req.Context())
				if info == nil {
					// no workspace information available - cannot resolve IDE image and path
					return ""
				}
				image := info.IDEImage
				imagePath := strings.TrimPrefix(req.URL.Path, t.Config.BlobServer.PathPrefix+"/"+image)
				if imagePath != "/index.html" && imagePath != "/" {
					return image
				}
				// blobserve can inline static links in index.html for IDE and supervisor to avoid redirects for each resource
				// but it has to know exposed URLs in the context of current workspace cluster
				// so first we ask blobserve to preload the supervisor image
				// and if it is successful we pass exposed URLs to IDE and supervisor to blobserve for inlining
				supervisorURL, err := resolveSupervisorURL(t.Config, info, req)
				if err != nil {
					log.WithError(err).Error("could not preload supervisor")
					return image
				}
				supervisorURLString := supervisorURL.String() + "/main.js"
				preloadSupervisorReq, err := http.NewRequest("HEAD", supervisorURLString, nil)
				if err != nil {
					log.WithField("supervisorURL", supervisorURL).WithError(err).Error("could not preload supervisor")
					return image
				}
				resp, err := t.DoRoundTrip(preloadSupervisorReq)
				if err != nil {
					log.WithField("supervisorURL", supervisorURL).WithError(err).Error("could not preload supervisor")
					return image
				}
				_ = resp.Body.Close()
				if resp.StatusCode != http.StatusOK {
					log.WithField("supervisorURL", supervisorURL).WithField("statusCode", resp.StatusCode).WithField("status", resp.Status).Error("could not preload supervisor")
					return image
				}

				// use the config value for backwards compatibility when info.SupervisorImage is not set
				supervisorImage := t.Config.WorkspacePodConfig.SupervisorImage
				if len(info.SupervisorImage) > 0 {
					supervisorImage = info.SupervisorImage
				}

				inlineVars := &BlobserveInlineVars{
					IDE:             t.asBlobserveURL(image, ""),
					SupervisorImage: t.asBlobserveURL(supervisorImage, ""),
				}
				inlinveVarsValue, err := json.Marshal(inlineVars)
				if err != nil {
					log.WithError(err).WithField("inlineVars", inlineVars).Error("could no serialize inline vars")
					return image
				}

				req.Header.Add("X-BlobServe-InlineVars", string(inlinveVarsValue))
				return image
			},
		}
	}, withHTTPErrorHandler(workspaceIDEPass), withUseTargetHost()))
}

const imagePathSeparator = "/__files__"

// installBlobserveRoutes  implements long-lived caching with versioned URLs, see https://web.dev/http-cache/#versioned-urls
func installBlobserveRoutes(r *mux.Router, config *RouteHandlerConfig, infoProvider WorkspaceInfoProvider) {
	r.Use(logHandler)
	r.Use(logRouteHandlerHandler("BlobserveRootHandler"))

	targetResolver := func(cfg *Config, infoProvider WorkspaceInfoProvider, req *http.Request) (tgt *url.URL, err error) {
		segments := strings.SplitN(req.URL.Path, imagePathSeparator, 2)
		if len(segments) < 2 {
			return nil, xerrors.Errorf("invalid URL")
		}
		image, path := segments[0], segments[1]

		req.URL.Path = path

		var dst url.URL
		dst.Scheme = cfg.BlobServer.Scheme
		dst.Host = cfg.BlobServer.Host
		dst.Path = cfg.BlobServer.PathPrefix + "/" + strings.TrimPrefix(image, "/")
		return &dst, nil
	}
	r.NewRoute().Handler(proxyPass(config, infoProvider, targetResolver, withLongTermCaching(), withUseTargetHost()))
}

// installWorkspacePortRoutes configures routing for exposed ports.
func installWorkspacePortRoutes(r *mux.Router, config *RouteHandlerConfig, infoProvider WorkspaceInfoProvider) error {
	showPortNotFoundPage, err := servePortNotFoundPage(config.Config)
	if err != nil {
		return err
	}

	r.Use(logHandler)
	r.Use(config.WorkspaceAuthHandler)
	// filter all session cookies
	r.Use(sensitiveCookieHandler(config.Config.GitpodInstallation.HostName))

	// forward request to workspace port
	r.NewRoute().HandlerFunc(
		func(rw http.ResponseWriter, r *http.Request) {
			// a work-around for servers which does not respect case-insensitive headers, see https://github.com/gitpod-io/gitpod/issues/4047#issuecomment-856566526
			for _, name := range []string{"Key", "Extensions", "Accept", "Protocol", "Version"} {
				values := r.Header["Sec-Websocket-"+name]
				if len(values) != 0 {
					r.Header.Del("Sec-Websocket-" + name)
					r.Header["Sec-WebSocket-"+name] = values
				}
			}
			r.Header.Add("X-Forwarded-Proto", "https")
			r.Header.Add("X-Forwarded-Host", r.Host)
			r.Header.Add("X-Forwarded-Port", "443")
			proxyPass(
				config,
				infoProvider,
				workspacePodPortResolver,
				withHTTPErrorHandler(showPortNotFoundPage),
				withXFrameOptionsFilter(),
			)(rw, r)
		},
	)

	return nil
}

// workspacePodResolver resolves to the workspace pod's url from the given request.
func workspacePodResolver(config *Config, infoProvider WorkspaceInfoProvider, req *http.Request) (url *url.URL, err error) {
	coords := getWorkspaceCoords(req)
	workspaceInfo := infoProvider.WorkspaceInfo(coords.ID)
	return buildWorkspacePodURL(workspaceInfo.IPAddress, fmt.Sprint(config.WorkspacePodConfig.TheiaPort))
}

// workspacePodPortResolver resolves to the workspace pods ports.
func workspacePodPortResolver(config *Config, infoProvider WorkspaceInfoProvider, req *http.Request) (url *url.URL, err error) {
	coords := getWorkspaceCoords(req)
	workspaceInfo := infoProvider.WorkspaceInfo(coords.ID)
	return buildWorkspacePodURL(workspaceInfo.IPAddress, coords.Port)
}

// workspacePodSupervisorResolver resolves to the workspace pods Supervisor url from the given request.
func workspacePodSupervisorResolver(config *Config, infoProvider WorkspaceInfoProvider, req *http.Request) (url *url.URL, err error) {
	coords := getWorkspaceCoords(req)
	workspaceInfo := infoProvider.WorkspaceInfo(coords.ID)
	return buildWorkspacePodURL(workspaceInfo.IPAddress, fmt.Sprint(config.WorkspacePodConfig.SupervisorPort))
}

func dynamicIDEResolver(config *Config, infoProvider WorkspaceInfoProvider, req *http.Request) (res *url.URL, err error) {
	info := getWorkspaceInfoFromContext(req.Context())
	if info == nil {
		log.WithFields(log.OWI("", getWorkspaceCoords(req).ID, "")).Warn("no workspace info available - cannot resolve Theia route")
		return nil, xerrors.Errorf("no workspace information available - cannot resolve Theia route")
	}

	var dst url.URL
	dst.Scheme = config.BlobServer.Scheme
	dst.Host = config.BlobServer.Host
	dst.Path = config.BlobServer.PathPrefix + "/" + info.IDEImage

	return &dst, nil
}

func buildWorkspacePodURL(ipAddress string, port string) (*url.URL, error) {
	return url.Parse(fmt.Sprintf("http://%v:%v", ipAddress, port))
}

// corsHandler produces the CORS handler for workspaces.
func corsHandler(scheme, hostname string) (mux.MiddlewareFunc, error) {
	origin := fmt.Sprintf("%s://%s", scheme, hostname)

	domainRegex := strings.ReplaceAll(hostname, ".", "\\.")
	originRegex, err := regexp.Compile(".*" + domainRegex)
	if err != nil {
		return nil, err
	}

	return handlers.CORS(
		handlers.AllowedOriginValidator(func(origin string) bool {
			// Is the origin a subdomain of the installations hostname?
			matches := originRegex.Match([]byte(origin))
			return matches
		}),
		// TODO(gpl) For domain-based workspace access with authentication (for accessing the IDE) we need to respond with the precise Origin header that was sent
		handlers.AllowedOrigins([]string{origin}),
		handlers.AllowedMethods([]string{
			"GET",
			"POST",
			"OPTIONS",
		}),
		handlers.AllowedHeaders([]string{
			// "Accept", "Accept-Language", "Content-Language" are allowed per default
			"Cache-Control",
			"Content-Type",
			"DNT",
			"If-Modified-Since",
			"Keep-Alive",
			"Origin",
			"User-Agent",
			"X-Requested-With",
		}),
		handlers.AllowCredentials(),
		// required to be able to read Authorization header in frontend
		handlers.ExposedHeaders([]string{"Authorization"}),
		handlers.MaxAge(60),
		handlers.OptionStatusCode(200),
	), nil
}

type wsproxyContextKey struct{}

var (
	logContextValueKey  = wsproxyContextKey{}
	infoContextValueKey = wsproxyContextKey{}
)

func logHandler(h http.Handler) http.Handler {
	return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
		var (
			vars = mux.Vars(req)
			wsID = vars[workspaceIDIdentifier]
			port = vars[workspacePortIdentifier]
		)
		entry := logrus.Fields{
			"workspaceId": wsID,
			"portID":      port,
			"url":         req.URL.String(),
		}
		ctx := context.WithValue(req.Context(), logContextValueKey, entry)
		req = req.WithContext(ctx)

		h.ServeHTTP(resp, req)
	})
}

func logRouteHandlerHandler(routeHandlerName string) mux.MiddlewareFunc {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
			getLog(req.Context()).WithField("routeHandler", routeHandlerName).Debug("hit route handler")
			h.ServeHTTP(resp, req)
		})
	}
}

func getLog(ctx context.Context) *logrus.Entry {
	r := ctx.Value(logContextValueKey)
	rl, ok := r.(logrus.Fields)
	if rl == nil || !ok {
		return log.Log
	}

	return log.WithFields(rl)
}

func sensitiveCookieHandler(domain string) func(h http.Handler) http.Handler {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
			cookies := removeSensitiveCookies(readCookies(req.Header, ""), domain)
			header := make([]string, 0, len(cookies))
			for _, c := range cookies {
				if c == nil {
					continue
				}

				cookie := c.String()
				if cookie == "" {
					// because we're checking for nil above, it must be that the cookie name is invalid.
					// Some languages have no quarels with producing invalid cookie names, so we must too.
					// See https://github.com/gitpod-io/gitpod/issues/2470 for more details.
					var (
						originalName    = c.Name
						replacementName = fmt.Sprintf("name%d%d", rand.Uint64(), time.Now().Unix())
					)
					c.Name = replacementName
					cookie = c.String()
					if cookie == "" {
						// despite our best efforts, we still couldn't render the cookie. We'll just drop
						// it at this point
						continue
					}

					cookie = strings.Replace(cookie, replacementName, originalName, 1)
					c.Name = originalName
				}

				header = append(header, cookie)
			}

			// using the header string slice here directly would result in multiple cookie header
			// being sent. See https://github.com/gitpod-io/gitpod/issues/2121.
			req.Header["Cookie"] = []string{strings.Join(header, ";")}

			h.ServeHTTP(resp, req)
		})
	}
}

// workspaceMustExistHandler redirects if we don't know about a workspace yet.
func workspaceMustExistHandler(config *Config, infoProvider WorkspaceInfoProvider) mux.MiddlewareFunc {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
			coords := getWorkspaceCoords(req)
			info := infoProvider.WorkspaceInfo(coords.ID)
			if info == nil {
				log.WithFields(log.OWI("", coords.ID, "")).Info("no workspace info found - redirecting to start")
				redirectURL := fmt.Sprintf("%s://%s/start/?not_found=true#%s", config.GitpodInstallation.Scheme, config.GitpodInstallation.HostName, coords.ID)
				http.Redirect(resp, req, redirectURL, http.StatusFound)
				return
			}

			h.ServeHTTP(resp, req.WithContext(context.WithValue(req.Context(), infoContextValueKey, info)))
		})
	}
}

// getWorkspaceInfoFromContext retrieves workspace information put there by the workspaceMustExistHandler.
func getWorkspaceInfoFromContext(ctx context.Context) *WorkspaceInfo {
	r := ctx.Value(infoContextValueKey)
	rl, ok := r.(*WorkspaceInfo)
	if !ok {
		return nil
	}
	return rl
}

// removeSensitiveCookies all sensitive cookies from the list.
// This function modifies the slice in-place.
func removeSensitiveCookies(cookies []*http.Cookie, domain string) []*http.Cookie {
	hostnamePrefix := domain
	for _, c := range []string{" ", "-", "."} {
		hostnamePrefix = strings.ReplaceAll(hostnamePrefix, c, "_")
	}
	hostnamePrefix = "_" + hostnamePrefix + "_"

	n := 0
	for _, c := range cookies {
		if strings.EqualFold(c.Name, hostnamePrefix) {
			// skip session cookie
			continue
		}
		if strings.HasPrefix(c.Name, hostnamePrefix) && strings.HasSuffix(c.Name, "_port_auth_") {
			// skip port auth cookie
			continue
		}
		if strings.HasPrefix(c.Name, hostnamePrefix) && strings.HasSuffix(c.Name, "_owner_") {
			// skip owner token
			continue
		}
		log.WithField("hostnamePrefix", hostnamePrefix).WithField("name", c.Name).Debug("keeping cookie")
		cookies[n] = c
		n++
	}
	return cookies[:n]
}

// region blobserve transport.
type blobserveTransport struct {
	transport    http.RoundTripper
	Config       *Config
	resolveImage func(t *blobserveTransport, req *http.Request) string
}

func (t *blobserveTransport) DoRoundTrip(req *http.Request) (resp *http.Response, err error) {
	for i := 0; i < 5; i++ {
		resp, err = t.transport.RoundTrip(req)
		if err != nil {
			return nil, err
		}

		if resp.StatusCode >= http.StatusBadRequest {
			respBody, err := io.ReadAll(resp.Body)
			if err != nil {
				return nil, err
			}
			_ = resp.Body.Close()

			if resp.StatusCode == http.StatusServiceUnavailable && string(respBody) == "timeout" {
				// on timeout try again till the client request is cancelled
				// blob server sometimes takes time to pull a new image
				continue
			}

			// treat any client or server error code as a http error
			return nil, xerrors.Errorf("blobserver error: (%d) %s", resp.StatusCode, string(respBody))
		}
		break
	}
	return resp, err
}

func (t *blobserveTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
	image := t.resolveImage(t, req)

	resp, err = t.DoRoundTrip(req)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		// only redirect successful responses
		return resp, nil
	}

	if req.URL.RawQuery != "" {
		// URLs with query cannot be static, i.e. the server is required to resolve the query
		return resp, nil
	}

	// region use fetch metadata to avoid redirections https://developer.mozilla.org/en-US/docs/Glossary/Fetch_metadata_request_header
	mode := req.Header.Get("Sec-Fetch-Mode")
	dest := req.Header.Get("Sec-Fetch-Dest")
	if mode == "" && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
		// fallback for user agents not supporting fetch metadata to avoid redirecting on user navigation
		mode = "navigate"
	}
	if mode == "navigate" || mode == "nested-navigate" || mode == "websocket" {
		// user navigation and websocket requests should not be redirected
		return resp, nil
	}

	if mode == "same-origin" && !(dest == "worker" || dest == "sharedworker") {
		// same origin should not be redirected, except workers
		// supervisor installs the worker proxy from the workspace origin serving content from the blobserve origin
		return resp, nil
	}
	// endregion

	if image == "" {
		return resp, nil
	}

	_ = resp.Body.Close()
	return t.redirect(image, req)
}

func (t *blobserveTransport) redirect(image string, req *http.Request) (*http.Response, error) {
	path := strings.TrimPrefix(req.URL.Path, t.Config.BlobServer.PathPrefix+"/"+image)
	location := t.asBlobserveURL(image, path)

	header := make(http.Header, 2)
	header.Set("Location", location)
	header.Set("Content-Type", "text/html; charset=utf-8")

	code := http.StatusSeeOther
	var (
		status  = http.StatusText(code)
		content = []byte("<a href=\"" + location + "\">" + status + "</a>.\n\n")
	)

	return &http.Response{
		Request:       req,
		Header:        header,
		Body:          io.NopCloser(bytes.NewReader(content)),
		ContentLength: int64(len(content)),
		StatusCode:    code,
		Status:        status,
	}, nil
}

func (t *blobserveTransport) asBlobserveURL(image string, path string) string {
	return fmt.Sprintf("%s://ide.%s/blobserve/%s%s%s",
		t.Config.GitpodInstallation.Scheme,
		t.Config.GitpodInstallation.HostName,
		image,
		imagePathSeparator,
		path,
	)
}

// endregion

const (
	builtinPagePortNotFound = "port-not-found.html"
)

func servePortNotFoundPage(config *Config) (http.Handler, error) {
	fn := filepath.Join(config.BuiltinPages.Location, builtinPagePortNotFound)
	if tp := os.Getenv("TELEPRESENCE_ROOT"); tp != "" {
		fn = filepath.Join(tp, fn)
	}
	page, err := os.ReadFile(fn)
	if err != nil {
		return nil, err
	}
	page = bytes.ReplaceAll(page, []byte("https://gitpod.io"), []byte(fmt.Sprintf("%s://%s", config.GitpodInstallation.Scheme, config.GitpodInstallation.HostName)))

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusNotFound)
		_, _ = w.Write(page)
	}), nil
}
