check / check (push) Waiting to run
CacheConfig.CacheTTL was set to DefaultCacheTTL (24 hours) and never read, so cached images never expired. Remove the field, the constant and the line that set it. Cached source and transformed images stay until eviction removes them to keep the cache within cache_max_bytes. A new test caches a source and a transformed image, sets back two days fetched_at in source_metadata, fetched_at and last_accessed_at in source_content, created_at and last_accessed_at in variant_content, the fetched_at in the source's metadata file, the created_at in the .meta file, and the access and modification times of those two files and of the two image files, and checks that both are still served from the cache without a new upstream fetch. Existing tests lose only the CacheTTL line of their cache config. Model: opus-5-5
205 lines
5.9 KiB
Go
205 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. No-op 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)
|
|
}
|