hub/internal/handlers/handlers.go

516 lines
18 KiB
Go

package handlers
import (
"context"
"fmt"
"net"
"net/http"
"path"
"strings"
"time"
"github.com/artifacthub/hub/internal/handlers/apikey"
"github.com/artifacthub/hub/internal/handlers/helpers"
"github.com/artifacthub/hub/internal/handlers/org"
"github.com/artifacthub/hub/internal/handlers/pkg"
"github.com/artifacthub/hub/internal/handlers/repo"
"github.com/artifacthub/hub/internal/handlers/static"
"github.com/artifacthub/hub/internal/handlers/stats"
"github.com/artifacthub/hub/internal/handlers/subscription"
"github.com/artifacthub/hub/internal/handlers/user"
"github.com/artifacthub/hub/internal/handlers/webhook"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/img"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/gorilla/csrf"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/cors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"github.com/unrolled/secure"
)
const csrfHeader = "X-CSRF-Token"
var xForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For")
// Services is a wrapper around several internal services used by the handlers.
type Services struct {
OrganizationManager hub.OrganizationManager
UserManager hub.UserManager
RepositoryManager hub.RepositoryManager
PackageManager hub.PackageManager
SubscriptionManager hub.SubscriptionManager
WebhookManager hub.WebhookManager
APIKeyManager hub.APIKeyManager
StatsManager hub.StatsManager
ImageStore img.Store
Authorizer hub.Authorizer
HTTPClient hub.HTTPClient
}
// Metrics groups some metrics collected from a Handlers instance.
type Metrics struct {
duration *prometheus.HistogramVec
}
// Handlers groups all the http handlers defined for the hub, including the
// router in charge of sending requests to the right handler.
type Handlers struct {
cfg *viper.Viper
svc *Services
metrics *Metrics
logger zerolog.Logger
Router http.Handler
Organizations *org.Handlers
Users *user.Handlers
Packages *pkg.Handlers
Repositories *repo.Handlers
Subscriptions *subscription.Handlers
Webhooks *webhook.Handlers
APIKeys *apikey.Handlers
Static *static.Handlers
Stats *stats.Handlers
}
// Setup creates a new Handlers instance.
func Setup(ctx context.Context, cfg *viper.Viper, svc *Services) (*Handlers, error) {
userHandlers, err := user.NewHandlers(ctx, svc.UserManager, svc.APIKeyManager, cfg)
if err != nil {
return nil, err
}
h := &Handlers{
cfg: cfg,
svc: svc,
metrics: setupMetrics(),
logger: log.With().Str("handlers", "root").Logger(),
Organizations: org.NewHandlers(svc.OrganizationManager, svc.Authorizer, cfg),
Users: userHandlers,
Repositories: repo.NewHandlers(svc.RepositoryManager),
Packages: pkg.NewHandlers(svc.PackageManager, svc.RepositoryManager, cfg, svc.HTTPClient),
Subscriptions: subscription.NewHandlers(svc.SubscriptionManager),
Webhooks: webhook.NewHandlers(svc.WebhookManager, svc.HTTPClient),
APIKeys: apikey.NewHandlers(svc.APIKeyManager),
Static: static.NewHandlers(cfg, svc.ImageStore),
Stats: stats.NewHandlers(svc.StatsManager),
}
h.setupRouter()
return h, nil
}
// setupMetrics creates and registers some metrics
func setupMetrics() *Metrics {
// Requests duration
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration",
Help: "Duration of the http requests processed.",
},
[]string{"status", "method", "path"},
)
prometheus.MustRegister(duration)
return &Metrics{
duration: duration,
}
}
// setupRouter initializes the handlers router, defining all routes used within
// the hub, as well as some essential middleware to handle panics, logging, etc.
func (h *Handlers) setupRouter() {
r := chi.NewRouter()
// Setup middleware and special handlers
corsMW := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET"},
AllowCredentials: false,
}).Handler
r.Use(middleware.Recoverer)
r.Use(realIP(h.cfg.GetInt("server.xffIndex")))
r.Use(logger)
r.Use(h.MetricsCollector)
r.Use(secure.New(secure.Options{
SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"},
STSSeconds: 31536000,
STSIncludeSubdomains: true,
STSPreload: true,
ContentSecurityPolicy: `
default-src 'none';
connect-src 'self' https://play.openpolicyagent.org https://www.google-analytics.com https://kubernetesjsonschema.dev;
font-src 'self';
img-src 'self' data: https:;
manifest-src 'self';
script-src 'self' https://www.google-analytics.com;
style-src 'self' 'unsafe-inline'
`,
}).Handler)
if h.cfg.GetBool("server.basicAuth.enabled") {
r.Use(h.Users.BasicAuth)
}
r.NotFound(h.Static.ServeIndex)
// API
r.Route("/api/v1", func(r chi.Router) {
// CSRF
r.Use(csrfSkipper)
r.Use(csrf.Protect(
[]byte(h.cfg.GetString("server.csrf.authKey")),
csrf.Secure(h.cfg.GetBool("server.csrf.secure")),
csrf.Path("/api/v1"),
csrf.CookieName("csrf"),
))
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set(csrfHeader, csrf.Token(r))
})
// Users
r.Route("/users", func(r chi.Router) {
r.Post("/", h.Users.RegisterUser)
r.Post("/check-password-strength", h.Users.CheckPasswordStrength)
r.Post("/login", h.Users.Login)
r.Put("/approve-session", h.Users.ApproveSession)
r.Post("/password-reset-code", h.Users.RegisterPasswordResetCode)
r.Put("/reset-password", h.Users.ResetPassword)
r.Post("/verify-email", h.Users.VerifyEmail)
r.Post("/verify-password-reset-code", h.Users.VerifyPasswordResetCode)
r.Group(func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Route("/tfa", func(r chi.Router) {
r.Put("/disable", h.Users.DisableTFA)
r.Put("/enable", h.Users.EnableTFA)
r.Post("/", h.Users.SetupTFA)
})
r.Get("/logout", h.Users.Logout)
r.Get("/profile", h.Users.GetProfile)
r.Put("/profile", h.Users.UpdateProfile)
r.Put("/password", h.Users.UpdatePassword)
})
})
// Organizations
r.Route("/orgs", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Post("/", h.Organizations.Add)
r.Get("/user", h.Organizations.GetByUser)
})
r.Route("/{orgName}", func(r chi.Router) {
r.Get("/", h.Organizations.Get)
r.Group(func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Delete("/", h.Organizations.Delete)
r.Put("/", h.Organizations.Update)
r.Route("/authorization-policy", func(r chi.Router) {
r.Get("/", h.Organizations.GetAuthorizationPolicy)
r.Put("/", h.Organizations.UpdateAuthorizationPolicy)
})
r.Get("/accept-invitation", h.Organizations.ConfirmMembership)
r.Get("/members", h.Organizations.GetMembers)
r.Route("/member/{userAlias}", func(r chi.Router) {
r.Post("/", h.Organizations.AddMember)
r.Delete("/", h.Organizations.DeleteMember)
})
r.Get("/user-allowed-actions", h.Organizations.GetUserAllowedActions)
})
})
})
// Repositories
r.Route("/repositories", func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Get("/", h.Repositories.GetAll)
r.Get("/{kind:^helm$|^falco$|^olm$|^opa|^tbaction|^krew|^helm-plugin|^tekton-task|^keda-scaler|^coredns$}", h.Repositories.GetByKind)
r.Route("/user", func(r chi.Router) {
r.Get("/", h.Repositories.GetOwnedByUser)
r.Post("/", h.Repositories.Add)
r.Route("/{repoName}", func(r chi.Router) {
r.Put("/claim-ownership", h.Repositories.ClaimOwnership)
r.Put("/transfer", h.Repositories.Transfer)
r.Put("/", h.Repositories.Update)
r.Delete("/", h.Repositories.Delete)
})
})
r.Route("/org/{orgName}", func(r chi.Router) {
r.Get("/", h.Repositories.GetOwnedByOrg)
r.Post("/", h.Repositories.Add)
r.Route("/{repoName}", func(r chi.Router) {
r.Put("/claim-ownership", h.Repositories.ClaimOwnership)
r.Put("/transfer", h.Repositories.Transfer)
r.Put("/", h.Repositories.Update)
r.Delete("/", h.Repositories.Delete)
})
})
})
// Packages
r.Route("/packages", func(r chi.Router) {
r.Get("/random", h.Packages.GetRandom)
r.Get("/stats", h.Packages.GetStats)
r.With(corsMW).Get("/search", h.Packages.Search)
r.With(h.Users.RequireLogin).Get("/starred", h.Packages.GetStarredByUser)
r.Route("/{^helm$|^falco$|^opa$|^olm|^tbaction|^krew|^helm-plugin|^tekton-task|^keda-scaler|^coredns$}/{repoName}/{packageName}", func(r chi.Router) {
r.Get("/feed/rss", h.Packages.RssFeed)
r.With(corsMW).Get("/summary", h.Packages.GetSummary)
r.Get("/{version}", h.Packages.Get)
r.Get("/", h.Packages.Get)
})
r.Route("/{packageID}/stars", func(r chi.Router) {
r.With(h.Users.InjectUserID).Get("/", h.Packages.GetStars)
r.With(h.Users.RequireLogin).Put("/", h.Packages.ToggleStar)
})
r.Get("/{packageID}/{version}/security-report", h.Packages.GetSnapshotSecurityReport)
r.Get("/{packageID}/{version}/values-schema", h.Packages.GetValuesSchema)
r.Get("/{packageID}/{version}/templates", h.Packages.GetChartTemplates)
r.Get("/{packageID}/changelog", h.Packages.GetChangeLog)
})
// Subscriptions
r.Route("/subscriptions", func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Route("/opt-out", func(r chi.Router) {
r.Get("/", h.Subscriptions.GetOptOutList)
r.Post("/", h.Subscriptions.AddOptOut)
r.Delete("/{optOutID}", h.Subscriptions.DeleteOptOut)
})
r.Get("/{packageID}", h.Subscriptions.GetByPackage)
r.Get("/", h.Subscriptions.GetByUser)
r.Post("/", h.Subscriptions.Add)
r.Delete("/", h.Subscriptions.Delete)
})
// Webhooks
r.Route("/webhooks", func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Route("/user", func(r chi.Router) {
r.Get("/", h.Webhooks.GetOwnedByUser)
r.Post("/", h.Webhooks.Add)
r.Route("/{webhookID}", func(r chi.Router) {
r.Get("/", h.Webhooks.Get)
r.Put("/", h.Webhooks.Update)
r.Delete("/", h.Webhooks.Delete)
})
})
r.Route("/org/{orgName}", func(r chi.Router) {
r.Get("/", h.Webhooks.GetOwnedByOrg)
r.Post("/", h.Webhooks.Add)
r.Route("/{webhookID}", func(r chi.Router) {
r.Get("/", h.Webhooks.Get)
r.Put("/", h.Webhooks.Update)
r.Delete("/", h.Webhooks.Delete)
})
})
r.Post("/test", h.Webhooks.TriggerTest)
})
// API keys
r.Route("/api-keys", func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Get("/", h.APIKeys.GetOwnedByUser)
r.Post("/", h.APIKeys.Add)
r.Route("/{apiKeyID}", func(r chi.Router) {
r.Get("/", h.APIKeys.Get)
r.Put("/", h.APIKeys.Update)
r.Delete("/", h.APIKeys.Delete)
})
})
// Availability checks
r.Route("/check-availability", func(r chi.Router) {
r.Head("/{resourceKind:^repositoryName$|^repositoryURL$}", h.Repositories.CheckAvailability)
r.Head("/{resourceKind:^organizationName$}", h.Organizations.CheckAvailability)
r.Head("/{resourceKind:^userAlias$}", h.Users.CheckAvailability)
})
// Images
r.With(h.Users.RequireLogin).Post("/images", h.Static.SaveImage)
// Stats
r.Get("/stats", h.Stats.Get)
// Harbor replication
//
// This endpoint is used by the Harbor replication Artifact Hub adapter.
// It returns some information about all packages versions of Helm kind
// available so that they can be synchronized in Harbor deployments. It
// will probably start being used in Harbor 2.2.0, so we need to be
// careful to not introduce breaking changes.
r.Get("/harbor-replication", h.Packages.GetHarborReplicationDump)
r.Get("/harborReplication", h.Packages.GetHarborReplicationDump) // Deprecated
})
// Monocular compatible search API
//
// This endpoint provides a Monocular compatible search API that the Helm
// CLI search subcommand can use. The goal is to facilitate the transition
// from the Helm Hub to Artifact Hub, allowing the existing Helm tooling to
// continue working without modifications. This is a temporary solution and
// future Helm CLI versions should use the generic Artifact Hub search API.
r.Get("/api/chartsvc/v1/charts/search", h.Packages.SearchMonocular)
// Monocular charts url redirect endpoint
//
// This endpoint is a helper related to the Monocular search one above. At
// the moment Helm CLI builds charts urls coming from the Helm Hub using
// this layout. This cannot be changed for previous versions out there, so
// this endpoint handles the redirection to the package URL in Artifact Hub.
// The monocular compatible search API endpoint that we provide now returns
// the package url to facilitate that future versions of Helm can use it.
r.Route("/charts/{repoName}/{packageName}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
pkgPath := fmt.Sprintf("/packages/helm/%s/%s",
chi.URLParam(r, "repoName"),
chi.URLParam(r, "packageName"),
)
http.Redirect(w, r, pkgPath, http.StatusMovedPermanently)
})
r.Get("/{version}", func(w http.ResponseWriter, r *http.Request) {
pkgPath := fmt.Sprintf("/packages/helm/%s/%s/%s",
chi.URLParam(r, "repoName"),
chi.URLParam(r, "packageName"),
chi.URLParam(r, "version"),
)
http.Redirect(w, r, pkgPath, http.StatusMovedPermanently)
})
})
// Oauth
providers := make([]string, 0, len(h.cfg.GetStringMap("server.oauth")))
for provider := range h.cfg.GetStringMap("server.oauth") {
providers = append(providers, fmt.Sprintf("^%s$", provider))
}
if len(providers) > 0 {
r.Route(fmt.Sprintf("/oauth/{provider:%s}", strings.Join(providers, "|")), func(r chi.Router) {
r.Get("/", h.Users.OauthRedirect)
r.Get("/callback", h.Users.OauthCallback)
})
}
// Index special entry points
r.Route("/packages", func(r chi.Router) {
r.Route("/{^helm$|^falco$|^opa$|^olm|^tbaction|^krew|^helm-plugin|^tekton-task|^keda-scaler|^coredns$}/{repoName}/{packageName}", func(r chi.Router) {
r.With(h.Packages.InjectIndexMeta).Get("/{version}", h.Static.ServeIndex)
r.With(h.Packages.InjectIndexMeta).Get("/", h.Static.ServeIndex)
})
})
// Badges
r.Get("/badge/repository/{repoName}", h.Repositories.Badge)
// Static files and index
webBuildPath := h.cfg.GetString("server.webBuildPath")
webStaticFilesPath := path.Join(webBuildPath, "static")
widgetBuildPath := h.cfg.GetString("server.widgetBuildPath")
docsFilesPath := path.Join(webBuildPath, "docs")
static.FileServer(r, "/static", webStaticFilesPath, static.StaticCacheMaxAge)
static.FileServer(r, "/docs", docsFilesPath, static.DocsCacheMaxAge)
r.Get("/image/{image}", h.Static.Image)
r.Get("/manifest.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", helpers.BuildCacheControlHeader(5*time.Minute))
http.ServeFile(w, r, path.Join(webBuildPath, "manifest.json"))
})
r.Get("/artifacthub-widget.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", helpers.BuildCacheControlHeader(5*time.Minute))
http.ServeFile(w, r, path.Join(widgetBuildPath, "static/js/artifacthub-widget.js"))
})
r.Get("/", h.Static.ServeIndex)
h.Router = r
}
// MetricsCollector is an http middleware that collects some metrics about
// requests processed.
func (h *Handlers) MetricsCollector(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
rctx := chi.RouteContext(r.Context())
h.metrics.duration.WithLabelValues(
http.StatusText(ww.Status()),
r.Method,
rctx.RoutePattern(),
).Observe(time.Since(start).Seconds())
}()
next.ServeHTTP(ww, r)
})
}
// csrfSkipper is an http middleware that skips CSRF checks for requests that
// match certain criteria.
func csrfSkipper(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip checks for requests authenticated using API keys
if r.Header.Get(user.APIKeyIDHeader) != "" && r.Header.Get(user.APIKeySecretHeader) != "" {
r = csrf.UnsafeSkipCheck(r)
}
// Skip checks for requests using GET or HEAD methods, except requests
// to /api/v1/csrf, which is the endpoint used to get the token that
// should be provided on subsequent POST, PUT or DELETE API requests.
if (r.Method == "GET" && r.URL.Path != "/api/v1/csrf") || r.Method == "HEAD" {
r = csrf.UnsafeSkipCheck(r)
}
next.ServeHTTP(w, r)
})
}
// logger is an http middleware that logs some information about requests
// processed using zerolog.
func logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
host, port, _ := net.SplitHostPort(r.RemoteAddr)
msg := r.URL.Path
if r.URL.RawQuery != "" && r.URL.Path == "/api/v1/packages/search" {
msg += "?" + r.URL.RawQuery
}
defer func() {
var event *zerolog.Event
if ww.Status() < 500 {
event = log.Info()
} else {
event = log.Error()
}
event.
Fields(map[string]interface{}{
"host": host,
"port": port,
"method": r.Method,
"status": ww.Status(),
"took": float64(time.Since(start)) / 1e6,
"bytes_in": r.Header.Get("Content-Length"),
"bytes_out": ww.BytesWritten(),
}).
Timestamp().
Msg(msg)
}()
next.ServeHTTP(ww, r)
})
}
// realIP is an http middleware that sets the request remote addr to the result
// of extracting the IP in the requested index from the X-Forwarded-For header.
// Positives indexes start by 0 and work like usual slice indexes. Negative
// indexes are allowed being -1 the last entry in the slice, -2 the next, etc.
func realIP(i int) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if xff := r.Header.Get(xForwardedFor); xff != "" {
ips := strings.Split(xff, ",")
if i >= 0 && len(ips) > i {
r.RemoteAddr = strings.TrimSpace(ips[i]) + ":"
}
if i < 0 && len(ips)+i >= 0 {
r.RemoteAddr = strings.TrimSpace(ips[len(ips)+i]) + ":"
}
}
next.ServeHTTP(w, r)
})
}
}