hub/internal/handlers/static/handlers.go

238 lines
7.8 KiB
Go

package static
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/artifacthub/hub/internal/handlers/helpers"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/img"
"github.com/go-chi/chi/v5"
svg "github.com/h2non/go-is-svg"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
const (
cspPolicy = `
default-src 'none';
connect-src 'self' https://play.openpolicyagent.org https://www.google-analytics.com https://kubernetesjsonschema.dev https://raw.githubusercontent.com/yannh/kubernetes-json-schema/ https://cncf.github.io/banners/banners.yml;
font-src 'self';
img-src 'self' data: https:;
manifest-src 'self';
script-src 'self' https://www.google-analytics.com;
style-src 'self' 'unsafe-inline';
frame-ancestors 'none';
`
indexCacheMaxAge = 5 * time.Minute
// DocsCacheMaxAge is the cache max age used when serving the docs.
DocsCacheMaxAge = 15 * time.Minute
// StaticCacheMaxAge is the cache max age used when serving static assets.
StaticCacheMaxAge = 365 * 24 * time.Hour
)
// Handlers represents a group of http handlers in charge of handling
// static files operations.
type Handlers struct {
cfg *viper.Viper
imageStore img.Store
logger zerolog.Logger
indexTmpl *template.Template
mu sync.RWMutex
imagesCache map[string][]byte
}
// NewHandlers creates a new Handlers instance.
func NewHandlers(cfg *viper.Viper, imageStore img.Store) *Handlers {
h := &Handlers{
cfg: cfg,
imageStore: imageStore,
imagesCache: make(map[string][]byte),
logger: log.With().Str("handlers", "static").Logger(),
}
h.setupIndexTemplate()
return h
}
// setupIndexTemplate parses the index.html template for later use.
func (h *Handlers) setupIndexTemplate() {
path := path.Join(h.cfg.GetString("server.webBuildPath"), "index.html")
text, err := os.ReadFile(path)
if err != nil {
log.Panic().Err(err).Msg("error reading index.html template")
}
h.indexTmpl = template.Must(template.New("").Parse(string(text)))
}
// Image is an http handler that serves images stored in the database.
func (h *Handlers) Image(w http.ResponseWriter, r *http.Request) {
// Extract image id and version
image := chi.URLParam(r, "image")
parts := strings.Split(image, "@")
var imageID, version string
if len(parts) == 2 {
imageID = parts[0]
version = parts[1]
} else {
imageID = image
}
// Check if image version data is cached
h.mu.RLock()
data, ok := h.imagesCache[image]
h.mu.RUnlock()
if !ok {
// Get image data from database
var err error
data, err = h.imageStore.GetImage(r.Context(), imageID, version)
if err != nil {
if errors.Is(err, hub.ErrNotFound) {
w.WriteHeader(http.StatusNotFound)
} else {
h.logger.Error().Err(err).Str("method", "Image").Str("imageID", imageID).Send()
w.WriteHeader(http.StatusInternalServerError)
}
return
}
// Save image data in cache
h.mu.Lock()
h.imagesCache[image] = data
h.mu.Unlock()
}
// Set headers and write image data to response writer
w.Header().Set("Cache-Control", helpers.BuildCacheControlHeader(StaticCacheMaxAge))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
if svg.Is(data) {
w.Header().Set("Content-Type", "image/svg+xml")
} else {
w.Header().Set("Content-Type", http.DetectContentType(data))
}
_, _ = w.Write(data)
}
// Index is an http handler that serves the index.html file.
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
// Set headers
w.Header().Set("Cache-Control", helpers.BuildCacheControlHeader(indexCacheMaxAge))
w.Header().Set("Content-Security-Policy", cspPolicy)
// Execute index template
title, _ := r.Context().Value(hub.IndexMetaTitleKey).(string)
if title == "" {
title = h.cfg.GetString("theme.siteName")
}
description, _ := r.Context().Value(hub.IndexMetaDescriptionKey).(string)
if description == "" {
description = "Find, install and publish Cloud Native packages"
}
openGraphImage := h.cfg.GetString("theme.images.openGraphImage")
if !strings.HasPrefix(openGraphImage, "http") {
openGraphImage = h.cfg.GetString("server.baseURL") + openGraphImage
}
data := map[string]interface{}{
"allowPrivateRepositories": h.cfg.GetBool("server.allowPrivateRepositories"),
"allowUserSignUp": h.cfg.GetBool("server.allowUserSignUp"),
"appleTouchIcon192": h.cfg.GetString("theme.images.appleTouchIcon192"),
"appleTouchIcon512": h.cfg.GetString("theme.images.appleTouchIcon512"),
"bannersURL": h.cfg.GetString("server.bannersURL"),
"description": description,
"gaTrackingID": h.cfg.GetString("analytics.gaTrackingID"),
"githubAuth": h.cfg.IsSet("server.oauth.github"),
"googleAuth": h.cfg.IsSet("server.oauth.google"),
"motd": h.cfg.GetString("server.motd"),
"motdSeverity": h.cfg.GetString("server.motdSeverity"),
"oidcAuth": h.cfg.IsSet("server.oauth.oidc"),
"openGraphImage": openGraphImage,
"primaryColor": h.cfg.GetString("theme.colors.primary"),
"reportURL": h.cfg.GetString("theme.reportURL"),
"secondaryColor": h.cfg.GetString("theme.colors.secondary"),
"shortcutIcon": h.cfg.GetString("theme.images.shortcutIcon"),
"siteName": h.cfg.GetString("theme.siteName"),
"title": title,
"websiteLogo": h.cfg.GetString("theme.images.websiteLogo"),
}
sampleQueriesJSON, err := getSampleQueriesJSON(h.cfg)
if err != nil {
h.logger.Error().Err(err).Msg("error reading provided sample queries to JSON, they won't be used")
} else {
data["sampleQueries"] = sampleQueriesJSON
}
if err := h.indexTmpl.Execute(w, data); err != nil {
h.logger.Error().Err(err).Msg("error executing index template")
http.Error(w, "", http.StatusInternalServerError)
}
}
// SaveImage is an http handler that stores the provided image returning its id.
func (h *Handlers) SaveImage(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
h.logger.Error().Err(err).Str("method", "SaveImage").Msg("error reading body data")
helpers.RenderErrorJSON(w, err)
return
}
imageID, err := h.imageStore.SaveImage(r.Context(), data)
if err != nil {
h.logger.Error().Err(err).Str("method", "SaveImage").Send()
helpers.RenderErrorJSON(w, err)
return
}
dataJSON := []byte(fmt.Sprintf(`{"image_id": "%s"}`, imageID))
helpers.RenderJSON(w, dataJSON, 0, http.StatusOK)
}
// FileServer sets up a http.FileServer handler to serve static files.
func FileServer(r chi.Router, public, static string, cacheMaxAge time.Duration) {
if strings.ContainsAny(public, "{}*") {
panic("FileServer does not permit URL parameters")
}
if public != "/" && public[len(public)-1] != '/' {
r.Get(public, http.RedirectHandler(public+"/", http.StatusMovedPermanently).ServeHTTP)
public += "/"
}
fsHandler := http.StripPrefix(public, http.FileServer(http.Dir(static)))
r.Get(public+"*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
file := strings.Replace(r.RequestURI, public, "/", 1)
if _, err := os.Stat(static + file); os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", helpers.BuildCacheControlHeader(cacheMaxAge))
fsHandler.ServeHTTP(w, r)
}))
}
// getSampleQueriesJSON is a helper function that reads the sample queries from
// the configuration provided and marshals them as json.
func getSampleQueriesJSON(cfg *viper.Viper) (string, error) {
var sampleQueries []map[string]string
if err := cfg.UnmarshalKey("theme.sampleQueries", &sampleQueries); err != nil {
return "", err
}
sampleQueriesJSON, err := json.Marshal(sampleQueries)
if err != nil {
return "", err
}
return string(sampleQueriesJSON), nil
}