A negative cache row was deleted only when its source URL was requested again after it expired, and CleanExpired was never called, so a client asking for a new missing path each time grew the database without limit. The eviction goroutine now calls CleanExpired at startup and on every periodic tick, so a row stays at most NegativeTTL plus one interval. The goroutine also starts with the disk cache disabled, where reconciliation and eviction already do nothing, since the negative cache is used either way. Model: opus-5-5
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
// Package handlers provides HTTP request handlers.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/pixa/internal/allowlist"
|
|
"sneak.berlin/go/pixa/internal/config"
|
|
"sneak.berlin/go/pixa/internal/database"
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/logger"
|
|
"sneak.berlin/go/pixa/internal/session"
|
|
)
|
|
|
|
// Params defines dependencies for Handlers.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Healthcheck *healthcheck.Healthcheck
|
|
Database *database.Database
|
|
Config *config.Config
|
|
|
|
// Fetcher, when provided, fetches upstream images in place of the
|
|
// fetcher the handlers build from the config. Only tests provide one;
|
|
// pixad does not.
|
|
Fetcher httpfetcher.Fetcher `optional:"true"`
|
|
}
|
|
|
|
// Handlers provides HTTP request handlers.
|
|
type Handlers struct {
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
db *database.Database
|
|
config *config.Config
|
|
fetcher httpfetcher.Fetcher
|
|
imgSvc *imgcache.Service
|
|
imgCache *imgcache.Cache
|
|
sessMgr *session.Manager
|
|
encGen *encurl.Generator
|
|
csrfProtect func(http.Handler) http.Handler
|
|
|
|
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
|
|
// reports whether a URL's host is on that list.
|
|
refererBlocklist *allowlist.HostAllowList
|
|
}
|
|
|
|
// New creates a new Handlers instance.
|
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := &Handlers{
|
|
log: params.Logger.Get(),
|
|
hc: params.Healthcheck,
|
|
db: params.Database,
|
|
config: params.Config,
|
|
fetcher: params.Fetcher,
|
|
csrfProtect: csrfProtect,
|
|
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
|
}
|
|
|
|
lc.Append(fx.Hook{
|
|
//nolint:contextcheck // the cache's goroutines outlive OnStart; OnStop stops them
|
|
OnStart: func(_ context.Context) error {
|
|
return s.initImageService()
|
|
},
|
|
// The pending counts are written here, before the database's stop
|
|
// hook closes it.
|
|
OnStop: func(ctx context.Context) error {
|
|
if s.imgCache == nil {
|
|
return nil
|
|
}
|
|
|
|
return errors.Join(
|
|
s.imgCache.StopEviction(ctx),
|
|
s.imgCache.StopPendingCountWrites(ctx),
|
|
)
|
|
},
|
|
})
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
|
// ends, and returns how many images were still being processed then.
|
|
func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|
return s.imgSvc.WaitForProcessing(ctx)
|
|
}
|
|
|
|
// newCacheConfig builds the image cache's configuration from cfg.
|
|
// cache_max_bytes: 0 disables the disk cache entirely; any other value
|
|
// is the eviction limit in bytes; when it is omitted, the cache works
|
|
// out the default limit itself.
|
|
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
|
return imgcache.CacheConfig{
|
|
StateDir: cfg.StateDir,
|
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
|
MaxBytes: cfg.CacheMaxBytes,
|
|
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
|
DisableDiskCache: cfg.CacheMaxBytesExplicit && cfg.CacheMaxBytes == 0,
|
|
Logger: log,
|
|
}
|
|
}
|
|
|
|
// initImageService initializes the image cache and service.
|
|
func (s *Handlers) initImageService() error {
|
|
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.imgCache = cache
|
|
|
|
// Background eviction: startup reconciliation, then periodic and
|
|
// write-pressure passes, and the removal of expired negative cache
|
|
// rows, which is all it does when the disk cache is disabled.
|
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
|
|
|
// Writes the counts requests could not write by their deadline
|
|
cache.StartPendingCountWrites()
|
|
|
|
// Create the fetcher config
|
|
fetcherCfg := httpfetcher.DefaultConfig()
|
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
|
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
|
|
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
|
|
|
|
if s.config.UpstreamConnectionsPerHost > 0 {
|
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
|
}
|
|
|
|
fetcherCfg.MaxConnections = s.config.UpstreamConnections
|
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
|
|
|
// Create the service. With no fetcher provided, it builds its own from
|
|
// fetcherCfg.
|
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: cache,
|
|
FetcherConfig: fetcherCfg,
|
|
Fetcher: s.fetcher,
|
|
SigningKey: s.config.SigningKey,
|
|
Allowlist: s.config.AllowlistHosts,
|
|
MaxConcurrentProcessing: s.config.MaxConcurrentProcessing,
|
|
Logger: s.log,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.imgSvc = svc
|
|
s.log.Info("image service initialized")
|
|
|
|
// Initialize session manager (signing key is validated at config load
|
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
|
sessMgr, err := session.NewManager(s.config.SigningKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.sessMgr = sessMgr
|
|
|
|
// Initialize encrypted URL generator
|
|
encGen, err := encurl.NewGenerator(s.config.SigningKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.encGen = encGen
|
|
|
|
s.log.Info("session manager and URL generator initialized")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
|
|
if data != nil {
|
|
err := json.NewEncoder(w).Encode(data)
|
|
if err != nil {
|
|
s.log.Error("json encode error", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
|
s.respondJSON(w, map[string]any{
|
|
"error": message,
|
|
"status": status,
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
}, status)
|
|
}
|